在 Spectre 漏洞与正常 CPU 行为中允许内存读取越界的推测执行失败的原因是什么?

信息安全 幽灵
2021-08-19 11:22:23

谷歌的 Spectre/Meltdown 项目零博客条目之后,有这段代码可以说明攻击:

struct array {
    unsigned long length;
    unsigned char data[];
};
struct array *arr1 = ...; /* small array */
struct array *arr2 = ...; /* array of size 0x400 */
/* >0x400 (OUT OF BOUNDS!) */
unsigned long untrusted_offset_from_caller = ...;
if (untrusted_offset_from_caller < arr1->length) {
    unsigned char value = arr1->data[untrusted_offset_from_caller];
    unsigned long index2 = ((value&1)*0x100)+0x200;
    if (index2 < arr2->length) {
        unsigned char value2 = arr2->data[index2];
    }
}

说明CPU后面的推测执行,会尝试执行到

arr2->data[index2];

在达到条件之前

if (untrusted_offset_from_caller < arr1->length) {

这将阻止访问区域内存的边界。

我的问题是:

  • 如果代码试图明确地这样做,什么会阻止在正常执行中访问该内存区域?

我想在某些地方,操作系统和/或 cpu 内存访问检查应该停止这种情况,并且推测性的 exec 只是跳过那个(?)。

似乎修补那个(实际上没有做的)检查(如果我之前的猜测是正确的......)是不够的或者不是正确的方法,因为已经说过需要另外两个条件:刷新分支预测器 &考虑到分支指令的完整地址(现在 CPU 似乎不这样做了),但是:

  • 检查(如果第一个问题的答案是需要额外检查)或“冲洗”对性能的影响不是吗?如果有,估计有多少?(当那些假定的 CPU 将/将被制造时)。
4个回答

稍微简化一下,问题是这样的

if (*p1) x = p2[256 * *p3];

可以处理为:

start loading *p1 into t1.
load *p3 into t2, and set t3 to 0 if it's a valid fetch, 1 otherwise.
load *(p2 + t2*256) into t4
wait for t1..t4 to be ready
if t1 was set, then...
  if t3 is set [access was invalid] then fire an invalid access trap.
  otherwise copy t4 into x.
discard t1..t4.

如果读取*p1产生零值,则无效的事实*p3一定不会导致陷阱(因为代码实际上不会要求读取*p3)。无论出于何种原因,英特尔的设计人员认为,将第一次内存读取的有效性延迟到使用获取的值来计算另一次推测性读取的地址之后,要比立即读取无效内存来迫使推测者假设更容易它的预测是错误的。

请注意,问题不在于处理器推测性地从 *p3 获取。问题是处理器使用该值而不考虑它是否是合法获得的。虽然目前的攻击侧重于使用获取的值来计算地址,然后使用缓存来找出获取的地址,但根本问题是数据被读取和锁定,而不考虑访问是否合法。任何时候设备从物理上获取本应无法访问的数据都会产生潜在的侧信道攻击。防止此类攻击的最佳方法是首先避免让设备获取此类数据。

关键概念是没有芯片必须根据指令精确执行代码。相反,它必须执行“好像”代码是根据指令精确执行的义务。所有现代处理器都利用了这种自由。

从理论上讲,只要最终结果“好像”该指令根本没有执行过,处理器就可以在任何时候自由地推测性地执行任何指令。这是用来提高性能的,利用芯片的空闲部分做推测工作,希望这个指令的效果是需要的。

在 Meltdown/Spectre 漏洞中,揭示了这种推测性执行实际上并不是“好像”它从未发生过。这种推测改变了缓存的状态,加载了可能不会被缓存的数据。这改变了 Meltdown/Spectre 从不应该读取的地方读取内存的时间。

关键的失败在于,芯片不再“像”完美地遵循命令一样运行。它的行进有点不合时宜。在这样的情况下:

if (cursorIdx < cursors.size())
    y = buffer[cursors[cursorIdx]];

程序员可能期望缓冲区中的内存不能被读取。这是合乎逻辑的,因为写入程序的指令要求在读取缓冲区之前检查游标数组大小。只要芯片“好像”按照命令运行,您就可以证明没有人应该能够观察到在大小检查之前发生的缓冲区读取。通过这个漏洞,我们看到观察者确实可以从缓冲区中读取,甚至可能是窃听器之外的其他内存。

因此,看似安全的代码,即使是针对侧信道攻击,也突然变得非常不安全,因为它不再“像”正确执行一样运行。问题不在于内存被推测性地读取。处理器一直被允许这样做,并且在技术上仍然被允许这样做。问题是,这个漏洞利用表明这种推测性读取并不是“好像”它们从未被读取过,因为我们可以观察它们是否发生。他们现在泄漏有关不应泄漏的内存空间的信息。

是的,要回答您的问题,对此的修复确实会对性能产生负面影响。此漏洞利用如此重要的原因之一是很难在不影响性能的情况下解决这些修复程序。

从 1970 年代末/1980 年代初开始,CPU 开始将指令的执行分解为一系列更小的步骤,称为“流水线执行”。例如,CPU 可能同时从内存中读取一条指令,执行第二条指令,并存储第三条指令的结果。以这种方式做事可以提高 CPU 的速度:通过同时在不同的处理阶段处理多条指令,对于相同的时钟速度,CPU 可以快很多倍。

当正在执行的指令是一个分支(例如“if”语句)时,这会遇到一个问题:哪条指令是应该从内存中加载的“下一条”指令?解决方案是分支预测和推测执行:CPU 对下一条指令进行有根据的猜测,然后运行它,但在知道猜测是否正确之前不会使结果永久化。这再次加快了速度,因为 CPU 不需要等待分支的结果被知道,除非它猜错了分支的走向。

现代 CPU 速度快,内存访问速度慢,而且管道比我上面描述的简单的三级管道长得多。在执行此行时从 CPU 的角度查看您的代码:

unsigned long untrusted_offset_from_caller = ...;

CPU 看到出现了两个“if”语句,然后说:“根据过去的经验,这两个 'if' 语句都会被证明是正确的。因此,我需要从内存中获取arr1->data[untrusted_offset_from_caller]和获取。”arr2->data[index2]

if (untrusted_offset_from_caller < arr1->length) {
    unsigned char value = arr1->data[untrusted_offset_from_caller];
    unsigned long index2 = ((value&1)*0x100)+0x200;
    if (index2 < arr2->length) {
        unsigned char value2 = arr2->data[index2];
    }
}

然后它继续发出内存请求并推测性地执行代码。现在,这一次,事实证明这if (index2 < arr2->length)是错误的,CPU 丢弃了它所做的工作。

但是,它不会全部丢弃。内存、寄存器和指令指针都显示了您对语句为假的期望,但抢先获取arr2->data[index2]仍然在 CPU 的数据缓存中。程序可以通过读取那部分内存比正常速度更快的事实来解决这个问题,并且可以推断出的值arr1->data[untrusted_offset_from_caller]是什么。

“什么会阻止在正常执行中访问该内存区域”?没有什么能阻止它。但通常使用的地址将基于运行代码合法可用的数据。所以数据会被推测性地读取,缓存行会被弹出,这会告诉我们读取了哪个字节,但无论如何它都是我们有权知道的字节。因此,任何秘密都不会被泄露。

这是我处理示例代码(在硬件中)给出的情况的建议:

一次读取操作没有问题。因此,一种简单的方法是只允许一个推测性读取操作。第二次推测性阅读必须等到第一个不再是推测性的。

第一个改进:不修改缓存(或以其他方式泄漏信息)的推测读取很好。所以我们总是允许一次推测性读取,然后我们允许更多读取,只要它们不弹出缓存行(或以其他方式泄漏信息)。

第二个改进:只要它们没有依赖于第一次读取的地址,进一步读取就可以了。因此,我们跟踪哪些寄存器是推测性读取的结果,以及它是如何传播的,并且只要地址不是基于推测性读取,就允许进一步读取。这允许“如果 (x > 0) z = a [0] + a [1] + a [2];” 继续。

第三个改进:我们修改了 L1 缓存,以便任何读取属于另一个进程的数据的尝试都会产生缓存未命中。现在我们知道,如果读取操作命中 L1 缓存,那么我们就可以读取数据。因此,我们忽略所有 L1 缓存命中的读取。