一、前言
过去几天,名为Spectre以及Meltdown的一组安全漏洞引发了各种讨论。几乎所有的Intel处理器会受到这些漏洞影响,而许多AMD处理器及ARM核心处理器同样也无法幸免于难(特别是Spectre漏洞)。利用Spectre漏洞,攻击者可以绕过软件检查机制,在当前地址空间中从任意位置读取数据;而Meltdown漏洞则可以帮助攻击者从操作系统内核地址空间的任意位置读取数据,即使内核地址空间通常情况下对用户程序透明。
这两个漏洞利用的是现代处理器中常用的性能优化功能(缓存及推测执行),通过侧信道(side-channel)攻击来获取信息。幸运的是,树莓派(Raspberry Pi)并不会受到这次事件影响,原因是树莓派使用了特定版本的ARM核心。
为了理解具体原因,我们需要先介绍一下现代处理器中的一些基本概念。我们通过符合Python语法的几个简单代码来介绍这些概念,示例程序如下所示:
t = a+b
u = c+d
v = e+f
w = v+g
x = h+i
y = j+k
虽然计算机中的处理器不会直接执行Python程序,但上述语句非常简单,大致可以等同于单条机器指令。这里我不会去介绍一些技术细节(比如流水线处理(pipelining))以及寄存器重命名(register renaming)),虽然这些技术点对处理器设计师来说非常重要,但对理解Spectre以及Meltdown的原理帮助不大。
如果想全面了解处理器设计及现代计算机架构的其他内容,大家可以阅读Hennessy以及Patterson编写的经典参考书:Computer Architecture: A Quantitative Approach,这是一本很棒的参考书,几乎无人能出其右。
二、标量处理器
所谓的标量处理器(scalar processor),指的是最为简单的一类现代处理器类型,这类处理器在每个周期(cycle)内只能执行一条指令。前面我们给出的那个例子在标量处理器上需要消耗6个执行周期。
许多处理器属于标量处理器,比如Intel 486、Raspberry Pi 1以及Raspberry Pi Zero所使用的ARM1176核心。
三、超标量处理器
如果想要提高标量处理器(或者其他类型的任意处理器)的运行速度,最容易想到的一种方法就是增加处理器的时钟频率。然而,这么做很快就会触碰到处理器内部逻辑门运行速度的天花板,因此处理器设计人员开始寻找可以同时完成多项任务的办法。
有序(in-order)超标量处理器(superscalar processor)会检查指令流,尝试使用多条流水线一次执行多条指令,但在同一条流水线中必须遵守指令的依赖关系。指令依赖非常重要,比如,在上述例子中,你可能会认为两路超标量处理器可以将6条指令配对(或者dual-issue)成如下形式:
t, u = a+b, c+d
v, w = e+f, v+g
x, y = h+i, j+k
但实际上并非如此,在计算w
之前,我们必须先计算v
,因此第3及第4条指令没办法同时执行。两路超标量处理器没办法找到与第3条指令配对的其他指令,因此上述指令需要使用4个周期才能执行完毕:
t, u = a+b, c+d
v = e+f # second pipe does nothing here
w, x = v+g, h+i
y = j+k
许多处理器属于超标量处理器,如Intel Pentium、Raspberry Pi 2以及Raspberry Pi 3中使用的ARM Cortex-A7以及Cortex-A53核心等。相比Raspberry Pi 2,Raspberry Pi 3的时钟速率只提升了33%,但整体性能提升了将近一倍,原因在于Cortex-A53比Cortex-A7能够支持更多指令的双路运行。
四、乱序处理器
回到这个例子中,我们可以看到,虽然v
和w
有依赖关系,但程序后续使用了一些独立指令,我们可以在第二个执行周期中,充分利用这些指令,填满空闲的流水线。乱序(out-of-order)超标量处理器可以输入指令流(当然同样需要遵守依赖关系),使流水线保持忙碌状态。
对于我们那个例子,乱序处理器可以交换w
以及x
的定义,以提高工作效率,如下所示:
t = a+b
u = c+d
v = e+f
x = h+i
w = v+g
y = j+k
这样只需3个周期就能完成所有指令的执行:
t, u = a+b, c+d
v, x = e+f, h+i
w, y = v+g, j+k
许多处理器属于乱序处理器,如Intel Pentium 2(Intel及AMD后续生产的x86处理器大多都属于这类处理器,某些Atom以及Quark处理器除外),近期推出的ARM核心也属于这一类别,如Cortex-A9、-A15、-A17以及-A57。
五、分支预测器
我们给出的例子是一段非常简单直白的代码,实际程序会复杂得多:这些程序既包括前向分支(用来实现条件判断操作,如if
语句),也包括后向分支(用来实现循环语句)。分支可以是条件无关的(即始终会被执行),也可以是条件相关的(由计算结果判断是否需要执行)。
在获取指令时,处理器可能会遇到某个条件分支,该分支依赖于某个尚未计算出的值。为了避免任务停滞,处理器必须猜测接下来要获取哪条指令:是按内存顺序(memory order)获取的下一条指令(不进入该分支),还是进入分支的下一条指令。分支预测器(branch predictor)可以帮助处理器智能判断是否会进入某条分支,具体方法是收集过去执行过程中进入这些分支的频率,根据统计信息给出结果。
现代分支预测器非常复杂,生成的预测结果也非常准确。Cortex-A7到Cortex-A53的分支预测改进了不少,这也是Raspberry Pi 3性能有所提升的原因所在。然而,攻击者可以精心构造一系列分支,训练分支预测器,使其预测结果非常糟糕。
六、推测执行
对顺序指令重新排序的确是非常强大的一种方法,可以提高指令级别的并行处理能力,但随着处理器变得越来越宽(即能够实现3指令、4指令并发指令),想要保持所有流水线处于忙碌状态也变得越来越难。因此现代处理器引入了推测执行(speculate)功能。引入推测执行功能后,处理器可能会执行并不需要的那些指令(如果相应分支被跳过),这样流水线会始终处于忙碌状态(不用就闲置了),如果最终结果是该指令没被执行,处理器只需要丢弃已得结果即可。
为了说明推测执行的优点,我们可以来看一下另一个例子:
t = a+b
u = t+c
v = u+d
if v:
w = e+f
x = w+g
y = x+h
这个例子中,以来关系为t
->u
->v
以及w
->x
->y
,因此,如果没有推测执行机制,两路乱序处理器无法填满第二条流水线。处理器需要3个周期才能计算t
、u
以及v
,之后才能知道if
语句下的代码段能否被执行,如果执行这段分支,则需3个周期来计算w
、x
以及y
。假设if
语句(由一条分支指令来实现)需要消耗1个周期,那么这个例子需要消耗4个周期(如果v
为0)或者7个周期(如果v
不为0).
如果分支预测器认为if
语句下的代码分支很有可能会被执行,那么处理器就可以通过推测执行将上述代码变成如下样子:
t = a+b
u = t+c
v = u+d
w_ = e+f
x_ = w_+g
y_ = x_+h
if v:
w, x, y = w_, x_, y_
如上代码增加了指令级别的并行度,可以保持流水线处于忙碌状态:
t, w_ = a+b, e+f
u, x_ = t+c, w_+g
v, y_ = u+d, x_+h
if v:
w, x, y = w_, x_, y_
经过处理后,推测型乱序处理器可以利用闲置的流水线能力来更新w
、x
以及y
的分支及条件,因此上述代码大约需要3个周期即可完成执行任务。
七、缓存
在早些时候,处理器的速度与内存访问速度不相上下。我原来用的是BBC Micro,这款计算机搭载了2MHz主频的6502处理器,每隔2µs(微秒)就能执行一条指令,内存处理周期为0.25µs。经过35年的发展,现在处理器已经变得非常快,但内存的变化并不大:Raspberry Pi 3中的Cortex-A53每隔0.5ns(纳秒)就能执行一条指令,但访问主内存却需要100ns。
乍听起来像是一场灾难:每次需要访问内存时,我们都必须等待100ns才能得到结果。我们来看一个例子:
a = mem[0]
b = mem[1]
这两条指令需要消耗200ns。
然而,实际生活中,程序访问内存的方式其实存在一定的相关性,某种程度上可以预测,这种相关性表现为时间局部性(如果我访问了一个位置,那么可能我很快就会再次访问该位置)以及空间局部性(如果我访问一个位置,那么可能我很快会访问其临近位置)。缓存(Cache)利用的正是这种相关性,来降低访问内存的平均成本。
高速缓存是一个小型的片上(on-chip)存储器,靠近处理器,用来存储最近使用过的位置(及临近位置)所对应的内容副本,以便在后续访问任务中快速返回这些数据。使用缓存机制后,上述代码的执行时间就会大幅减少,只需消耗100ns出头即可:
a = mem[0] # 100ns delay, copies mem[0:15] into cache
b = mem[1] # mem[1] is in the cache
从Spectre以及Meltdown的角度来看,关键点在于如果你可以计算出内存访问所需的时间,那么你就可以确定你所访问的目的地址是否在缓存中,位于缓存中则所需时间较短,否则需要较长时间。
八、侧信道攻击
根据维基百科的解释:
“……侧信道攻击是利用密码系统设备在运行中所泄露的信息的一种攻击方式,这种攻击并非针对加密算法的暴力破解攻击,也没有用到加密算法本身的理论缺陷(如基于比较的密码分析技术)。侧信道攻击中,时间消耗、功率消耗、电磁辐射或者声音等都可以作为额外的信息来源,攻击者可以利用这些信息突破目标系统的重重防御。”
Spectre以及Meltdown用到的正是侧信道攻击技术,利用时间消耗信息来观察可访问的一个位置是否在缓存中,进而推断通常情况下无法访问的某个内存位置所对应的具体内容。
九、综合考虑
现在我们来看看如何有机利用推测执行以及缓存机制,针对处理器发起类似Meltdown之类的攻击。考虑如下一个例子,这段代码是一个用户程序,可能会从非法地址(内核地址)读取数据,导致出现运行错误(即程序崩溃):
t = a+b
u = t+c
v = u+d
if v:
w = kern_mem[address] # if we get here, fault
x = w&0x100
y = user_mem[x]
现在,如果我们能训练分支预测器,使其认为v
很有可能是一个非零值,那么我们所使用的两路乱序超标量处理器就会重新排序代码,得到如下结果:
t, w_ = a+b, kern_mem[address]
u, x_ = t+c, w_&0x100
v, y_ = u+d, user_mem[x_]
if v:
# fault
w, x, y = w_, x_, y_ # we never get here
虽然处理器总是会推测读取内核地址所对应的数据,但直到它发现v
为非零值,才会返回异常结果。从表面上来看,这一点无关痛痒,因为有如下两点原因:
1、如果v
为0,那么非法读取所获得的结果不会提交给w
。
2、如果v
不为0,读取结果在提交给w
之前就会出现错误。
然而,假设我们在执行代码之前刷新了缓存,重新安排a
、b
、c
以及d
的值,使v
成功取得0值。那么,第3个周期所执行的推测读取指令为:
v, y_ = u+d, user_mem[x_]
这条指令会访问用户空间的0x000
地址或者0x100
地址,具体结果取决于非法读取结果的第8位值,然后将该地址以及临近地址加载到缓存中。由于v
为0,推测指令的结果将被处理器丢弃,继续执行流程。如果我们观察推测访问这些地址所需的时间,我们可以确定哪个地址位于缓存中。现在攻击任务已达成,我们成功从内核地址空间中读取出了1比特信息!
真正的Meltdown利用过程远比这个更加复杂(为了避免错误训练分支预测器,作者倾向于无条件执行非法读取操作,再去处理异常结果),但原理相同。Spectre使用了类似的方法来绕过软件数组边界检查机制。
十、总结
时至今日,现代处理器在后台做了许多工作,从抽象层面来看这些处理器是可以直接访问内存的有序标量处理器,但实际上它们引入了大量技术,如缓存、指令重排以及推测执行,从而获得了简单处理器无法企及的高性能指标。然而抽象和现实毕竟有所不同,即使差别非常细微,安全性方面依然值得细致推敲,Meltdown以及Spectre正是我们在抽象背景下遇到的安全风险。
Raspberry Pi使用的是ARM1176、Cortex-A7以及Cortex-A53核心,这些处理器并不具备推测执行功能,因此能免受这类攻击影响。
发表评论
您还未登录,请先登录。
登录