1.前言
计算机系统的安全性从根本上依赖于内存隔离,例如内核(Kernel)地址范围被标记为不可访问,并对用户访问加以限制和保护,因此操作系统确保了用户程序不能访问彼此的内存或内核内存。这种内存隔离是我们计算机环境的基石,它允许在个人设备上同时运行多个应用程序,或者在云中的一台机器上执行多个用户的进程。本文介绍的Meltdown利用现代处理器上乱序执行(Ouf-of-order)的副作用(side effects)来读取任意的内核内存位置,包括个人数据和密码。Meltdown攻击不依赖于任何操作系统,也不依赖于任何软件漏洞。Meltdown会破坏由地址空间隔离和虚拟化环境提供的所有安全保障,从而破坏建立在此基础上的所有安全机制。在受影响的系统上,Meltdown允许攻击者(黑客)能够在没有任何权限或特权的情况下读取云中的其它进程或虚拟机的内存,影响数百万客户和几乎每个个人计算机用户。
Meltdown是一种新颖的攻击方式,它利用了大多数现代处理器上可用的侧信道信息(side-channel)。典型易受这种攻击的CPU特点是:“允许非特权进程以特权进程从内核或物理地址加载数据到一个临时的CPU寄存器。此外,CPU甚至基于该寄存器执行进一步的计算,例如,基于该寄存器值访问数组。如果发现一条指令不应该被执行,处理器通过简单地丢弃内存查找的结果,可以确保正确的程序执行。因此,在体系结构级别(architectural level)上,不会出现安全问题。”Meltdown的攻击模式大致为:通过乱序执行CPU的指令流中读取特权内存数据,并通过非体系结构隐蔽通道(例如,Flush+Reload)将数据从内部状态传输到外部世界,在接收到重构解码数据信息。
2.背景知识
2.1乱序执行
乱序执行是一种优化技术,它允许尽可能地最大化CPU core的所有执行单元的利用率。CPU不是严格按照程序顺序处理指令,而是在所有所需资源可用时立即执行它们。当操作的执行单元被占用时,其它执行单元可以继续运行。因此,只要指令的结果符合体系结构定义,它们就可以并行运行。由于程序中经常需要跳转,CPU中会有分支预测单元,用于猜测下一个执行的指令。分支预测器试图在实际评估其条件之前确定采取分支的哪个方向。位于该路径上且没有任何依赖关系的指令可以提前执行,如果预测正确,则可以立即使用其结果。如果预测不正确,则reorder buffer允许通过清除清除中间缓冲区并重新初始化来回滚到相同的之前的状态。预测分支有多种方法:静态分支预测,仅根据指令本身预测结果。动态分支预测,在运行时收集统计信息来预测结果。一级分支预测使用1位或2位计数器记录分支的最后结果。现代处理器通常使用具有最近n个结果历史的两级自适应预测器,允许预测有规律的重复模式。
2.2地址空间
为了使进程之间相互隔离,CPU支持虚拟地址空间,其中虚拟地址需要被转换为物理地址。虚拟地址空间被划分为一组页,这些页可以通过多级页表转换单独映射到物理内存。页表定义了实际的虚拟到物理的映射以及用于特权检查的保护属性,例如可读、可写、可执行和用户可访问。当前使用的翻译表保存在一个特殊的CPU寄存器中,在每次上下文切换(context switch)时,操作系统用下一个进程的转换表地址更新这个寄存器,以便实现每个进程自己的虚拟地址空间。因此,每个进程只能引用属于其虚拟地址空间的数据。每个虚拟地址空间本身被分成一个用户和一个内核部分,虽然正在运行的应用程序可以访问用户地址空间,但内核地址空间只有在CPU以特权模式运行时才能访问。这是通过操作系统禁用相应页表的用户可访问属性来实现的。内核地址空间不仅有内核自己使用的内存映射,而且还需要在用户地址上执行操作,例如填充数据。因此,整个物理内存通常映射到内核中。在Linux和OS X上,这是通过直接物理映射完成的,也就是说,整个物理内存直接映射到一个预定义的虚拟地址。如下图所示,物理内存在内核中以一定偏移量直接映射。映射到用户空间可访问的物理地址(蓝色)也通过直接映射到内核空间中。
2.3高速缓存(cache)攻击
为了加速内存访问和地址转换,CPU包含小的内存缓冲区,称为缓存,用于存储经常使用的数据。CPU缓存通过将经常使用的数据缓冲在更小更快的内部存储器中来隐藏缓慢的内存访问延迟。现代CPU有多个级别的缓存,这些缓存要么是每个core私有的,要么是在它们之间共享的。页表也存储在内存中,因此也会缓存在常规缓存中。
缓存侧信道攻击利用缓存引入的时间差异。例如Flush+Reload是以单个缓存线(cache line)粒度进行攻击共享的、Inclusive的最后一级缓存。攻击者经常使用指令刷新目标内存位置,并测量重新加载数据所需的时间,就可以确定数据是否同时被另一个进程加载到缓存中。Flush+Reload攻击被用于攻击各种计算,例如加密算法、web服务器函数调用、用户输入和内核寻址信息。缓存侧信道攻击的一个特殊用例是隐蔽信道(covert channel)。攻击者控制产生副作用的部分和测量副作用的部分,可将信息从一个安全域泄漏到另一个安全域,同时绕过架构级别或更高级别上存在的任何边界。Prime+Probe和Flush+Reload已被用于高性能隐蔽通道。
3.Meltdown例子1
下面片段为一个简单的例子来说明乱序执行的CPU如何泄漏信息,它首先引发(暂时未被处理)异常,然后访问数组。异常的属性是控制流在异常之后不会继续执行代码,而是跳转到操作系统中的异常处理程序。不管这个异常是否由于内存访问引发的,例如,通过访问一个无效地址,还是由于任何其它除零等CPU异常。
raise_exception();
// the line below is never reached
access(byte array_A[data_x * 4096]);
因此,示例不能访问array_A数组,因为异常会立即被捕获内核并终止应用程序。然而由于乱序执行,且异常下面的指令不依赖于异常,那么CPU可能已经执行了下面的指令。如下图所示,只是由于这个异常,乱序执行的下面指令不会retire,因此不会对体系结构产生影响。
尽管乱序执行的执行对寄存器或内存没有任何可见的体系结构影响,但它们有微体系结构的副作用。在乱序执行期间,被引用的内存被取出放到寄存器中,也存储在缓存中。如果乱序执行必须被丢弃,寄存器和内存内容永远不会提交。然而,缓存的内容仍旧保存在缓存中。我们就可以利用微架构侧通道攻击,如Flush+Reload,它检测特定的内存位置是否被缓存,使这种微架构状态可见。其它侧通道也可以检测是否缓存了特定的内存位置,包括Prime+Probe,Evict+Reload,Flush+Flush。由于Flush+Reload是已知的最精确的缓存侧通道,并且易于实现,因此我们在本例中不考虑任何其他侧通道。
在本例中,当数据(data_x)乘以4096时,对array_A数组针对的数据访问分散在距离为4KB的阵列上。这样就存在从数据值到内存页的映射,即不同的数据值永远不会导致对同一页的访问。因此,如果一个页表的缓存行被缓存,就可以提取出数据(data_x)的值。尽管由于异常,array_A数组不应该被访问,但它的索引(data_x * 4096)被缓存了。可以在异常处理程序中,通过Flush+Reload测量迭代找出缓存命中的页表。这表明,即使是从未实际执行过的指令,也会改变程序的微架构状态。
4.Meltdown例子2
程序片段如下所示:
bit [511:0] array_A [256]; // 256位宽是因为cacheline大小为64Byte,256深度是因为1byte的范围是0~255
bit [7:0] array_B[8]; // 8位宽是因为作为array_A的索引,深度8无实际意义
main_function() {
clear_array_A(); // 这个函数将array_A的缓存区清除下,即cache中不存在它的数据
ldr r0 array_B[x]; // x为目标地址,非法操作,可以让x大于等于8,数组越界
ldr r1 array_A[r0 * 512];
for (i=0; i<256; i++) {
ldr r2 [array_A_base_addr + i]
}
}
程序说明:第一步先用函数clear_array_A()将array_A的内容从缓存中清除出去。第二步访问超过array_B大小的index(x),并将返回数据作为array_A的index访问内存。第三步,扫描array_A的所有元素,记录返回时间,由于之前清除了array_A的缓存区,ldr指令从缓存和内存拿数据有明显的时间差,如果扫描结果为某index元素访问最快,那么目标地址x就等于该index的值。
这样就完成了通过缓存侧信道攻击获取了不可访问内存的数据。
5.可能的解决方案
问题根源在于硬件本身,一个极端的对策是完全禁用乱序执行,但由于现代CPU的并行性无法再得到充分利用,性能影响将是巨大的。因此,这个解决方案不是很靠谱的。
Meltdown是在获取内存地址数据和相应的每个任务检查此地址之间的某种形式的竞争条件引起的。序列化权限检查和寄存器值获取可以防止Meltdown,因为如果权限检查失败,内存地址的数据永远不会被获取,因此内存获取必须暂停,直到权限检查完成。
更现实的解决方案可以引入用户空间和内核空间的硬分割。可以由kernel在CPU控制寄存器中使用一个新的硬分割位,kernel必须位于地址空间的上半部分,而用户空间必须位于地址空间的下半部分。通过这种硬分割,内存访问可以立即识别目标访问是否会违反安全界限,因为特权级别可以直接从虚拟地址产生,而无需进一步查找的。这种方案对性能的影响是最小的,而且还确保了向后的兼容性,kernel只有在支持硬分割特性时才会设置它。
在软件层面,使用KAISER在很大程度上防止了Meltdown,三大操作系统(Windows, Linux和OS X)都实现了KAISER的变体,并且推出补丁修复了。但软件只是暂时修补了,最优办法还是要从硬件层面上杜绝Meltdown的发生。