章节大纲

  • 本任务的目的是了解 CPU 中的乱序执行。我们将通过实验帮助学生观察这种类型的执行过程。
     
    1. 乱序执行

    Spectre 攻击依赖于大多数 CPU 实现的一个重要特性。为了理解这一特征,我们来看看以下代码。这段代码检查 x 是否小于 size,如果是,则变量 data 将被更新。假设 size 的值为 10,因此当 x 等于 15 时,第 3 行的代码不会被执行。
    1  data = 0;
    2  if (x < size) {  
    3     data = data + 5;
    4  }

    从 CPU 外部角度来观察这段代码,上述陈述是正确的。然而,如果我们深入到 CPU 的微架构层面查看执行顺序,则会发现即使 x 大于 size,第 3 行也可能被执行。这是因为现代 CPU 采用了一种重要的优化技术,称为乱序执行。乱序执行是一种优化技术,它允许 CPU 最大化利用所有的执行单元。只要指令所需要的数据已经准备好了,CPU 会并行地执行它们,而不是严格按照顺序来执行指令。

    在上述代码示例中,在微架构级别,第 2 行涉及两个操作:从内存加载 size 的值,以及比较该值与 x 的值。如果 size 不在 CPU 缓存中,则可能需要数百个 CPU 时钟周期才能读取其值。现代 CPU 不会闲置地等待比较的结果,而是预测比较的结果,并基于预测来执行相应的分支。由于这种指令的执行没有等前一个指令的结束就开始了,因此被称为乱序执行,在这里,这种乱序执行也叫推测性执行。在进行乱序执行之前,CPU 会存储其当前状态和寄存器的值。当 size 的值最终到达时,CPU 将检查实际结果。如果预测是对的话,则推测性执行的操作会被接受,从而节省了时间。如果预测是错误的,CPU 将恢复到其保存的状态,所有由乱序执行产生的结果都会被丢弃,就好像从未发生过一样。这就是为什么从外部来看,我们是看不到第 3 行被执行了的。下图展示了由于示例代码中的第 2 行引起的乱序执行。
     
    幽灵
     
    Intel 和其他几家 CPU 制造商在设计乱序执行时犯了一个严重的错误。如果提前执行的指令不应该被执行,那么他们应当清除乱序执行在寄存器和内存的痕迹,因此该执行不会产生任何可见效果。然而,他们忘记了缓存的影响。在乱序执行期间,被使用的内存会被存储在缓存中。如果乱序执行的结果需要被丢弃,则由该执行引起的缓存操作也应该被清除。不幸的是,在大多数 CPU 中并非如此。因此这就会产生可观察的痕迹。使用任务 1 和 2 中的侧信道技术,我们可以观察到这些痕迹。Spectre 攻击巧妙地利用了这种可观测的痕迹来找到受保护的秘密值。

     
    2. 实验

    在这个任务中,我们用一个实验来观察由乱序执行引起的效果,所用代码如下所示(SpectreExperiment.c)。
    /* SpectreExperiment.c */
    #define CACHE_HIT_THRESHOLD (80)
    #define DELTA 1024
    
    int size = 10;
    uint8_t array[256*4096];
    uint8_t temp = 0;
    
    void victim(size_t x)
    {
      if (x < size) {                          🅰
         temp = array[x * 4096 + DELTA];       🅱
      }
    }
    
    int main()
    {
      int i;
    
      // 将探测数组的缓存清除
      flushSideChannel();
    
      // 训练 CPU 使其在 victim() 中选择正确的分支
      for (i = 0; i < 10; i++) {               🅲
          victim(i);                           🅳
      }
    
      // 利用乱序执行
      _mm_clflush(&size);                      ★
      for (i = 0; i < 256; i++)  
          _mm_clflush(&array[i*4096 + DELTA]);
      victim(97);                              🅴
    
      // 重新加载探测数组
      reloadSideChannel();
      return (0);
    }

    为了使 CPU 进行推测性执行,它们需要能够预测 if 条件的结果。CPU 会记录每个分支在过去选择情况,然后用这些历史记录来预测在推测性执行中应选取哪个分支。因此,如果我们希望 CPU 在推测性执行中选取某个特定的分支,我们应当训练 CPU,使得该分支成为预测结果。该训练在第 🅲 行的循环里执行。在循环内部,我们调用 victim() 函数并传递一个较小的参数(从 0 到 9)。这些值都小于 size 的值,因此第 🅰 行当中的 if 条件总是真,条件是真的分支总是被执行。我们通过训练 CPU,来让它在后面的预判中选择条件是真的分支。

    一旦 CPU 进行了训练,我们将一个较大的值(97)传递给 victim() 函数(第 🅴 行)。这个值大于 size 的值,所以在实际执行中,if 条件会是假而非为真。然而我们已经清除了内存中的变量 size,因此获取其值需要一些时间。这时 CPU 就会通过预测来推测性执行后面的指令。

     
    任务

    请编译上述 SpectreExperiment.c 程序(编译方法见实验环境章节),运行该程序并描述观察结果。由于 CPU 中的一些其他缓存可能会导致侧信道中的噪声,我们稍后会减少这种噪声,但目前我们可以多次运行这个程序来观察效果。当 97 被传递给 victim() 时,观察第 🅱 行是否被执行。请完成以下操作:
     
    • 注释掉标记为 ★ 的行并重新执行一次。解释观察结果。完成后,请不要注释这行,以免影响后续任务。
    • 将第 🅳 行替换为 victim(i + 20),重新编译代码并解释观察结果。