从学习编程的小童鞋到如今成为安全砖家,我们大概都经历了一个过程,即一开始我们把各种编码漏洞归因于病毒和各种外部因素,比如排序例程时有一半的数据丢失了,我们可能会想 “这可能是Windows病毒造成的”或是 “Java编译器今天的运行是不是出现漏洞了”,等成为有经验的程序员后,我们就会将编程漏洞归因于代码本身,不过这些都不是最致命的原因。今天就说说我最近的一些新发现,本文是我在尝试调试神奇的OCaml时,所发现的设备本身的问题——Intel Skylake处理器的漏洞。
2016年4月下旬,OCaml 4.03.0发布后不久,就有SIOU(我们的一个用户)发现当他们用OCaml编写一个应用程序,并用OCaml 4.03.0编译时,程序就会经常崩溃,更要命的是,在查找问题的时候,崩溃会在代码中的不同地方发生,让人无法下手解决。而且,这些崩溃只是在那些运行Intel Skylake处理器的计算机上才发生。
起初,都以为是程序本身的问题
虽然过去OCaml也出现过许多漏洞,但是这次的却极为特别。为什么只有Skylake处理器会发生这样的情况呢?事实上,我无法在Inria的电脑上模拟使用SIOU二进制码的崩溃事件,因为我运行的都是旧版本的Intel处理器。另外, SIOU的应用程序是单线程的,没有网络I/O,只有文件I/O,所以它的执行应该是完全确定的,这样推断下来,任何导致segfault段错误的漏洞都可能使运行程序发生崩溃。
我的第一个猜测是SIOU的运行硬件是否出现了问题,比如内存芯片坏了?硬件过热?从个人经验来看,如果这些事情发生了,可能会导致一台计算机启动并运行一个GUI,然后在负载下崩溃。所以,我建议SIOU进行内存测试,对其处理器进行低频处理,并禁用超线程。 禁用超线程的建议还是来自于一个涉及AVX向量算法的Skylake漏洞的报告,该漏洞只会显示超线程启用。
但SIOU认为他们在Skylake设备上运行其他CPU和内存密集型测试都没有错误发生,只有在OCaml写入测试时才会崩溃。显然,他们非常相信他们的硬件是没有问题的,这个漏洞应该就在程序本身。
同时,SIOU进行了一个令人印象深刻的检测,他们改变了OCaml的版本,用于编译OCaml的运行时系统的C编译器和操作系统。具体检测配置如下:
OCaml:4.03,包括早期betas,而不是4.02.3。
C编译器:GCC,但不包括Clang。
操作系统:Linux和Windows,但不包括MacOS。由于MacOS使用Clang,而他们使用基于GCC的Windows端口。
当然,SIOU推测,在OCaml 4.03运行时系统中,有一个有漏洞的C代码即一个未定义的行为。因为C编译器被允许在存在未定义的行为,所以可以导致GCC生成崩溃的设备代码。
原来是硬件的问题
看到这里,你也许会认为上面的解释似乎是可信的,但仍然没有解释崩溃的随机性质。当GCC基于未定义的行为生成奇怪的代码时,它仍然会生成确定性代码。我可以想到的唯一的随机来源是ASLR。 OCaml运行时系统在某些地方使用绝对地址,例如索引到内存页的哈希表。然而,在关闭ASLR后,特别是在GDB调试器下运行时,崩溃仍然是随机的。
2016年5月初,我首次尝试构建一个调试版本的OCaml 4.03(稍后我计划添加更多调试工具),并使用此版本的OCaml重建SIOU的应用程序。不幸的是,这个调试版本不会触发崩溃。不过,我却从SIOU提供的可执行文件中触发了崩溃,首先在GDB下交互运行(请注意,我是等待一个小时才触发的崩溃),然后使用一个运行程序的OCaml脚本1000次并保存了每次崩溃产生的核心转储。
调试OCaml运行时系统并没有发现异常,但从核心转储进行验证后调试就开始出现异常了。 30个核心转储的分析显示,在7个不同的地方发生了崩溃,OCaml GC中有2个,应用中有5个,其中来自OCaml的GC的mark_slice函数所引发的崩溃占了50%。经过检测,OCaml堆已损坏,因为格式完整的数据结构也包含了带有漏洞的指针,即不指向Caml块的第一个字段的指针,而是指向标题或Caml中间的指针,甚至无效的内存。 mark_slice中的15个崩溃都是由位于4字代码块之前的两个词的指针引起的。
所有这些现象都与熟悉的漏洞一致,例如ocamlopt编译器忘记使用GC注册内存根。但是,这些漏洞能否导致可重复的崩溃,则完全取决于分配和GC模式。不过,在检测时,我完全没有看到OCaml可能导致随机崩溃的内存管理漏洞。
于是,我再次把问题的原因盯在硬件漏洞。因为根据经验判断,崩溃发生得越频繁,设备的漏洞概率就越多,比如硬件过热的,就会导致各种问题。为了测试我的这个想法,我修改了OCaml脚本,并行运行N个程序的副本。对于一些运行,我还禁用了OCaml内存压缩器,从而导致更大的内存占用更多的GC活动。虽然结果不符合我的预期,但仍然引人反思:
上面给出的故障次数是1000次测试程序,不知你是否注意到N = 2和N = 4之间的跳转,为了解释这些,我需要提供更多关于测试Skylake设备的信息。 超线程具有4个物理内核和8个逻辑内核。其中两个内核在后台忙于两个长时间运行的测试,因此系统负载是2 + N + epsilon,其中N是我运行的测试次数。
当同时不超过4个活动进程时,运行系统调度程序将它们均匀分布在设备的4个物理内核之间,并尝试在不同物理内核的两个逻辑核心上调度两个进程,否则就将导致其他物理核心资源利用不足。这是N = 1的情况,也是N = 2的大部分情况。当活动进程的数量增加到4以上时,运行系统开始利用超线程将进程调度到具有相同物理内核的两个逻辑内核,这是N = 4的情况。只有当设备的所有8个逻辑核心都忙碌时,操作系统才能在进程之间进行正常的时间分配。在我的实验中N = 8和N = 16就是这种的情况。
现在显而易见的是,只有当超线程运行时,或更准确地说,当OCaml程序沿着处理器的同一物理核心上的另一个超线程(逻辑内核)运行时,才发生崩溃。
于是,我向SIOU反馈了我的发现,希望他们接受我的理论即一切都与超线程有关。2017年1月6日,Engrefrand Decorne和Ahrefs的Jooris Giovannangeli也在OCaml 4.03.0发现了随机崩溃,不过这次是Caml漏洞跟踪器上的PR#7452。
在他们发现的重载问题中,错误有时发生在ocamlopt.opt编译器本身,有时在编译大型源文件时会崩溃或产生无意义的输出。于是,我和对方取得了联系,并提到了2016年在SIOU发生的随机事件以及禁用超线程的建议。
第二天,Joris Giovannangeli证实,当超线程被禁用时,崩溃就不能被复制。同时发现,只有当OCaml运行时且系统使用gcc -O2构建时而不是使用gcc -O1,才会发生崩溃。这解释了调试OCaml运行时和OCaml 4.02没有崩溃发生的原因,因为默认情况下都使用gcc -O1构建。
于是我得出了一个结论:OCaml 4.03运行时的gcc -O2是否会产生一个特定的指令序列,导致Skylake处理器出现问题。
总结
2017年5月26日,用户“ygrek”从Debian“微码”软件包中发布了以下Changelog条目的链接:
* New upstream microcode datafile 20170511 [...] * Likely fix nightmare-level Skylake erratum SKL150. Fortunately, either this erratum is very-low-hitting, or gcc/clang/icc/msvc won't usually issue the affected opcode pattern and it ends up being rare. SKL150 - Short loops using both the AH/BH/CH/DH registers and the corresponding wide register *may* result in unpredictable system behavior. Requires both logical processors of the same core (i.e. sibling hyperthreads) to be active to trigger, as well as a "complex set of micro-architectural conditions"
可以看出,英特尔在2017年4月份记录了SKL150,并在第六代英特尔处理器的规范更新的第65页中进行了描述,类似的漏洞矫正都是基于以Skylake架构为变体的SKW144,SKX150,SKZ7,KBL095。
相信SKL150的另一个原因是,在编译OCaml运行时系统时,GCC生成了这个勘误表中概述的有问题的代码模式。例如,在byterun / major_gc.c中,函数sweep_slice引发了以下的C代码:
hd = Hd_hp (hp); /*...*/ Hd_hp (hp) = Whitehd_hd (hd);
经过宏观扩张,就成为:
hd = *hp; /*...*/ *hp = hd & ~0x300;
Clang编译这个代码的方法很明显,只用全型字寄存器(full-width register):
movq (%rbx), %rax [...] andq $-769, %rax # imm = 0xFFFFFFFFFFFFFCFF movq %rax, (%rbx)
但是,gcc更喜欢使用%ah 8位寄存器对完整寄存器%rax第的8位到第15进行操作,其他位不变:
movq (%rdi), %rax [...] andb $252, %ah movq %rax, (%rdi)
两个代码在功能上是等效的, GCC选择代码的一个可能原因是它更紧凑,8位常量$ 252适合1个字节的代码,而32位扩展到64位常量$ -769需要4个字节的代码。无论如何,GCC生成的代码确实使用%rax和 %ah,并且根据优化级别的不同,这样的代码可能会足以触发SKL150漏洞的循环。所以硬件漏洞才是问题的根本。
译者注:2017.6.26,Linux Debian团队就发出警告,指出Intel Skylake、Kaby Lake处理器的HT超线程有问题,建议大家立即禁用HT超线程,等待Intel升级BIOS、微代码等。