推测性加载硬化

一种 Spectre 变体 #1 缓解技术

作者:Chandler Carruth - chandlerc@google.com

问题陈述

最近,Google Project Zero 和其他研究人员发现了通过利用现代 CPU 中的推测性执行来泄漏信息的安全漏洞。这些漏洞目前被分为三种变体

  • GPZ 变体 #1(又名 Spectre 变体 #1):边界检查(或谓词)绕过

  • GPZ 变体 #2(又名 Spectre 变体 #2):分支目标注入

  • GPZ 变体 #3(又名 Meltdown):恶意数据缓存加载

有关更多详细信息,请参阅 Google Project Zero 博客文章和 Spectre 研究论文

  • https://googleprojectzero.blogspot.com/2018/01/reading-privileged-memory-with-side.html

  • https://spectreattack.com/spectre.pdf

GPZ 变体 #1 的核心问题是,推测性执行使用分支预测来选择推测性执行的指令路径。这条路径会使用可用的数据进行推测性执行,并可能从内存中加载数据,并通过各种旁路通道泄漏加载的值,即使由于不正确而撤销了推测性执行,这些值也仍然存在。预测错误的路径会导致代码使用在正确执行中从未出现的数据输入来执行,这使得针对恶意输入的检查无效,并允许攻击者使用恶意数据输入来泄漏机密数据。这是一个示例,摘自并简化自 Project Zero 论文

struct array {
  unsigned long length;
  unsigned char data[];
};
struct array *arr1 = ...; // small array
struct array *arr2 = ...; // array of size 0x400
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;
  unsigned char value2 = arr2->data[index2];
}

攻击的关键是使用 untrusted_offset_from_caller 调用此函数,该函数在分支预测器预测它在范围内时远超出范围。在这种情况下,if 的主体将被推测性执行,并且可能会将机密数据读入 value 并通过缓存计时旁路通道泄漏它,当依赖访问用于填充 value2 时。

高级缓解方法

虽然正在积极探索几种方法来缓解特别危险的软件(最著名的是各种操作系统内核)内部的特定分支和/或加载,但这些方法需要手动和/或静态分析辅助的代码审计以及明确的源代码更改才能应用缓解措施。它们不太可能很好地扩展到大型应用程序。我们正在提出一种全面的缓解方法,该方法将在整个程序中自动应用,而不是通过手动更改代码来应用。虽然这可能会产生很高的性能成本,但某些应用程序可能处于可以接受这种性能/安全权衡的位置。

我们提出的具体技术是导致加载使用无分支代码进行检查,以确保它们沿着有效的控制流路径执行。考虑以下表示谓词保护潜在无效加载的核心思想的 C 伪代码

void leak(int data);
void example(int* pointer1, int* pointer2) {
  if (condition) {
    // ... lots of code ...
    leak(*pointer1);
  } else {
    // ... more code ...
    leak(*pointer2);
  }
}

这将转换为类似以下内容

uintptr_t all_ones_mask = std::numerical_limits<uintptr_t>::max();
uintptr_t all_zeros_mask = 0;
void leak(int data);
void example(int* pointer1, int* pointer2) {
  uintptr_t predicate_state = all_ones_mask;
  if (condition) {
    // Assuming ?: is implemented using branchless logic...
    predicate_state = !condition ? all_zeros_mask : predicate_state;
    // ... lots of code ...
    //
    // Harden the pointer so it can't be loaded
    pointer1 &= predicate_state;
    leak(*pointer1);
  } else {
    predicate_state = condition ? all_zeros_mask : predicate_state;
    // ... more code ...
    //
    // Alternative: Harden the loaded value
    int value2 = *pointer2 & predicate_state;
    leak(value2);
  }
}

