最近爆出来的 Intel CPU 的底层漏洞可谓是影响巨大,过去20年的电脑都可能会受影响。前几天 Raspberry Pi 的官方 Twitter(@Raspberry_Pi) 转推了这篇文章,通过简单的 Python 程序分析了各种硬件术语和漏洞攻击模式,内容简单易懂,看后神清气爽。今天抽空将其翻译,分享给大家。本人英语也不算太好,对着百度磕磕绊绊的翻译了出来,如有错误请多多包涵。——2018年1月8日
原文地址:https://www.raspberrypi.org/blog/why-raspberry-pi-isnt-vulnerable-to-spectre-or-meltdown
在过去的几天里,有许多关于一对叫 Spectre 和 Meltdown 的安全漏洞的讨论。这影响到所有近代的英特尔处理器,许多AMD处理器(在 Spectre 漏洞下)和 ARM 核心。 Spectre 允许攻击者绕过软件检查,去读取当前地址空间中任意位置的数据; Meltdown 允许攻击者去读取操作系统内核地址空间中(通常对用户程序不可访问)任意位置的数据。
这两个漏洞利用许多现代处理器常见的性能特征(缓存和预测执行),通过所谓的侧信道攻击(side-channel attack)来泄漏数据。幸运的是,树莓派不会受到这些漏洞的影响,因为我们使用特别的(particular)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 and Patterson’s classic Computer 体系结构更好:一种定量方法。(原文:you can’t do better than Hennessy and Patterson’s classic Computer Architecture: A Quantitative Approach.)
什么是标量处理器
最简单的现代处理器每周期执行一条指令,我们称之为标量处理器(scalar processor)。上面的示例将在标量处理器上以六个周期执行。
标量处理器的例子包括 Intel 486 和在 Raspberry Pi 1 与 Raspberry Pi Zero 上使用的 ARM1176 核心。
什么是超标量处理器
使标量处理器(实际上是任何处理器)运行得更快的明显方法是增加它的时钟速度(clock speed)。但是,我们很快达到了处理器内部逻辑门运行速度的极限。因此,处理器设计者开始寻找几种同时执行多个指令的方法。
顺序(in-order)超标量处理器(superscalar processor)检查传入的指令流,并尝试在一个流水线(pipelines -> pipes)中同时执行多个指令流,但要遵守指令之间的依赖关系。依赖关系很重要:你可能认为双路(two-way)超标量处理器可以结对(dual-issue)六个指令,像下面的例子一样:
t, u = a+b, c+d
v, w = e+f, v+g
x, y = h+i, j+k
但这没有意义:在计算 w 之前,我们必须计算 v ,所以第三和第四指令不能同时执行。我们的双路超标量处理器实际上不可能找到任何与第三指令相匹配的指令,所以我们的示例将以四个周期执行:
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 3 的时钟速度只比 Raspberry Pi 2 快了 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
将其以三个周期执行:
t, u = a+b, c+d
v, x = e+f, h+i
w, y = v+g, j+k
乱序处理器的例子包括 Intel Pentium 2 (绝大多数的 Intel 和 AMD x86 处理器,除了一些 Intel Atom 和 Intel Quark 设备),最新的 ARM 核心,像 Cortex-A9, -A15, -A17, and -A57 。
什么是分支预测器
上面的例子是一段顺序代码。当然,真正的程序不是这样的:它们还包含向前分支(forward branches,用于实现条件操作,如if语句)和向后分支(backward branches,用于实现循环)。分支可能是无条件的(总是执行),或条件的(是否执行取决于计算值)。
在获取指令时,处理器可能遇到依赖于尚未计算值的条件分支。为了避免停顿,处理器必须猜测下一个要取的指令:在内存中的一个指令(对应不执行分支),或分支目标中的一个(对应执行分支)。分支预测器(branch predictor)可帮助处理器对是否执行分支进行智能猜测。它通过收集有关过去特定分支的执行频率的统计数据来做到这一点。
现代分支预测是非常复杂的,可以产生非常准确的预测。Raspberry Pi 3 的额外性能的部分原因是由于分支预测在 Cortex-A7 和 Cortex-A53 之间的改进。然而,通过执行精心编制的一系列分支,攻击者可以错误地训练分支预测器,从而做出糟糕的预测。
什么是推测
重排(reordering)顺序指令是使更多指令级并行的强有力方法,但是随着处理器变得更强大(能够将三或四个指令结对),要使所有这些流水线忙起来变得困难。因此,现代处理器推测(speculation)的能力也变得更强。推测执行允许我们发出可能不需要的指令(因为代码可能会存在分支),这会使流水线保持繁忙(使用或丢弃),如果结果表明该指令未被执行,我们就可以将其丢弃。
推测执行不必要的指令(底层需要支持推测和重排)消耗额外的时间,但在许多情况下,这被认为是获得额外单线程性能的一个合算的折衷。分支预测器被用来选择程序最可能的路径,最大限度地提高推测的回报。
为了演示推测的好处,让我们看看另一个例子:
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 的依赖关系,所以没有推测的双路乱序处理器永远不能填满它的第二个流水线。处理器花费三个周期计算 t 、 u 和 v ,之后将知道 if 语句的主体部分是否执行,在执行 if 语句主体的情况下,再花费三个周期计算 w 、 x 和 y 。假设 if 语句(由一个分支指令实现)需要一个周期,我们的示例将花费四个周期(如果 v 为 0)或七个周期(如果 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_
循环计数在推测乱序处理器中定义不太好(原文:Cycle counting becomes less well defined in speculative out-of-order processors),但 w 、 x 和 y 的分支和条件更新是大约不占用时间的,所以我们的示例大约在三个周期中执行。
什么是缓存
在过去的好日子里,处理器的速度与内存访问速度匹配得很好。我的 BBC Micro 有 2MHz ,执行一条指令大约 2μs ,存储周期(memory cycle time)为 0.25μs 。在接下来的35年里,处理器已经变得很快,但内存还仅仅是那样。在 Raspberry Pi 3 中的一个 Cortex-A53 核心,执行一条指令大约 0.5ns ,但可能需要多达 100ns 去访问主存。
乍一看,这听起来像一个灾难:我们每次访问内存,要等待 100ns 后才得到结果返回。下面这个例子需要花费 200ns :
a = mem[0]
b = mem[1]
然而,在实际中,程序倾向于以相对可预测的方式去访问内存,同时显示时间局部性(temporal locality ,如果我访问一个位置,我很可能很快就会再次访问它)和空间局部性(spatial locality ,如果我访问一个位置,我很可能很快就会访问它附近的位置)。缓存利用了这些特性,以减少访问内存的平均成本。
缓存是一个容量小的芯片存储器,靠近处理器,存储最近使用的地址(及其附近)的内容的副本,以便它们在后续访问中很快可用。有了缓存,上面的例子会执行一个多 100ns :
a = mem[0] # 100ns delay, copies mem[0:15] into cache
b = mem[1] # mem[1] is in the cache
从 Spectre 和 Meltdown 的角度来看,重要的一点是,如果能够计算内存访问的时间,就可以判定所访问的地址是否在缓存。
什么是侧信道
维基百科zh-cn:
侧信道攻击(英语:Side-channel attack)是一种攻击方式,它基于从密码系统的物理实现中获取的信息而非暴力破解法或是算法中的理论性弱点(较之密码分析)。例如:时间信息、功率消耗、电磁泄露或甚是声音可以提供额外的信息来源,这可被利用于进一步对系统的破解。
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 可能是非 0 的,我们双路乱序超标量处理器将会这样打乱程序:
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 是非零的。从表面上看,这感觉很安全,因为:
- v 为零,因此非法读取的结果不会被提交到 w
- v 为非零,但在将读取提交到 w 之前发生故障
但是,假设我们在执行代码之前清空缓存,并排列 a、b、c 和 d, 以便 v 实际上是零。现在,在第三周期中推测读取
v, y_ = u+d, user_mem[x_]
将访问用户地址 0x000 或地址 0x100 ,具体取决于非法读取的结果的第八位,将该地址及其附近加载到缓存中。由于 v 为零,因此将丢弃推测性指令的结果,并继续执行。如果我们对其中一个地址进行后续访问, 我们就可以确定哪个地址在缓存中。恭喜你,你刚刚从内核的地址空间读取了一位!
真正的 Meltdown 利用比这更为复杂(特别是为了避免错误地训练分支预测器,作者更愿意无条件地执行非法读取并处理结果异常),但原理是一样的。 Spectre 使用类似的方法来颠覆软件数组边界检查。
结论
现代处理器不遗余力地保持抽象,即它们是直接访问存储器的顺序的标量机器。而实际上使用许多技术,包括缓存、指令重排和推测,可以提供比简单处理器更高的性能。 Meltdown 和 Spectre 是我们在抽象的背景下对安全进行推理的例子,然后在抽象和现实之间遇到细微的差异。
在 Raspberry Pi 中,ARM1176、Cortex-A7 和 Cortex-A53 核心的缺少推测功能使我们对这种类型的攻击免疫。