本帖最后由 tangptr@126.com 于 2021-5-5 21:20 编辑
前言
这个CVE-2017-5753是非常著名的“2018新年CPU漏洞”之一。本文讲的是恶魂攻击(变体1,即“边界检查绕过攻击”)。
请注意: 不要将分支预测与乱序执行混为一谈,二者概念不同,并非同义词。
恶魂攻击的Logo是一个幽灵手里拿着一根树枝。这根树枝有分叉,暗喻其攻击来自分支预测。名字中"Spectre"来自"SPECulation"(推测)一词。
阅读本文时至少需要掌握处理器流水线的基本知识。
原始分支预测/否认式分支预测
为加速处理器流水线,流水线会引入分支预测器达到减少不必要的流水线阻塞。最原始的流水线的分支预测策略为否认式预测,即每次都预测分支不跳转。它的优点在于每次预测成功后走到正确路径的概率为100%,因为流水线获取的下一条指令就在分支指令的正后一条上。假如预测它跳转,则流水线无法不以额外的资源较准确地预测跳转位置。
正因如此,那个时代的编译器和汇编程序员会刻意将经常满足条件的代码放到分支指令之后,而将偶尔满足条件的代码放到跳转位置上。有些程序员未必知道为什么这么写会更快,但他们学汇编的时候老师通常都是这么教的。
现代分支预测
一旦预测的结果是错误的,处理器就必须刷新掉分支指令之后的流水线以及一般数据总线。刷新流水线是为了开始执行正确的路径的代码,刷新一般数据总线是为了避免把走错路的代码的执行结果影响到存储器上。预测错误的代价通常不会小,尤其是对于拥有复杂流水线的处理器(所谓复杂流水线通常是拥有动态调度能力的处理器,比如引入了Tomasulo调度法的。可以认为现代处理器都拥有复杂流水线)而言,预测错误可能会导致数百甚至上千个时钟周期的浪费。因此就必须为流水线设计一个专有的分支预测器用于减少预测错误。通常而言,现代分支预测器要预测两点:
- 是否产生跳转(是否为跳转指令?是否为无条件跳转?条件跳转的条件是否满足?)
- 跳转位置(跳转目标的
rip 是多少)
要实现第一点,分支预测器会维护一张(组)表,这张表存储了不同rip 的值它的跳转规律是怎么样的。并基于此预测是否产生跳转。可以认为现代分支预测器相当于一个根据rip 的值查询跳转规律和目标地址的字典式数据结构并拥有分析能力以实现预测的组件。
由于与恶魂攻击(变体1)无关,第二点省略。
缓存
对现代处理器而言,访问内存消耗的时间通常很长,为缩短这个时间,处理器引入了高速缓存解决问题。如果一片内存在很长一段时间内都未被访问过,那它就不太可能会留在缓存上。若访问的内存不在缓存上,则产生缓存失误,处理器将从内存上读取内容并存到缓存上以加速后续指令对这片内存的访问。若访问的内存在缓存上,则产生缓存命中,内存访问指令的延迟将会被大幅缩短。
致命缺陷/侧信道
如前文所言,分支预测若预测失误,则要刷新掉流水线以及一般数据总线。所谓刷新一般数据总线,包括恢复被写入的寄存器,内存等。但现代分支预测器的致命缺陷在于没有刷新掉预测错误造成的新增缓存。或许处理器的设计师想到了这一点,但认为缓存放在那不用白不用,缓存里的内容也不是错的,那为何不留在缓存里呢。但是,这个缓存的存在是可以被感知到的。一段不该存在于缓存上的内存出现在了缓存上就形成一种侧信道,被恶魂漏洞(变体1)的攻击者所利用。
所谓侧信道,即一个过程所产生的不容易被注意到的额外作用。这种额外作用会被攻击者所利用。恶魂漏洞就是利用了分支预测错误造成多余缓存这一点形成的的侧信道,再与缓存与内存的速度差造成的侧信道结合起来实施攻击的。
攻击算法
恶魂攻击(变体1)的目标是读取受软件级隔离保护的内存。下列伪码演示了一种软件级隔离保护措施。
#define Limit x
UINT8 Buffer[SIZE]; // 即便 Limit>=SIZE也无所谓.
UINT8 ReadByteIsolated(IN UINTN Offset)
{
if(Offset>=Limit)
return 0;
else
return Buffer[Offset]; // 当Offset>=Limit时,预测错误的流水线仍然会执行这一行代码。
}
攻击方法的步骤如下:
- 确定已缓存内存和未缓存内存之间的访问时间,以此确定区分已缓存内存与未缓存内存之间的时间门槛。
- 分配256个页(通常而言一条缓存线不会大于一个页)。并刷新掉它们的缓存(可以使用SSE2的
clflush 指令刷新特定地址,注意clflush 不是特权指令,因此可以在用户模式使用)。
- 训练分支预测器的行为,让它们在
ReadByteIsolated 函数的分支中总是走向else 的代码。由于现代分支预测器拥有根据rip 的值查询跳转规律的能力,训练它其实很简单:反复传一个满足条件的参数即可。通常而言如果几千次都会走到else 分支,那么处理器的分支预测器一般都会在下一次预测走向else 分支。若是保险起见也可以走上万次。
- 训练的差不多后,以此函数访问一个被隔离在外的地址,此时分支预测一般都会预测错误,虽然最终都会返回0,但在处理器刷新流水线之前,你已经拥有了这个字节的值。
- 在处理器刷新流水线之前,以这个字节的值作为索引,访问那256个页的其中一个从而把它放进缓存里。这一步骤对时间十分敏感。 如果处理器在你读那个页之前意识到预测错误从而刷新了流水线的话,你将没有机会以该字节作为索引访问页组。如果处理器预测对了,那就需要在第三步加大针对分支预测器的训练力度。
- 由于
Offset 参数不在范围之内,故原则上ReadByteIsolated 函数返回的值是零,再加上那256个页的缓存在第二步时都被刷新掉了,那么这256个页中应该只有第零个是被缓存了的。但由于处理器保留了预测错误所造成的新增的缓存,因此除了第零页以外,还有一个页也被放到缓存上了。这个页的索引就是被偷取的字节的值。这也是为什么要分配256个页了。访问每一个页并测量访问耗时(需要用rdtscp 之类的能等到其他指令完成后再计时的指令,rdtsc 就不行),如果访问时间较短就说明这个页在缓存上。
- 指向已缓存页的索引值就是被偷取出来的字节的值。保存好这个字节。
- 重复第2-7步直到所有要偷取的字节都被偷出来了。但在第二步别去申请页了。
值得注意的是,传入的Offset 参数未必要在Buffer 的范围以内。它可以是任意可以访问的地址的偏移量。
攻击防范
由于恶魂攻击(变体1)依赖于预测错误时的窗口期,因此如果能在隔离资源的函数返回前保证处理器意识到预测错误,即消除掉这个窗口期,那么攻击者就无法拿到预测错误时的返回值了。最朴素的防范措施是在执行流走到攻击者的代码前插入一个序列化指令以排空流水线,比如用iret 指令取代ret 指令实现函数返回,不过它会与影栈机制产生冲突。如果要同时兼容影栈机制,可以选择在每次分支后的内存访问时插入内存障碍(如lfence 、sfence 、mfence 指令等实现序列化(这也是MSVC防范恶魂攻击(变体1)的处理方法)。对流水线实施序列化固然会严重影响性能,但为了安全起见可以不用在意这点性能损失。通常而言,不需要对所有的分支都插入序列化,只需要对于关键部位的分支(比如密码验证)插入序列化即可。如果编译器支持,可以对特定函数加上标识,表示这个函数无需/必须防范恶魂漏洞。MSVC自Visual C++ 2017 15.5.5版本开始支持这种标识。
值得一提的是,将网页浏览器设计为每个网页一个进程是一种非常好的防范恶魂攻击的设计模式。由于切换进程需要切换页表寄存器,几乎所有的处理器都会对这样的指令实施流水线序列化(比如x86的mov crn,reg 指令)。如此一来,即便是处理器、操作系统、浏览器自身都没有特别防范恶魂攻击,也可以做到防范某些恶意网页试图从其他网页上偷取信息。
操作系统通常无法简单地阻止同进程之下的恶魂攻击(变体1)。但操作系统可以通过更新处理器微码以修正处理器的漏洞。有意思的是处理器微码更新会在关机或重启的时候消失,所以每次启动都要更新微码。这个过程既可以由操作系统实现,也可以由固件实现。另外,操作系统(包括驱动程序)有必要对它提供的接口里关键分支代码做出防范,以防止恶魂攻击偷取内核内存。比如Windows会在系统调用的入口处切换页表序列化一下流水线。
其他资料
这里列举一些可供参考的资料
|