结果应该是,如果 if (condition) { 分支预测错误,则在通过它们加载之前,对用于清零任何指针的条件或对所有加载的位进行清零存在数据依赖关系。即使这种代码模式可能仍然推测性执行,无效的推测性执行也会被阻止从内存中泄漏机密数据(但请注意,这些数据可能仍然以安全的方式加载,并且某些内存区域需要不包含机密信息,请参见以下详细限制)。这种方法仅要求底层硬件能够实现寄存器值的无分支和不可预测的条件更新。所有现代架构都支持这一点,事实上,为了正确实现恒定时间加密原语,需要这样的支持。

这种方法的关键属性

  • 它没有阻止任何特定的旁路通道工作。这一点很重要,因为存在数量未知的潜在旁路通道,并且我们预计会继续发现更多。相反,它首先阻止了机密数据的观察。

  • 它累积谓词状态,即使在嵌套正确预测的控制流的情况下也能提供保护。

  • 它跨越函数边界传递此谓词状态,以提供过程间保护

  • 在强化加载的地址时,它使用加载地址的破坏性不可逆修改来阻止攻击者使用攻击者控制的输入反转检查。

  • 它不会完全阻止推测性执行,而仅仅阻止错误推测的路径从内存中泄漏机密信息(并在可以确定之前暂停推测)。

  • 它完全通用,并且除了能够执行无分支条件数据更新和缺乏值预测之外,对底层架构没有做出任何基本假设。

  • 它不需要程序员使用静态源代码注释或容易受到变体 #1 样式攻击的代码来识别所有可能的机密数据。

这种方法的局限性

  • 它需要重新编译源代码以插入强化指令序列。只有以这种模式编译的软件受到保护。

  • 性能在很大程度上取决于特定架构的实现策略。我们在下面概述了潜在的 x86 实现并描述了其性能。

  • 它无法防御已从内存中加载并驻留在寄存器中或通过非推测性执行中的其他旁路通道泄漏的机密数据。处理此问题的代码(例如加密例程)已经使用恒定时间算法和代码来防止旁路通道。此类代码还应根据这些指南清理寄存器中的机密数据。

  • 为了获得合理的性能,许多加载可能不会被检查,例如那些具有编译时固定地址的加载。这主要包括全局变量和局部变量的编译时常量偏移量处的访问。需要这种保护并有意存储机密数据的代码必须确保用于机密数据的内存区域一定是动态映射或堆分配。这是一个可以调整以提供更全面保护的领域,但会以性能为代价。

  • 强化加载如果未被攻击者控制的地址,则仍可能从有效地址加载数据。为了防止这些读取机密数据,地址空间的低 2gb 以及任何可执行页面之上和之下的 2gb 应受到保护。

鸣谢

  • 通过数据跟踪错误推测并标记指针以阻止错误推测加载的核心思想是在 HACS 2018 讨论中由 Chandler Carruth、Paul Kocher、Thomas Pornin 和其他一些个人开发的。

  • 屏蔽加载位的基本思想是 Jann Horn 在报告这些攻击时提出的原始缓解措施的一部分。

间接分支、调用和返回

可以使用变体 #1 样式的错误预测来攻击除条件分支之外的其他控制流。

  • 对虚拟方法的热门调用目标的预测可能导致在使用预期类型时推测性地执行它(通常称为“类型混淆”)。

  • 由于预测而不是作为跳转表实现的 switch 语句的正确情况,热门情况可能会被推测性执行。

  • 从函数返回时,可能会错误地预测热门的常用返回地址。

这些代码模式也容易受到 Spectre 变体 #2 的影响,因此最好在 x86 平台上使用retpoline 来缓解。当使用 retpoline 这样的缓解技术时,推测根本无法通过间接控制流边(或者在填充 RSB 的情况下无法预测错误),因此它也受到变体 #1 样式攻击的保护。但是,某些架构、微架构或供应商没有采用 retpoline 缓解措施,并且在未来的 x86 硬件(英特尔和 AMD)上,预计由于基于硬件的缓解措施,它将变得不必要。

当不使用 retpoline 时,这些边将需要独立地免受变体 #1 样式攻击的保护。用于条件控制流的类似方法应该有效

uintptr_t all_ones_mask = std::numerical_limits<uintptr_t>::max();
uintptr_t all_zeros_mask = 0;
void leak(int data);
void example(int* pointer1, int* pointer2) {
  uintptr_t predicate_state = all_ones_mask;
  switch (condition) {
  case 0:
    // Assuming ?: is implemented using branchless logic...
    predicate_state = (condition != 0) ? all_zeros_mask : predicate_state;
    // ... lots of code ...
    //
    // Harden the pointer so it can't be loaded
    pointer1 &= predicate_state;
    leak(*pointer1);
    break;

  case 1:
    predicate_state = (condition != 1) ? all_zeros_mask : predicate_state;
    // ... more code ...
    //
    // Alternative: Harden the loaded value
    int value2 = *pointer2 & predicate_state;
    leak(value2);
    break;

    // ...
  }
}

核心思想保持不变:使用数据流验证控制流,并使用该验证来检查加载是否无法沿错误推测的路径泄漏信息。这通常涉及跨越边的此类控制流的所需目标,并在之后检查它是否正确。请注意,虽然认为这可以缓解变体 #2 攻击,但事实并非如此。这些攻击会转到不包含检查的任意小工具。

变体 #1.1 和 #1.2 攻击:“边界检查绕过存储”

除了核心变体 #1 攻击之外,还有技术可以扩展此攻击。主要技术称为“边界检查绕过存储”,并在本研究论文中进行了讨论:https://people.csail.mit.edu/vlk/spectre11.pdf

我们将独立分析这两个变体。首先,变体 #1.1 通过在边界检查绕过后推测性地覆盖返回地址来工作。然后,此推测性存储在返回的推测性执行期间被 CPU 使用,可能将推测性执行引导到二进制文件中的任意小工具。让我们看一个例子。

unsigned char local_buffer[4];
unsigned char *untrusted_data_from_caller = ...;
unsigned long untrusted_size_from_caller = ...;
if (untrusted_size_from_caller < sizeof(local_buffer)) {
  // Speculative execution enters here with a too-large size.
  memcpy(local_buffer, untrusted_data_from_caller,
         untrusted_size_from_caller);
  // The stack has now been smashed, writing an attacker-controlled
  // address over the return address.
  minor_processing(local_buffer);
  return;
  // Control will speculate to the attacker-written address.
}

但是,可以通过像任何其他加载一样强化返回地址的加载来缓解此问题。这有时很复杂,因为例如 x86隐式地从堆栈加载返回地址。但是,下面的实现技术专门设计用于通过使用堆栈指针在函数之间传达错误推测来缓解这种隐式加载。这还会导致错误推测具有无效的堆栈指针,并且永远无法读取推测性存储的返回地址。请参阅下面的详细讨论。

对于变体 #1.2,攻击者推测性地存储到用于实现间接调用或间接跳转的 vtable 或跳转表中。由于这是推测性的,因此即使这些存储在只读页面中,通常也是可能的。例如

class FancyObject : public BaseObject {
public:
  void DoSomething() override;
};
void f(unsigned long attacker_offset, unsigned long attacker_data) {
  FancyObject object = getMyObject();
  unsigned long *arr[4] = getFourDataPointers();
  if (attacker_offset < 4) {
    // We have bypassed the bounds check speculatively.
    unsigned long *data = arr[attacker_offset];
    // Now we have computed a pointer inside of `object`, the vptr.
    *data = attacker_data;
    // The vptr points to the virtual table and we speculatively clobber that.
    g(object); // Hand the object to some other routine.
  }
}
// In another file, we call a method on the object.
void g(BaseObject &object) {
  object.DoSomething();
  // This speculatively calls the address stored over the vtable.
}

缓解此问题需要强化来自这些位置的加载,或缓解间接调用或间接跳转。任何这些都足以阻止调用或跳转使用已读回的推测性存储值。

对于这两种情况,使用 retpoline 同样足够。一种可能的混合方法是对于间接调用和跳转使用 retpoline,同时依靠 SLH 来缓解返回。

另一种对这两种情况都足够的方法是强化所有推测性存储。但是,由于大多数存储都不重要并且本身不会泄漏数据,因此考虑到它防御的攻击,预计这将非常昂贵。

实现细节

此技术的实现涉及许多复杂的细节,这些细节会受到特定架构和特定编译器的影响。我们将讨论针对 x86 架构和 LLVM 编译器的建议实现技术。这些主要作为示例,因为还有其他许多可能的实现技术。

x86 实现细节

在 x86 平台上,我们将实现分解为三个核心组件:通过控制流图累积谓词状态、检查加载和检查过程之间的控制转移。

累积谓词状态

考虑以下基本的 x86 指令,这些指令测试三个条件,如果全部通过,则从内存加载数据并可能通过某些侧信道泄露数据

# %bb.0:                                # %entry
        pushq   %rax
        testl   %edi, %edi
        jne     .LBB0_4
# %bb.1:                                # %then1
        testl   %esi, %esi
        jne     .LBB0_4
# %bb.2:                                # %then2
        testl   %edx, %edx
        je      .LBB0_3
.LBB0_4:                                # %exit
        popq    %rax
        retq
.LBB0_3:                                # %danger
        movl    (%rcx), %edi
        callq   leak
        popq    %rax
        retq

当我们进行投机执行加载时,我们需要知道是否有任何动态执行的谓词被错误预测。为了跟踪这一点,沿着每个条件边,我们需要跟踪允许该边被执行的数据。在 x86 上,此数据存储在条件跳转指令使用的标志寄存器中。在此控制流分支后的两个边上,标志寄存器保持有效并包含我们可以用来构建累积谓词状态的数据。我们使用 x86 条件移动指令累积它,该指令也读取状态所在的标志寄存器。众所周知,这些条件移动指令不会在任何 x86 处理器上进行预测,这使得它们能够避免可能重新引入漏洞的错误预测。当我们插入条件移动时,代码最终看起来像这样

# %bb.0:                                # %entry
        pushq   %rax
        xorl    %eax, %eax              # Zero out initial predicate state.
        movq    $-1, %r8                # Put all-ones mask into a register.
        testl   %edi, %edi
        jne     .LBB0_1
# %bb.2:                                # %then1
        cmovneq %r8, %rax               # Conditionally update predicate state.
        testl   %esi, %esi
        jne     .LBB0_1
# %bb.3:                                # %then2
        cmovneq %r8, %rax               # Conditionally update predicate state.
        testl   %edx, %edx
        je      .LBB0_4
.LBB0_1:
        cmoveq  %r8, %rax               # Conditionally update predicate state.
        popq    %rax
        retq
.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...

在这里,我们通过将 %rax 清零来创建“空”或“正确执行”谓词状态,并通过将 -1 放入 %r8 中来创建常量“错误执行”谓词值。然后,沿着条件分支出来的每个边,我们执行一个条件移动,在正确执行时它将是一个空操作,但如果错误预测,它将用 %r8 的值替换 %rax。任何一个谓词的错误预测都会导致 %rax 保持来自 %r8 的“错误执行”值,因为当执行正确时我们保留传入值而不是覆盖它。

现在,我们在每个基本块中的 %rax 中有一个值,指示在之前某个时刻谓词是否被错误预测。并且我们已经安排好该值在下面用于强化加载时特别有效。

间接调用、分支和返回谓词

在跟踪间接调用、分支和返回时,没有类似的标志可以使用。必须通过其他方式累积谓词状态。从根本上说,这是 CFI 中提出的问题的反面:我们需要检查我们来自哪里,而不是我们要去哪里。对于函数本地跳转表,这很容易通过在每个目标(尚未实现,使用 retpolines)中测试跳转表的输入来安排。

        pushq   %rax
        xorl    %eax, %eax              # Zero out initial predicate state.
        movq    $-1, %r8                # Put all-ones mask into a register.
        jmpq    *.LJTI0_0(,%rdi,8)      # Indirect jump through table.
.LBB0_2:                                # %sw.bb
        testq   $0, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL

.LBB0_3:                                # %sw.bb1
        testq   $1, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL

.LBB0_5:                                # %sw.bb10
        testq   $2, %rdi                # Validate index used for jump table.
        cmovneq %r8, %rax               # Conditionally update predicate state.
        ...
        jmp     _Z4leaki                # TAILCALL
        ...

        .section        .rodata,"a",@progbits
        .p2align        3
.LJTI0_0:
        .quad   .LBB0_2
        .quad   .LBB0_3
        .quad   .LBB0_5
        ...

返回在 x86-64(或其他具有所谓的“红色区域”区域的 ABI,该区域位于堆栈末尾之外)上具有简单的缓解技术。保证此区域在中断和上下文切换期间保持不变,从而使用于返回到当前代码的返回地址保留在堆栈上并有效读取。我们可以在调用者中发出代码以验证返回边是否未被错误预测

        callq   other_function
return_addr:
        testq   -8(%rsp), return_addr   # Validate return address.
        cmovneq %r8, %rax               # Update predicate state.

对于没有“红色区域”(因此无法从堆栈读取返回地址)的 ABI,我们可以在调用之前将预期的返回地址计算到跨调用保留的寄存器中,并类似于上述方式使用它。

间接调用(以及在没有红色区域 ABI 的情况下返回)提出了传播方面最重大的挑战。最简单的技术是定义一个新的 ABI,以便将预期的调用目标传递到被调用函数并在入口处进行检查。不幸的是,在 C 和 C++ 中部署新的 ABI 非常昂贵。虽然目标函数可以在 TLS 中传递,但我们仍然需要复杂的逻辑来处理使用和不使用此额外逻辑的函数的混合(本质上,使 ABI 向后兼容)。目前,我们建议在这里使用 retpolines,并将继续研究缓解此问题的方法。

优化、替代方案和权衡

仅仅累积谓词状态就会带来很大的成本。我们采用了多种关键优化来最大程度地减少这种成本,以及各种在生成的代码中呈现不同权衡的替代方案。

首先,我们努力减少跟踪状态所使用的指令数量

  • 与其在原始程序中的每个条件边上插入 cmovCC 指令,不如跟踪我们在进入每个基本块之前需要捕获的每组条件标志,并为这些标志重用一个通用的 cmovCC 序列。

    • 当需要多个 cmovCC 指令来捕获标志集时,我们可以进一步重用后缀。目前,人们认为这并不值得付出成本,因为配对标志相对较少,而它们的后缀则极其罕见。

  • x86 中的一个常见模式是具有多个使用相同标志但处理不同条件的条件跳转指令。简单来说,我们可以将它们之间的每个贯穿视为一条“边”,但这会导致更复杂的控制流图。相反,我们累积贯穿所需的条件集,并在单个贯穿边中使用一系列 cmovCC 指令来跟踪它。

其次,我们通过为“错误”状态分配一个寄存器来交换寄存器压力以简化 cmovCC 指令。我们可以从内存中读取该值作为条件移动指令的一部分,但是,这会创建更多微操作,并需要加载存储单元参与。目前,我们将该值放入虚拟寄存器中,并允许寄存器分配器决定何时寄存器压力足以使其值得溢出到内存并重新加载。

强化加载

一旦我们将谓词累积到一个用于正确与错误预测的特殊值中,我们就需要将其应用于加载,以确保它们不会泄露秘密数据。为此,主要有两种技术:我们可以强化加载值以防止观察,或者我们可以强化地址本身以防止加载发生。这些具有显著不同的性能权衡。

强化加载值

强化加载的最吸引人的方法是屏蔽所有加载的位。关键要求是,对于每个加载的位,沿着错误预测的路径,该位始终固定为 0 或 1,而不管加载的位的数值如何。最明显的实现使用 and 指令,沿着错误预测的路径使用全零掩码,沿着正确路径使用全一掩码,或者使用 or 指令,沿着错误预测的路径使用全一掩码,沿着正确路径使用全零掩码。其他选项变得不那么有吸引力,例如乘以零或多个移位指令。由于我们在下面详细说明的原因,我们最终建议您使用 or 和全一掩码,使 x86 指令序列如下所示

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        movl    (%rsi), %edi            # Load potentially secret data from %rsi.
        orl     %eax, %edi

其他有用的模式可能是将加载本身折叠到 or 指令中,代价是寄存器到寄存器的复制。

部署此方法存在一些挑战

  1. x86 上的许多加载都被折叠到其他指令中。将它们分离会增加非常显著且昂贵的寄存器压力,并带来过高的性能成本。

  2. 加载可能不会针对通用寄存器,需要额外的指令将状态值映射到正确的寄存器类,并且可能需要更昂贵的指令以某种方式屏蔽该值。

  3. x86 上的标志寄存器很可能处于活动状态,并且难以廉价地保留。

  4. 加载的值比用于加载的指针和索引多得多。因此,强化加载的结果所需的指令比强化加载的地址所需的指令多得多(见下文)。

尽管存在这些挑战,但强化加载的结果至关重要地允许加载继续进行,因此对执行的总投机/乱序潜力影响大大减少。还有一些有趣的方法可以尝试缓解这些挑战,并使强化加载的结果在至少某些情况下变得可行。但是,我们通常预计当强化加载的值无利可图时会回退到强化地址本身的下一种方法。

折叠到数据不变操作中的加载可以在操作后进行强化

使这成为可能的第一关键是认识到 x86 上的许多操作是“数据不变”的。也就是说,由于特定的输入数据,它们没有(已知的)可观察到的行为差异。在实现处理私钥数据的加密原语时,这些指令通常被使用,因为它们不被认为会提供任何侧信道。同样,我们可以将其强化推迟到它们之后,因为它们本身不会引入投机执行侧信道。这会导致如下所示的代码序列

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate without leaking.
        orl     %eax, %edi

虽然对加载的(可能是秘密的)值进行加法运算,但这不会泄露任何数据,然后我们立即对其进行强化。

强化加载值的强化下沉到数据不变表达式图

我们可以概括前面的想法,并将强化下沉到表达式图中,跨越尽可能多的数据不变操作。这可以使用非常保守的规则来判断某物是否为数据不变。主要目标应该是使用单个强化指令处理多个加载

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate without leaking.
        addl    4(%rsi), %edi           # Continue without leaking.
        addl    8(%rsi), %edi
        orl     %eax, %edi              # Mask out bits from all three loads.
在 Haswell、Zen 和更新的处理器上保留标志的同时强化加载值

遗憾的是,x86 上没有有用的指令可以应用掩码到所有 64 位而不会触及标志寄存器。但是,我们可以通过将值零扩展到完整的字大小,然后使用 BMI2 shrx 指令至少向右移动原始位数来强化比字更窄的加载值(在 32 位系统上少于 32 位,在 64 位系统上少于 64 位)。

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        addl    (%rsi), %edi            # Load and accumulate 32 bits of data.
        shrxq   %rax, %rdi, %rdi        # Shift out all 32 bits loaded.

因为在 x86 上零扩展是免费的,所以这可以有效地强化加载的值。

强化加载的地址

当强化加载的值不适用时,最常见的原因是指令直接泄露信息(如 cmpjmpq),我们会切换到强化加载的地址而不是加载的值。这避免了通过展开加载或支付其他一些高成本来增加寄存器压力。

为了理解这在实践中是如何工作的,我们需要检查 x86寻址模式的确切语义,其完全通用的形式看起来像(%base,%index,scale)offset。这里%base%index是64位寄存器,它们可能包含任何值,并且可能受攻击者控制,而scaleoffset是固定的立即数。scale必须是1248,而offset可以是任何32位符号扩展的值。然后执行的查找地址的确切计算是:%base + (scale * %index) + offset,在64位2的补码模运算下。

这种方法的一个问题是,在加固之后,`%base + (scale *

然后一个大的正offset将索引到地址空间前两个GB内的内存中。虽然这些偏移量不受攻击者控制,但攻击者可以选择攻击恰好具有所需偏移量的加载操作,然后成功读取该区域的内存。这大大增加了攻击者的负担,并限制了攻击范围,但并没有消除它。为了完全关闭攻击,我们必须与操作系统合作,防止在地址空间的低两个GB内映射内存。

64位加载检查指令

我们可以使用以下指令序列来检查加载操作。在这些示例中,我们将%r8设置为-1的特殊值,它将在错误预测的路径中通过cmov传递到%rax

单寄存器寻址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rsi              # Mask the pointer if misspeculating.
        movl    (%rsi), %edi

双寄存器寻址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rsi              # Mask the pointer if misspeculating.
        orq     %rax, %rcx              # Mask the index if misspeculating.
        movl    (%rsi,%rcx), %edi

这将导致一个接近零的负地址或offset将地址空间回绕到一个小正地址。对于大多数操作系统,小的负地址将在用户模式下导致故障,但需要高地址空间可供用户访问的目标可能需要调整上面使用的确切序列。此外,低地址需要由操作系统标记为不可读,以完全加固加载操作。

RIP相对寻址更容易破坏

有一种常见的寻址模式习惯用法,它更难检查:相对于指令指针的寻址。我们无法更改指令指针寄存器的值,因此我们面临着一个更难的问题,即通过*仅*更改%index来强制%base + scale * %index + offset为无效地址。我们唯一的优势是攻击者也无法修改%base。如果我们使用上面的快速指令序列,但仅将其应用于索引,我们将始终访问%rip + (scale * -1) + offset。如果攻击者可以找到一个具有此地址的加载操作,该地址恰好指向秘密数据,那么他们就可以访问它。但是,加载程序和基础库也可以简单地拒绝在程序中任何文本的 2GB 内映射堆、数据段或栈,就像它可以保留地址空间的低 2GB 一样。

标志寄存器再次使一切变得困难

不幸的是,使用orq指令的技术在 x86 上存在一个严重的缺陷。使它易于累积状态的因素,即包含谓词的标志寄存器,在这里会导致严重的问题,因为它们可能处于活动状态并被加载指令或后续指令使用。在 x86 上,orq指令**设置**标志,并将覆盖任何已有的标志。这使得将它们插入指令流变得非常危险。不幸的是,与加固加载值时不同,我们这里没有后备方案,因此我们必须有一个完全通用的方法可用。

生成这些序列时,我们首先要做的是尝试分析周围的代码,以证明标志实际上并非处于活动状态或正在使用。通常,它是由一些其他指令设置的,这些指令碰巧设置了标志寄存器(就像我们的!),但没有实际的依赖关系。在这些情况下,直接插入这些指令是安全的。或者,我们可能能够将它们提前移动以避免覆盖使用的值。

但是,这最终可能是不可行的。在这种情况下,我们需要在这些指令周围保留标志

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        pushfq
        orq     %rax, %rcx              # Mask the pointer if misspeculating.
        orq     %rax, %rdx              # Mask the index if misspeculating.
        popfq
        movl    (%rcx,%rdx), %edi

使用pushfpopf指令在插入的代码周围保存标志寄存器,但代价很高。首先,我们必须将标志存储到栈中并重新加载它们。其次,这会导致栈指针动态调整,需要使用帧指针来引用溢出到栈中的临时变量等。

在较新的 x86 处理器上,我们可以使用lahfsahf指令将除溢出标志之外的所有标志保存在寄存器中,而不是栈中。然后,我们可以使用setoadd在寄存器中保存和恢复溢出标志。结合起来,这将以与上述相同的方式保存和恢复标志,但使用两个寄存器而不是栈。如果在大多数情况下略微比pushfpopf便宜,但这仍然非常昂贵。

Haswell、Zen 和更新的处理器上的无标志替代方案

从 Haswell 和 Zen 处理器上提供的 BMI2 x86 指令集扩展开始,有一个不设置任何标志的移位指令:shrx。我们可以使用它和lea指令来实现类似于上述代码序列的代码序列。但是,这些仍然非常微不足道地慢,因为在大多数现代 x86 处理器中,能够分派移位指令的端口比or指令的端口少。

快速,单寄存器寻址模式

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shrxq   %rax, %rsi, %rsi        # Shift away bits if misspeculating.
        movl    (%rsi), %edi

这将使寄存器归零或归一,并且寻址模式中除偏移量之外的所有内容都小于或等于 9。这意味着只能保证完整地址小于(1 << 31) + 9。操作系统可能希望保护低地址空间的额外一页以说明这一点

优化

这种方法的大部分成本来自以这种方式检查加载操作,因此优化这一点非常重要。但是,除了使应用检查的指令序列更高效(例如,避免pushfqpopfq序列)之外,唯一重要的优化是在不引入漏洞的情况下检查更少的加载操作。我们应用了几种技术来实现这一点。

不要检查来自编译时常量栈偏移量的加载操作

我们通过跳过使用固定帧指针偏移量的加载操作的检查来实现此优化。

此优化的结果是,诸如重新加载溢出寄存器或访问全局字段之类的模式不会被检查。这是一个非常显著的性能提升。

不要检查相关的加载操作

此缓解策略之所以有效的一个核心部分是它在加载的地址上建立了数据流检查。但是,这意味着如果地址本身已经使用经过检查的加载操作加载,则无需检查相关的加载操作,前提是它在与经过检查的加载操作相同的基本块内,并且因此没有其他谓词对其进行保护。考虑以下代码

        ...

.LBB0_4:                                # %danger
        movq    (%rcx), %rdi
        movl    (%rdi), %edx

这将转换为

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        orq     %rax, %rcx              # Mask the pointer if misspeculating.
        movq    (%rcx), %rdi            # Hardened load.
        movl    (%rdi), %edx            # Unhardened load due to dependent addr.

这不会检查通过%rdi的加载操作,因为该指针依赖于已检查的加载操作。

使用单个 lfence 保护大型、加载密集型块

在以(非常)大量的需要独立保护*并且*需要加固加载操作地址的加载操作开头的块的开头使用单个lfence指令可能是值得的。但是,这在实践中不太可能有利可图。当*正确*地投机执行时,加固的延迟命中需要超过lfence的延迟命中。但在这种情况下,lfence的成本是投机执行的完全损失(至少)。到目前为止,我们对使用lfence的性能成本的证据表明,很少有(如果有的话)热代码模式在这种权衡会有意义。

破坏安全模型的有诱惑力的优化

考虑了一些由于未能维护安全模型而没有实现的优化。其中一个特别值得讨论,因为许多其他的优化都会简化为它。

我们想知道是否只需要检查基本块中的第一个加载操作。如果检查按预期工作,它会形成一个无效指针,该指针甚至不会在硬件中进行虚拟地址转换。它应该在其处理的早期阶段发生故障。也许这会及时阻止事情发生,从而使错误预测的路径无法泄露任何秘密。但这最终不起作用,因为处理器从根本上来说是乱序的,即使在其推测域中也是如此。结果,攻击者可能导致初始地址计算本身停滞,并允许任意数量的不相关加载(包括被攻击的秘密数据加载)通过。

过程间检查

现代 x86 处理器可能会推测进入被调用函数并从函数推测到它们的返回地址。结果,我们需要一种方法来检查在错误预测的谓词之后发生的加载操作,但加载操作和错误预测的谓词位于不同的函数中。从本质上讲,我们需要对谓词状态跟踪进行一些过程间泛化。在函数之间传递谓词状态的主要挑战在于,我们希望不需要更改 ABI 或调用约定以使这种缓解措施更容易部署,并且进一步希望以这种方式缓解的代码能够轻松地与未缓解的代码混合,而不会完全失去缓解的价值。

将谓词状态嵌入到栈指针的高位

我们可以使用允许硬化指针的技术将谓词状态传递到函数中和函数之外。栈指针在函数之间很容易传递,我们可以测试其高位是否被设置以检测它何时由于错误预测而被标记。调用点指令序列如下所示(假设错误预测的状态值为-1

        ...

.LBB0_4:                                # %danger
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shlq    $47, %rax
        orq     %rax, %rsp
        callq   other_function
        movq    %rsp, %rax
        sarq    63, %rax                # Sign extend the high bit to all bits.

这首先将谓词状态放入%rsp的高位,然后调用函数,之后再从%rsp的高位读回它。在正确执行(推测性或非推测性)时,这些都是空操作。在错误预测时,栈指针最终将变为负数。我们安排它保持一个规范地址,但除此之外,保留低位不变,以允许栈调整正常进行,而不会破坏这一点。在被调用函数中,我们可以提取此谓词状态,然后在返回时重置它。

other_function:
        # prolog
        callq   other_function
        movq    %rsp, %rax
        sarq    63, %rax                # Sign extend the high bit to all bits.
        # ...

.LBB0_N:
        cmovneq %r8, %rax               # Conditionally update predicate state.
        shlq    $47, %rax
        orq     %rax, %rsp
        retq

当所有代码都以这种方式缓解时,这种方法是有效的,甚至可以承受非常有限的进入未缓解代码的范围(状态将在未缓解函数中进出往返,只是不会更新)。但它确实有一些局限性。将状态合并到%rsp中需要付出代价,并且它不会使已缓解的代码免受未缓解的调用方中错误预测的影响。

使用这种形式的过程间缓解也有一个优势:通过形成这些无效的栈指针地址,我们可以防止错误预测的返回成功读取错误预测写入到实际栈的值。这首先通过在计算栈上返回地址的地址和我们的谓词状态之间形成数据依赖关系来实现。即使满足条件,如果错误预测导致状态被污染,则生成的栈指针将无效。

重写内部函数的 API 以直接传播谓词状态

(尚未实现。)

对于内部函数,我们可以选择直接调整其 API 以接受谓词作为参数并返回它。对于进入函数,这可能比嵌入到%rsp中更便宜。

使用lfence保护函数转换

一个lfence指令可以用来防止后续加载在所有先前的错误预测的谓词解决之前推测性地执行。我们可以使用此更广泛的屏障来防止函数之间执行的推测性加载。我们在入口块中发出它以处理调用,并在每个返回之前发出它。这种方法还具有在与未缓解代码混合时提供最高程度的缓解的优势,方法是停止所有进入已缓解函数的错误预测,而不管调用方发生了什么。但是,这种混合本质上风险更大。这种混合是否构成充分的缓解措施需要仔细分析。

不幸的是,实验结果表明,对于某些代码模式,这种方法的性能开销非常高。一个典型的例子是任何形式的递归求值引擎。当使用lfence缓解时,热、快速调用和返回序列会表现出显着的性能下降。仅此组件就可以使性能下降 2 倍或更多,即使仅在代码混合中使用,也使其成为一个不愉快的权衡。

使用内部 TLS 位置传递谓词状态

我们可以定义一个特殊的线程本地值来保存函数之间的谓词状态。这通过使用调用方和被调用方之间的一个侧信道来通信谓词状态,从而避免了直接的 ABI 影响。它还允许隐式零初始化状态,这允许未检查的代码成为第一个执行的代码。

但是,这需要在入口块中从 TLS 加载,在每次调用和每次返回之前存储到 TLS,以及在每次调用之后从 TLS 加载。结果,预计它将比使用%rsp甚至可能在函数入口块中使用lfence要贵得多。

定义新的 ABI 和/或调用约定

我们可以定义一个新的 ABI 和/或调用约定,以显式地将谓词状态传递到函数中和函数之外。如果其他方法的性能都不够,这可能很有趣,但它使部署和采用变得极其复杂,甚至可能不可行。

高级替代缓解策略

有完全不同的替代方法可以缓解变体 1 攻击。大多数 讨论 到目前为止,重点是通过手动重写代码以包含不受攻击的指令序列来缓解 Linux 内核(或其他内核)中特定已知的可攻击组件。对于 x86 系统,这是通过在代码路径中注入lfence指令来完成的,如果推测性地执行,该指令将泄露数据,或者通过重写内存访问以无分支地屏蔽到已知的安全区域来完成。在 Intel 系统上,lfence 将阻止秘密数据的推测性加载。在 AMD 系统上,lfence目前是空操作,但可以通过设置 MSR 使其调度序列化,从而排除代码路径的错误预测(缓解 G-2 + V1-1)。

但是,这依赖于查找和枚举代码中所有可能被攻击以泄露信息的位置。虽然在某些情况下,静态分析可以有效地大规模执行此操作,但在许多情况下,它仍然依赖于人工判断来评估代码是否可能易受攻击。特别是对于接受较少详细审查但仍然对这些攻击敏感的软件系统,这似乎是一个不切实际的安全模型。我们需要一种自动且系统的缓解策略。

在条件边上自动使用lfence

扩展现有手动编码缓解措施的一种自然方法是简单地将lfence指令注入到每个条件分支的目标和贯穿目的地。这确保了没有谓词或边界检查可以被推测性地绕过。但是,这种方法的性能开销,简单地说,是灾难性的。然而,它仍然是在此项工作之前已知的唯一真正“默认安全”的方法,并且是性能的基线。

尝试解决此问题并使其更易于部署的一种方法是MSVC 的 /Qspectre 开关。他们的技术是在编译器中使用静态分析,仅将lfence指令插入到存在攻击风险的条件边。但是,最初 分析表明,这种方法是不完整的,并且只捕获了可攻击模式的一个小而有限的子集,这些模式恰好与最初的概念证明非常相似。因此,虽然其性能可以接受,但它似乎并不是一种充分的系统缓解措施。

性能开销

这种全面缓解措施的性能开销非常高。但是,它与先前推荐的方法(如lfence指令)相比非常有利。就像用户可以限制lfence的范围以控制其性能影响一样,这种缓解技术也可以限制其范围。

但是,了解获得完全缓解的基线需要付出什么成本非常重要。在这里,我们假设目标是 Haswell(或更新)处理器,并使用所有技巧来提高性能(因此,程序中未保护的低 2gb 和 PC 周围 +/- 2gb)。我们运行了 Google 的微基准测试套件和一个使用 ThinLTO 和 PGO 构建的大型高度调整过的服务器。所有这些都使用-march=haswell构建,以访问 BMI2 指令,基准测试在大型 Haswell 服务器上运行。我们收集了使用基于lfence的缓解措施和此处介绍的加载硬化措施的数据。总结一下,使用加载硬化缓解的速度比使用lfence缓解快 1.77 倍,并且与普通程序相比,加载硬化的开销可能在 10% 到 50% 之间,大多数大型应用程序的开销在 30% 或更少。

基准测试

lfence

加载硬化

缓解加速

Google 微基准测试套件

-74.8%

-36.4%

2.5 倍

大型服务器 QPS(使用 ThinLTO 和 PGO)

-62%

-29%

1.8 倍

以下是微基准套件结果的可视化,有助于显示结果的分布,这些分布在摘要中或多或少地丢失了。y 轴是负载硬化相对于lfence的速度提升比率的对数刻度(向上 -> 更快 -> 更好)。每个箱线图代表一个微基准,该基准可能测量了许多不同的指标。红线表示中位数,方框表示第一和第三四分位数,须线表示最小值和最大值。

Microbenchmark result visualization

我们还没有关于 SPEC 或 LLVM 测试套件的基准数据,但我们可以努力获取这些数据。尽管如此,以上内容应该可以很好地描述性能,并且特定的基准测试不太可能揭示特别有趣的特性。

未来工作:细粒度控制和 API 集成

此技术的性能开销可能非常大,并且用户希望控制或降低开销。这里有一些有趣的选项会影响所使用的实现策略。

一个特别吸引人的选项是允许以合理的细粒度(例如,在每个函数的基础上)选择加入和选择退出此缓解措施,包括对内联决策的智能处理——可以阻止受保护的代码内联到不受保护的代码中,并且不受保护的代码将在内联到受保护的代码中时变得受保护。对于只有有限的代码集可由外部控制的输入访问的系统,可以通过此类机制限制缓解范围,而不会影响应用程序的整体安全性。性能影响也可能集中在几个关键函数上,这些函数可以通过性能开销较低的方式手动缓解,而应用程序的其余部分则接收自动保护。

对于限制缓解范围或手动缓解热点函数,都需要有一些支持来混合缓解和未缓解的代码,而不会完全破坏缓解。对于第一个用例,如果在从未缓解的代码进行错误推测期间调用缓解的代码时,缓解的代码仍然安全,这将特别理想。

对于第二个用例,可能需要将自动缓解技术连接到显式缓解 API,例如 http://wg21.link/p0928 中描述的 API(或任何其他最终 API),以便有一种干净的方法从自动缓解切换到手动缓解,而不会立即暴露漏洞。但是,在 API 更好地建立之前,很难设计出如何做到这一点。当这些 API 成熟时,我们将重新审视这个问题。