唐凌 发表于 2021-5-3 10:09:13

【处理器】详解CVE-2017-5753漏洞

本帖最后由 tangptr@126.com 于 2021-5-5 21:20 编辑


# 前言
这个CVE-2017-5753是非常著名的“2018新年CPU漏洞”之一。本文讲的是恶魂攻击(变体1,即“边界检查绕过攻击”)。
请注意: **不要将分支预测与乱序执行混为一谈,二者概念不同,并非同义词。**
恶魂攻击的Logo是一个幽灵手里拿着一根树枝。这根树枝有分叉,暗喻其攻击来自分支预测。名字中"Spectre"来自"SPECulation"(推测)一词。
![恶魂攻击的Logo图片](https://meltdownattack.com/spectre-text.png)
阅读本文时至少需要掌握处理器流水线的基本知识。

# 原始分支预测/否认式分支预测
为加速处理器流水线,流水线会引入分支预测器达到减少不必要的流水线阻塞。最原始的流水线的分支预测策略为否认式预测,即每次都预测分支不跳转。它的优点在于每次预测成功后走到正确路径的概率为100%,因为流水线获取的下一条指令就在分支指令的正后一条上。假如预测它跳转,则流水线无法不以额外的资源较准确地预测跳转位置。
正因如此,那个时代的编译器和汇编程序员会刻意将经常满足条件的代码放到分支指令之后,而将偶尔满足条件的代码放到跳转位置上。有些程序员未必知道为什么这么写会更快,但他们学汇编的时候老师通常都是这么教的。

# 现代分支预测
一旦预测的结果是错误的,处理器就必须刷新掉分支指令之后的流水线以及一般数据总线。刷新流水线是为了开始执行正确的路径的代码,刷新一般数据总线是为了避免把走错路的代码的执行结果影响到存储器上。预测错误的代价通常不会小,尤其是对于拥有复杂流水线的处理器(所谓复杂流水线通常是拥有动态调度能力的处理器,比如引入了Tomasulo调度法的。可以认为现代处理器都拥有复杂流水线)而言,预测错误可能会导致数百甚至上千个时钟周期的浪费。因此就必须为流水线设计一个专有的分支预测器用于减少预测错误。通常而言,现代分支预测器要预测两点:

1. 是否产生跳转(是否为跳转指令?是否为无条件跳转?条件跳转的条件是否满足?)
2. 跳转位置(跳转目标的`rip`是多少)

要实现第一点,分支预测器会维护一张(组)表,这张表存储了不同`rip`的值它的跳转规律是怎么样的。并基于此预测是否产生跳转。可以认为**现代分支预测器相当于一个根据`rip`的值查询跳转规律和目标地址的字典式数据结构并拥有分析能力以实现预测的组件。**
由于与恶魂攻击(变体1)无关,第二点省略。

# 缓存
对现代处理器而言,访问内存消耗的时间通常很长,为缩短这个时间,处理器引入了高速缓存解决问题。如果一片内存在很长一段时间内都未被访问过,那它就不太可能会留在缓存上。若访问的内存不在缓存上,则产生缓存失误,处理器将从内存上读取内容并存到缓存上以加速后续指令对这片内存的访问。若访问的内存在缓存上,则产生缓存命中,内存访问指令的延迟将会被大幅缩短。

# 致命缺陷/侧信道
如前文所言,分支预测若预测失误,则要刷新掉流水线以及一般数据总线。所谓刷新一般数据总线,包括恢复被写入的寄存器,内存等。但现代分支预测器的致命缺陷在于没有刷新掉预测错误造成的新增缓存。或许处理器的设计师想到了这一点,但认为缓存放在那不用白不用,缓存里的内容也不是错的,那为何不留在缓存里呢。但是,这个缓存的存在是可以被感知到的。一段不该存在于缓存上的内存出现在了缓存上就形成一种侧信道,被恶魂漏洞(变体1)的攻击者所利用。
所谓侧信道,即一个过程所产生的不容易被注意到的额外作用。这种额外作用会被攻击者所利用。恶魂漏洞就是利用了分支预测错误造成多余缓存这一点形成的的侧信道,再与缓存与内存的速度差造成的侧信道结合起来实施攻击的。

# 攻击算法
恶魂攻击(变体1)的目标是读取受软件级隔离保护的内存。下列伪码演示了一种软件级隔离保护措施。
```C
#define Limit x
UINT8 Buffer;   // 即便 Limit>=SIZE也无所谓.
UINT8 ReadByteIsolated(IN UINTN Offset)
{
    if(Offset>=Limit)
      return 0;
    else
      return Buffer;        // 当Offset>=Limit时,预测错误的流水线仍然会执行这一行代码。
}
```
攻击方法的步骤如下:

1. 确定已缓存内存和未缓存内存之间的访问时间,以此确定区分已缓存内存与未缓存内存之间的时间门槛。
2. 分配256个页(通常而言一条缓存线不会大于一个页)。并刷新掉它们的缓存(可以使用SSE2的`clflush`指令刷新特定地址,注意`clflush`不是特权指令,因此可以在用户模式使用)。
3. 训练分支预测器的行为,让它们在`ReadByteIsolated`函数的分支中总是走向`else`的代码。由于现代分支预测器拥有根据`rip`的值查询跳转规律的能力,训练它其实很简单:反复传一个满足条件的参数即可。通常而言如果几千次都会走到`else`分支,那么处理器的分支预测器一般都会在下一次预测走向`else`分支。若是保险起见也可以走上万次。
4. 训练的差不多后,以此函数访问一个被隔离在外的地址,此时分支预测一般都会预测错误,虽然最终都会返回0,但在处理器刷新流水线之前,你已经拥有了这个字节的值。
5. 在处理器刷新流水线之前,以这个字节的值作为索引,访问那256个页的其中一个从而把它放进缓存里。**这一步骤对时间十分敏感。** 如果处理器在你读那个页之前意识到预测错误从而刷新了流水线的话,你将没有机会以该字节作为索引访问页组。如果处理器预测对了,那就需要在第三步加大针对分支预测器的训练力度。
6. 由于`Offset`参数不在范围之内,故原则上`ReadByteIsolated`函数返回的值是零,再加上那256个页的缓存在第二步时都被刷新掉了,那么这256个页中应该只有第零个是被缓存了的。但由于处理器保留了预测错误所造成的新增的缓存,因此除了第零页以外,还有一个页也被放到缓存上了。这个页的索引就是被偷取的字节的值。这也是为什么要分配256个页了。访问每一个页并测量访问耗时(需要用`rdtscp`之类的能等到其他指令完成后再计时的指令,`rdtsc`就不行),如果访问时间较短就说明这个页在缓存上。
7. 指向已缓存页的索引值就是被偷取出来的字节的值。保存好这个字节。
8. 重复第2-7步直到所有要偷取的字节都被偷出来了。但在第二步别去申请页了。

值得注意的是,传入的`Offset`参数未必要在`Buffer`的范围以内。它可以是任意可以访问的地址的偏移量。

# 攻击防范
由于恶魂攻击(变体1)依赖于预测错误时的窗口期,因此如果能在隔离资源的函数返回前保证处理器意识到预测错误,即消除掉这个窗口期,那么攻击者就无法拿到预测错误时的返回值了。最朴素的防范措施是在执行流走到攻击者的代码前插入一个序列化指令以排空流水线,比如用`iret`指令取代`ret`指令实现函数返回,不过它会与[影栈机制](https://www.0xaa55.com/thread-26280-1-1.html)产生冲突。如果要同时兼容影栈机制,可以选择在每次分支后的内存访问时插入内存障碍(如`lfence`、`sfence`、`mfence`指令等实现序列化(这也是MSVC防范恶魂攻击(变体1)的处理方法)。对流水线实施序列化固然会严重影响性能,但为了安全起见可以不用在意这点性能损失。通常而言,不需要对所有的分支都插入序列化,只需要对于关键部位的分支(比如密码验证)插入序列化即可。如果编译器支持,可以对特定函数加上标识,表示这个函数无需/必须防范恶魂漏洞。MSVC自Visual C++ 2017 15.5.5版本开始支持[这种标识](https://docs.microsoft.com/en-us/cpp/cpp/spectre?view=msvc-160)。
值得一提的是,将网页浏览器设计为每个网页一个进程是一种非常好的防范恶魂攻击的设计模式。由于切换进程需要切换页表寄存器,几乎所有的处理器都会对这样的指令实施流水线序列化(比如x86的`mov crn,reg`指令)。如此一来,即便是处理器、操作系统、浏览器自身都没有特别防范恶魂攻击,也可以做到防范某些恶意网页试图从其他网页上偷取信息。
操作系统通常无法简单地阻止同进程之下的恶魂攻击(变体1)。但操作系统可以通过更新处理器微码以修正处理器的漏洞。有意思的是处理器微码更新会在关机或重启的时候消失,所以每次启动都要更新微码。这个过程既可以由操作系统实现,也可以由固件实现。另外,操作系统(包括驱动程序)有必要对它提供的接口里关键分支代码做出防范,以防止恶魂攻击偷取内核内存。比如Windows会在系统调用的入口处切换页表序列化一下流水线。

# 其他资料
这里列举一些可供参考的资料

- 恶魂攻击的[官方论文](https://spectreattack.com/spectre.pdf)
- MSVC的(https://docs.microsoft.com/en-us/cpp/build/reference/qspectre?view=msvc-160)编译器选项
- LLVM对于恶魂攻击的[防范](https://llvm.org/docs/SpeculativeLoadHardening.html)
- 美国雪城大学(Syracuse University)杜文亮教授撰写的(https://seedsecuritylabs.org/Labs_20.04/System/Spectre_Attack/)可自行上手操作进行实验

Golden Blonde 发表于 2021-5-5 16:33:13

虽然看不懂但是也装作看懂了并回复一下。

0xAA55 发表于 2021-5-5 18:15:22

我试着看了一下“如何攻击”,然后想不出头绪

大能猫 发表于 2021-5-11 05:55:39

本帖最后由 大能猫 于 2021-5-11 06:00 编辑

啊。。。前几天看到这个贴,花了些时间去恶补了下现代CPU的分支预测和流水线的知识(以前只是知道有乱序执行和分支预测这一回事,不知道具体的原理),感觉算是看懂了这个攻击的思路,所以分享下吧。这里如果不懂流水线和分支预测的内容可以看看这个 系列贴 ,感觉写的挺简洁也挺不错的,不过乱序执行的记分板算法和Tomasulo算法写的有点简略,所以我找了点资料记了个 笔记 ,如果对这两个算法有疑惑看看这个笔记可能会有些帮助。此外唐大佬给的SEEDLAB链接中的实验代码也可以增进理解

下面进入正题,帖子里对攻击场景的描述感觉有点简练,实际上按我理解是这样的

// 下面函数是某个库的代码,如可以是一个系统dll。它需要限制用户程序对内存的访问(比如这段代码可能是一段内核态的代码)

#define Limit x
UINT8 Buffer;   // 即便 Limit>=SIZE也无所谓.
UINT8 ReadByteIsolated(UINT Offset)
{
if(Offset>=Limit)
    return 0;
else
    return Buffer;// 当Offset>=Limit时,预测错误的流水线仍然会执行这一行代码。
}


// 下面是用户代码
int ReadByte(UINT index)
{
char a = ReadByteIsolated(index);
}

上面的ReadByte是一个正常场景中对ReadByteIsolated函数的使用。
要理解该漏洞首先要理解CPU的分支预测与流水线机制。ReadByteIsolated的汇编可能如下(godbolt,yyds),这里49是随便选的一个Limit值(其实Limit值是50,这里编译成49是因为编译器调换了跳转的次序,不懂的话可以看汇编想想,虽然这不是重点)

      push    rbp
      mov   rbp, rsp
      mov   DWORD PTR -4, edi
      cmp   DWORD PTR -4, 49
      jbe   .L2
      mov   eax, 0
      jmp   .L3
.L2:
      mov   rdx, QWORD PTR Buffer@GOTPCREL
      mov   eax, DWORD PTR -4
      movzx   eax, BYTE PTR
.L3:
      pop   rbp
      ret

首先因为分支预测器的一个常见形式是BHT或者BTB,所以如果一条跳转指令多次执行的结果都是跳转,那么下次执行预测器也会倾向于预测这次执行结果也是跳转。
又因为流水线的存在将一条指令拆成了不同的执行阶段,对于跳转指令也一样,也就是说跳转指令在还未确定最后是否跳转的情况下,流水线就已经会继续从分支预测器预测的地址继续取指令执行了。
此外又因为诸如Tomasulo算法的使用,流水线可以实现这样的操作:如对于 mul eax,ebxmov ecx,add ecx,ecx 这样一个指令序列,由于每条指令用到了不同的运算部件,所以即使执行mul需要花费大量时间,它也不会阻塞流水线,因此实际上在mul指令执行完时,其后的两条指令可能早已执行完了。

在以上几个因素的综合影响下,上面的代码就可能出现这样的bug:
1.因为前几次读取的都是范围内的数据,因此分支预测器会倾向于预测此次读取也在范围内,即预测分支会成功跳转到L2
2.cmp DWORD PTR -4, 49和jbe .L2可能因为种种原因需要执行很长时间,比如访问的内存此时不在cache内。这就会导致处理器迟迟无法判断跳转成功与否(一旦确定了跳转是否成功,如果处理器发现实际跳转结果与预测的不一致,将会丢弃此前预取的指令的运行结果)
3.如果运气好,直到ReadByteIsolated返回可能跳转判断都未完成,此时eax存放的值就会是Buffer,即我们想要越界访问的值
但此时eax中的值是无法直接使用的,因为不管我们对这个值做什么操作,比如赋值给其他寄存器、赋值给内存等等,由于当前运行的操作都还是处理器预测的操作,因此一旦处理器发现跳转失败,所有的执行结果都会被撤销。这里我类比下git,预测执行的指令有点类似暂存区的东西,如果处理器判断跳转结果与预测结果相同,那就会把更改commit上去,否则就直接reset

但研究人员发现的漏洞就在这里了:即使预测失败,处理器也不会reset新加入cache的内容。
意思就是如果有下面的代码:
char x = {0,0};
if(cond)
x = 1;
假如x一开始没在cache内,如果在执行这段代码时,分支预测器预测条件成立但实际上不成立,那么x虽然仍旧是0,但x此时会被加载进cache

而我们都知道数据没加载进cache与加载进cache相比访问速度慢了非常多
所以对于一开始的那个例子,可以写这样一段攻击代码
char buffer;// 这里256是为了对应一个字节,4096可以是其他值,只要这个值大于当前处理器的cacheline大小即可
int ReadByte(UINT index)
{
char a = ReadByteIsolated(index);
buffer = 0;
}

注意,在进行攻击前,必须将buffer从cache中清空(使用clflush指令)
那么按照上面的说法,若是出现了index越界访问,但实际跳转地址迟迟无法确定,使得越界访问内容由于CPU流水线的继续执行被暂时赋值给a的情况。此时使用a作为下标访问buffer就会使得对应的内存地址被加载进cache
接下来等到跳转地址确定后,先前由于预测错误执行的指令造成的副作用都会被reset掉,但由于上述的漏洞,buffer还会留在cache中
那么我们只需要依次访问buffer buffer ... buffer,并计算访问时间,就可以确定buffer的哪个部分被加载进了cache,其对应的下标就是a

唐凌 发表于 2021-5-11 07:34:37

大能猫 发表于 2021-5-11 05:55
啊。。。前几天看到这个贴,花了些时间去恶补了下现代CPU的分支预测和流水线的知识(以前只是知道有乱序执 ...

辛苦了,不过恶魂攻击和乱序执行无关。你也不用非得把汇编搬出来讲。

大能猫 发表于 2021-5-11 13:44:42

tangptr@126.com 发表于 2021-5-11 07:34
辛苦了,不过恶魂攻击和乱序执行无关。你也不用非得把汇编搬出来讲。

还是有一定关联的吧,因为按我理解就是因为乱序执行的机制存在,所以才会使得处理器在跳转结果还没确定的情况下就会先执行预测地址的指令。不过感觉乱序执行好像也不能算机制,而是现代CPU流水线设计导致的一个现象

唐凌 发表于 2021-5-11 18:16:22

大能猫 发表于 2021-5-11 13:44
还是有一定关联的吧,因为按我理解就是因为乱序执行的机制存在,所以才会使得处理器在跳转结果还没确定的 ...

我能根据你这话认为你白读了那些资料么?
流水线的设计本来就使得跳转结果无法在第一阶段(如Issue,Fetch阶段等)确定,所以才有分支预测。
以MIPS的顺序五级流水线为例:无条件跳转如果不能预测就要停滞一级,直到ID阶段完成才能继续;条件跳转如果预测错了就要停滞两级,直到EX阶段完成才能继续。
所以我文章一开始就强调了分支预测和乱序执行无关。至于你说“乱序执行是现代处理器流水线的现象”倒是没错。

大能猫 发表于 2021-5-11 19:37:59

本帖最后由 大能猫 于 2021-5-11 19:51 编辑

tangptr@126.com 发表于 2021-5-11 18:16
我能根据你这话认为你白读了那些资料么?
流水线的设计本来就使得跳转结果无法在第一阶段(如Issue,Fetc ...
是这样,可能我之前表述有问题。我的原意应该说是现代处理器流水线设计使得指令会被乱序执行,也使得处理器需要一个分支预测的器件,就是说分支预测和乱序执行都是为了契合流水线设计而诞生的不同产物。之前的表述有点类似把这句话反过来说了,所以会让人觉得把这两者混为一谈,有些歧义
包括我在回帖里提及乱序执行也是因为我觉得如果读者没有对现代处理器的流水线设计有一定了解,而只是知道现代处理器存在乱序执行和分支预测的现象,可能无法直接理解为什么分支预测会使得处理器在还没确定跳转结果的情况下就预取并执行部分指令。而乱序执行这个词在我理解跟顺序执行是相对的,也就是描述一种不完全按照汇编所描述的顺序执行的行为;分支预测使得处理器在不知道跳转结果的情况下先预运行了预测地址的指令也算是一种“乱序”吧
页: [1]
查看完整版本: 【处理器】详解CVE-2017-5753漏洞