利用Spectre(幽灵)实现Meltdown攻击
Meltdown攻击通过利用Intel微处理器结构的设计缺陷,通过乱序执行时的计算痕迹建立信道,从而实现越界访问,进而攻击内核空间的地址。然而朴素的Meltdown攻击由于会频繁触发缺页错误,导致攻击程序提前退出,无法持续的对靶地址进行持续的访问。所以,常见的解决方式包括:
- 使用自定义的SEGV处理函数,接住系统抛出的异常,从而不退出攻击程序
- 利用Intel的TSX,建立事务同步扩展,在子线程里完成攻击,子线程退出后仍然不终止攻击
对这两种感兴趣的话可以看:
在这篇笔记中,主要介绍第三种处理异常退出攻击的方式:Spectre(幽灵)攻击。具体的说,通过处理器分支预测算法的设计缺陷,在错误的分支预测被系统发现之前的间隙,完成meltdown攻击。
分支预测
分支预测是Spectre攻击(准确的说是Spectre_v1
)的核心。简单来说,为了提升性能,系统不会等到分支条件语句的计算结果得出后,再开始执行之后的代码。相反的,处理器会预测一个(或多个)分支的可能结果,并且提前准备它们的执行。如果随后验证发现预测正确,那么就节省了等待的时间,反之,如果预测失败,那么处理器会消除之前运行的痕迹,并且从头执行正确的指令,相反降低了效率。虽然分支预测算法有利有弊,但是实际中,由于用户的代码中的分支结果其实很容易被预测,比如for循环10000次,只会在最后一次分支的时候跳出循环,所以简单预测“不跳出循环”即可获得99.99%的准确度。诸如此类。也正是因为分支预测带来的性能提升,处理器公司不会选择主动放弃它带来的市场优势,即便在知道算法背后的安全隐患。
那么如何利用分支预测攻击呢?以下代码为例,secret和addr分别是攻击者的目标信息和目标地址。直接对地址进行访问(*addr
)会触发系统警报,甚至可能直接退出程序,因为用户没有取得这个信息的权限。但是,如果我们对外层套一个分支的壳子作为伪装,情况就不一样了:
if false:
*addr // addr = &secret;
- 首先,处理器分支预测,猜测之后会执行(
*addr
)的命令,于是在还没有判断出if
结果的情况下就提前执行了。 - 随后,处理器得到
false
的结果,发现之前判断错误,于是自己偷偷删除了之前的运行过程,由于系统以为是自己犯的错,于是不会触发对于用户一开始是否“有权限执行该语句”的判断。 - 最后,用户顺利浑水摸鱼的执行了一条有害指令。
总结起来,幽灵攻击的过程分为以下3步:
- 首先,诱骗系统执行恶意的分支
- 其次,在恶意的分支内运行有害代码
- 最后,确保在分支错误发现之前完成攻击
当然,尽管过程非常的简单易懂,但是想要高效稳定的建立信道,需要攻击者有足够的技术,使得以上的三步都可以顺利执行。如果处理器可以(1)识别出,并且避免不会发生的分支,或者(2)提前发现错误并且在攻击者取走信息之前关闭通道,那么攻击会立即失效。所以,我们的攻击需要针对这两点进行重点突破。
误导分支预测
目前我们的攻击(上述代码)其实显然并不会成功,原因之一就是系统不会傻到看不出if
语句的结果。所以攻击者首先要做的就是提升分支预测的难度。比如以下的代码,当处理器在执行x
的前n-1
次分支时得到的结果都是true
,那么会自然而然的觉得下一次结果也会进入循环,从而被我们误导。
for x in 0, ..., n:
if x != n:
...
利用这个技巧,我们只需要让前n-1
次被执行的操作看上去无害,并且把有害的操作放到最后一次,如下。这样被处理器正常执行的语句都是无害的(&val
),在诱导处理器犯错并自我修改时,攻击者偷偷的执行了最后一个有害的指令&secret
array = [&val, &val, ... &val, &secret]
for x in 0, ..., n:
if x != n:
*array[x]
加速有害指令
之前提到,如果在处理器修复错误之前,攻击者未能成功取到信息,那么将错过进攻机会。于是解决的方向在于,(1)如何可以让系统慢点发现问题,以及(2)如何快速取到信息。两个问题有统一的解决方案,那就是缓存。当处理器执行计算的时候,首先需要获取计算对应的输入。这个过程需要处理器向存储器提交读取指令。而缓存的存在,让这个过程可快可慢:如果读取的指令在缓存里,那么读取可以在60个时钟左右完成,反之,如果读取的指令不在缓存,而在内存,那么读取需要300个左右的时钟。相似的,尽管缓存的存在提供了一条信息泄露的渠道,厂商也不会因为安全问题而放弃它带来的巨大性能提升,由此失去市场。这样的取舍给了进攻者可乘之机。
flush(x)
flush(n)
cache(addr)
if x != n:
*addr
考虑以上代码,攻击者利用缓存创造时间差,将分支判断所需要的数据从缓存中删除flush
,并且将攻击需要的地址提前读取至缓存cache
,那么在计算分支结果之前,攻击者有大于300个时钟的间隔可以进行攻击,其中只需要花费60个读取目标,剩余的时间差即可用于如何将其偷偷运走。
利用Meltdown运走数据
虽然到目前为止攻击者已经可以成功的执行语句,但是简单的访问依然无法奏效,因为处理器会在发现后试图改正预测失败产生的副作用。这其中就包括寄存器状态,堆栈指针等等。但是,处理器的设计缺陷使得,并不是所有的痕迹都可以被轻松的擦除,其中最明显的就是缓存记录。由于清除缓存成本极高,不可能在每次分支后追踪哪些记录是新添加的,哪些记录是之前就存在的。于是通过对于缓存的攻击,黑客可以反向推断出处理器在改错过程中的行为。代码如下:
if x != n:
array[*addr] // *addr = secret
for N in 0, ...
time array[N]
可以发现,攻击者并不直接取走得到的secret
数值,而是用它作为序号,去敲击对应的缓存位置(array[secret]
)。通过这样的方法,即便攻击者没有在有限的时间差内完成攻击,也会在处理器内留下一条缓存记录。这样,即便之后处理器成功的恢复了寄存器状态,攻击者只需要对于所有缓存地址进行遍历,分析哪个地址在过去被访问过(被访问过的地址再次访问需要的时间减少),由此在随后取走所需信息。
最终,结合以上三步,攻击者即可在缓存记录中,分析出所需数据。如上图,红色箭头对应的缓存位置由于再次读取所需时间减少,说明最近被处理器访问过,并且留下了痕迹。