Intel CET 与虚函数表劫持与 COOP 攻击
Intel CET
Intel CET(Controlflow Enforcement Technology / 控制流加固技术)CFI(Control Flow Integrity / 控制流完整性)检查是近些年兴起的保护机制,其中包含 SHSTK(SHadow STacK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake(11 代)及以上 CPU 才支持。
SHSTK 是一种后向控制流完整性(Backward-edge CFI)检查(简单来说是在“返回”时的检查),一般需要硬件支持。程序运行时在不同内存映射中同时维护两个调用栈,一个是众所周知的栈区,另一个是新增的“影子栈”,只存储返回地址。CPU 在过程调用与返回时将同时操作两个栈,如果在返回时发现正常调用栈的返回地址与影子栈中的不同就产生异常。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,也就是所有 ROP 攻击都将失效。
IBT 是一种前向控制流完整性(Forward-edge CFI)检查(简单来说是在“调用”时的检查)。CPU 每当执行间接调用及跳转时都检查目标的指令是否为 endbr64/32
,如果是就不执行任何操作(类似 nop
),如果不是就产生异常。这样一来大幅削弱了 COP / JOP 攻击。Forward-edge CFI 可以在软件层面模拟,例如 Windows CFG、Clang CFI 等,但是会大幅降低程序性能,每次虚函数调用等都需要校验,实用价值有限。
目前 Windows 只支持 Shadow Stack(Windows CFG),还不支持以 endbr
指令为基础的 IBT(存疑,Windows 我不熟);Linux 上也几乎没有程序启用这项保护,算是高度实验性的功能吧。不过 SHSTK 保护很可能在未来成为继 RELRO、Stack Canary、NX、ASLR (PIE) 后又一项常见的用户态保护机制。下文即将提到的 COOP 攻击对于未来的 Pwner 可能也会是像 ROP 一样必学的攻击方式?(Hackergame 2024 中有一道开启了 SHSTK 和 IBT 的 Pwn 题,但好像没开 ASLR,解法是直接篡改 shadow stack 本身再 ROP。)
验证 Intel CET 是否可用
接下来是在 Linux 上实验启用 CET 的具体操作,主要是 Shadow Stack。首先需要内核启用 CET 特性,Linux 6.2 默认启用 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, SHSTK
GCC / glibc 环境启用 Intel CET
Glibc 已经在其 ld.so 中初步支持 Intel CET 保护。
我的环境:
CPU: 13th Gen Intel(R) Core(TM) i7-13700H (20) @ 5.00 GHz
Kernel: 6.14.7-zen2-1-zen (archlinux)
glibc: GNU C Library (GNU libc) stable release version 2.41
如果自己的电脑没有 Intel CET 软硬件支持,推荐使用 eqqie 学长开发的 qemu 插件(github)。我的电脑由于恰好满足所有条件,所以我图方便直接实机测试了比较粗暴。开虚拟环境总是对的。
编译时
使用 gcc 编译时添加以下编译参数:
gcc -fcf-protection=full
运行时
目前 glibc 动态链接器 ld.so 默认不启用 Intel CET 特性(见源码),需要设置以下环境变量以强制启用:
GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
将以上环境变量中on
改为permissive
可以以更兼容的方式启用 CET(SHSTK / IBT),当程序本身或其动态链接库不支持 CET 特性时,动态链接器将自动关闭 CET。
保护效果
编写这样一个简单的 C 程序:
# File: test_cet.c
#include <stdio.h>
int main(void) {
puts("Hello, shadow stack!");
return 0;
}
编译运行:
$ gcc -fcf-protection=branch test_cet.c -o test_cet
$ GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK ./test_cet
Hello, shadow stack!
程序正常输出,但这并不是重点。接下来使用 gdb + pwndbg 调试验证 Shadow Stack 确实已启用:
$ gdb ./a.out
pwndbg> r
...
pwndbg> set environment GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
pwndbg> start
...
pwndbg> vmmap
...
0x7ffff7400000 0x7ffff7c00000 rw-p 800000 0 [anon_7ffff7400]
...
pwndbg> b _IO_file_write
...
pwndbg> c
...
pwndbg> tele 0x7ffff7bff000 1000
00:0000│ 0x7ffff7bff000 ◂— 0
... ↓ 496 skipped
1f1:0f88│ 0x7ffff7bfff88 —▸ 0x7ffff7e99aac (sbrk+108) ◂— test eax, eax
1f2:0f90│ 0x7ffff7bfff90 —▸ 0x7ffff7e252e6 (__default_morecore+22) ◂— cmp rax, -1
1f3:0f98│ 0x7ffff7bfff98 —▸ 0x7ffff7e2622b (sysmalloc+1019) ◂— mov r10, qword ptr [rbp - 0x50]
1f4:0fa0│ 0x7ffff7bfffa0 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f5:0fa8│ 0x7ffff7bfffa8 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f6:0fb0│ 0x7ffff7bfffb0 —▸ 0x7ffff7e28002 (malloc+434) ◂— test rax, rax
1f7:0fb8│ 0x7ffff7bfffb8 —▸ 0x7ffff7e012fd (_IO_file_doallocate+173) ◂— mov eax, 1
1f8:0fc0│ 0x7ffff7bfffc0 —▸ 0x7ffff7e0d2dd (new_do_write+93) ◂— movzx edi, word ptr [rbx + 0x80]
1f9:0fc8│ 0x7ffff7bfffc8 —▸ 0x7ffff7e0e191 (_IO_do_write+33) ◂— cmp rbx, rax
1fa:0fd0│ 0x7ffff7bfffd0 —▸ 0x7ffff7e0e70b (_IO_file_overflow+283) ◂— cmp eax, -1
1fb:0fd8│ 0x7ffff7bfffd8 —▸ 0x7ffff7e03dba (puts+474) ◂— cmp eax, -1
1fc:0fe0│ 0x7ffff7bfffe0 —▸ 0x555555555160 (main+23) ◂— mov eax, 0
1fd:0fe8│ 0x7ffff7bfffe8 —▸ 0x7ffff7da8e08 (__libc_start_call_main+120) ◂— mov edi, eax
1fe:0ff0│ 0x7ffff7bffff0 —▸ 0x7ffff7da8ecc (__libc_start_main+140) ◂— mov r14, qword ptr [rip + 0x1c10a5]
1ff:0ff8│ 0x7ffff7bffff8 —▸ 0x555555555075 (_start+37) ◂— hlt
pwndbg> bt
#0 _IO_new_file_write (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0, n=18) at fileops.c:1174
#1 0x00007ffff7e0d2dd in new_do_write (fp=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0 "Hello, Intel CET!\n",
to_do=to_do@entry=18) at /usr/src/debug/glibc/glibc/libio/libioP.h:1030
#2 0x00007ffff7e0e191 in _IO_new_do_write (fp=fp@entry=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=<optimized out>, to_do=18) at fileops.c:426
#3 0x00007ffff7e0e70b in _IO_new_file_overflow (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, ch=10) at fileops.c:784
#4 0x00007ffff7e03dba in __GI__IO_puts (str=0x555555556004 "Hello, Intel CET!") at ioputs.c:41
#5 0x0000555555555160 in main ()
#6 0x00007ffff7da8e08 in __libc_start_call_main (main=main@entry=0x555555555149 <main>, argc=argc@entry=1,
argv=argv@entry=0x7fffffffe2f8) at ../sysdeps/nptl/libc_start_call_main.h:58
#7 0x00007ffff7da8ecc in __libc_start_main_impl (main=0x555555555149 <main>, argc=1, argv=0x7fffffffe2f8, init=<optimized out>,
fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2e8) at ../csu/libc-start.c:360
#8 0x0000555555555075 in _start ()
可以看到程序运行时多出一个较大的匿名内存区域,这就是 Shadow Stack。在一个 glibc 中调用层级较深的函数下断点,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈),与 back trace 相符。
我们可以试试在 SHSTK 开启时栈溢出修改栈上返回地址劫持控制流是否还有效。
#include <stdio.h>
int main() {
char buf[0x10];
gets(buf);
return 0;
}
$ gcc -fcf-protection=full --ansi -fno-stack-protector main.c
pwndbg> r
Starting program: /home/rik/Desktop/a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
aaaaaaaaaaaaaaaaaaaaaaaa
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555167 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
──[ REGISTERS / show-flags off / show-compact-regs off ]──
RAX 0
RBX 0
RCX 0x7ffff7f6c7c0 ◂— 0
RDX 0x7ffff7f6c7c0 ◂— 0
RDI 0x7fffffffe151 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa'
RSI 0x5555555592a1 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa\n'
R8 0x5555555592b9 ◂— 0
R9 0xfbad2288
R10 0
R11 0x202
R12 0x7fffffffe288 —▸ 0x7fffffffe658 ◂— '/home/rik/Desktop/a.out'
R13 1
R14 0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe310 —▸ 0x555555554000 ◂— 0x10102464c457f
R15 0x555555557dd8 —▸ 0x5555555550f0 ◂— endbr64
RBP 0x6161616161616161 ('aaaaaaaa')
RSP 0x7fffffffe168 —▸ 0x7ffff7dab600 ◂— cmp al, 0xff
RIP 0x555555555167 (main+30) ◂— ret
──[ DISASM / x86-64 / set emulate on ]──
► 0x555555555167 <main+30> ret <0x7ffff7dab600>
↓
0x7ffff7dab600 cmp al, 0xff 0x0 - 0xff EFLAGS => 0x213 [ CF pf AF zf sf IF df of ]
...
pwndbg> xinfo 0x7ffff7dab600
Extended information for virtual address 0x7ffff7dab600:
Containing mapping:
0x7ffff7da8000 0x7ffff7f18000 r-xp 170000 24000 /usr/lib/libc.so.6
可以看到程序在 ret
处就已经产生段错误,连 RIP
都还不是篡改后的地址。
用户态的 Shadow Stack 目前依旧是实验性的功能,在我自己电脑上测试,设置上述环境变量(on
)后连 gdb 都打不开了...
Fatal signal: 段错误
----- Backtrace -----
0x5ef6d4be903e ???
0x5ef6d4d107c0 ???
0x5ef6d4d109bb ???
0x746656c4c1cf ???
0x7466508bf125 ???
---------------------
A fatal error internal to GDB has been detected, further
debugging is not possible. GDB will now terminate.
This is a bug, please report it. For instructions, see:
<https://www.gnu.org/software/gdb/bugs/>.
fish: Job 1, 'GLIBC_TUNABLES=glibc.cpu.x86_sh…' terminated by signal SIGSEGV (Address boundary error)
Linux 用户态启用 SHSTK 底层实现
Linux 用户态动态开关 Shadow Stack 主要依赖 arch_prctl
系统调用:
arch_prctl(ARCH_SHSTK_ENABLE, unsigned long feature)
启用‘feature’指定的特性。一次只能操作一个特性。
arch_prctl(ARCH_SHSTK_DISABLE, unsigned long feature)
关闭‘feature’指定的特性。一次只能操作一个特性。
arch_prctl(ARCH_SHSTK_LOCK, unsigned long features)
锁定特性的启用或关闭状态。‘features’ 是所有需要锁定的特性的遮罩(按位或)。没有按位或设定的特性的锁定状态不变,之后无法再启用或关闭已锁定的特性。
arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
锁定特性。‘features’ 是所有需要解锁的特性的遮罩。没有按位或设定的特性的锁定状态不变。只能在 ptrace 时使用。
arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
将当前启用的特性拷贝到 addr 地址处。特性启用状态描述方式同上述用法中传入的 ‘features’。
可指定的特性:
ARCH_SHSTK_SHSTK - Shadow stack
ARCH_SHSTK_WRSS - WRSS
Glibc 动态链接器 ld.so 中有关启用 SHSTK 的源码(sysdeps/unix/sysv/linux/x86_64/dl-cet.h
):
/* 用宏启用 shadow stack 以避免调用栈下溢。(刚启用时 shadow stack 为空,此时若返回将触发保护。)*/
#define ENABLE_X86_CET(cet_feature) \
if ((cet_feature & GNU_PROPERTY_X86_FEATURE_1_SHSTK)) \
{ \
long long int kernel_feature = ARCH_SHSTK_SHSTK; \
INTERNAL_SYSCALL_CALL (arch_prctl, ARCH_SHSTK_ENABLE, \
kernel_feature); \
}
#define X86_STRINGIFY_1(x) #x
#define X86_STRINGIFY(x) X86_STRINGIFY_1 (x)
/* 如果已在 GL(dl_x86_feature_1) 启用,则在调用 _dl_init 前启用 shadow stack。调用 _dl_setup_x86_features 以初始化 shadow stack。*/
#define RTLD_START_ENABLE_X86_FEATURES \
"\
# 检查 shadow stack 是否已在 GL(dl_x86_feature_1) 启用。\n\
movl _rtld_local+" X86_STRINGIFY (RTLD_GLOBAL_DL_X86_FEATURE_1_OFFSET) "(%rip), %edx\n\
testl $" X86_STRINGIFY (X86_FEATURE_1_SHSTK) ", %edx\n\
jz 1f\n\
# 如果在 GL(dl_x86_feature_1) 中启用,启用 shadow stack。\n\
movl $" X86_STRINGIFY (ARCH_SHSTK_SHSTK) ", %esi\n\
movl $" X86_STRINGIFY (ARCH_SHSTK_ENABLE) ", %edi\n\
movl $" X86_STRINGIFY (__NR_arch_prctl) ", %eax\n\
syscall\n\
1:\n\
# 将 GL(dl_x86_feature_1) 传参给 _dl_cet_setup_features。\n\
movl %edx, %edi\n\
# 为调用 _dl_cet_setup_features 对齐栈指针。\n\
andq $-16, %rsp\n\
call _dl_cet_setup_features\n\
# 从 %r12 和 %r13 恢复 %rax 和 %rsp。\n\
movq %r12, %rax\n\
movq %r13, %rsp\n\
"
所以其实可以这样手动在程序中使用 shadow stack 而无需依赖 glibc ld.so(注意要使用宏函数,原因见上方源码中注释):
#include <stdio.h>
#define ENABLE_SHSTK \
asm("mov $0x5001, %rdi;" \
"mov $0x1, %rsi;" \
"mov $158, %rax;" \
"syscall;");
#define DISABLE_SHSTK \
asm("mov $0x5002, %rdi;" \
"mov $0x1, %rsi;" \
"mov $158, %rax;" \
"syscall;");
int main(void) {
ENABLE_SHSTK
// ...
DISABLE_SHSTK // 或者直接 exit(0);
return 0;
}
SHSTK 内核底层实现
arch_prctl
在处理 SHSTK 相关操作时会转到 shstk_prctl
,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c
。SHSTK 本身主要由硬件实现,内核只负责检查参数、管理 shadow stack 内存页、通过 wrss
指令设置相关寄存器和异常处理等。
在申请 shadow stack 内存页 do_mmap
时,flags
参数传入了一个新的标志 VM_SHADOW_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<Base *> 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。
COOP
COOP(Counterfeit Object Oriented Programming / 面向伪对象编程)是一种新的代码重用攻击。其实我们早已见过 COOP 攻击:高版本 glibc 堆利用往往结合 IO_FILE 利用,“打 IO”就是一种原始的 COOP,只是不在 C++ 中。通过篡改 IO_FILE
中某些作为跳表函数参数的字段,篡改 vtable
,构造 system("/bin/sh")
。然而我们的目标是实现几乎任意代码执行,需要真正的基于 vfgadgets 的 COOP。
Vfgadgets 是 COOP 论文提出的新型 gadgets,由于 SHSTK + IBT,我们能够利用的 gadgets 从 ret
前的代码片段(ROP gadgets)变成了完整的函数——vfgadget。vfgadgets 大致可以分为 Main Loop Gadget(将其他 vfgadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;
)、Invoker Gadget(类似 ROP 中的 system
)和 Collector Gadget(存储 Invoker 的结果)。
以上是一张关于 COOP 攻击十分经典的配图
搜寻 vfgadgets 的工具(类似 ropper
):https://github.com/x86-512/VXpp
我觉得理解 COOP 需要搞清楚这几种 vfgadgets 的来源,以下是高度简化的模型:
// 这个类实际上应该不会生成虚函数,所有 vfgadgets 也不是在同一个类中,只为简化理解。
class Vfgadgets {
size_t data; // 数据类型不是重点
size_t *addr;
std::vector<Base *> objs; // 也可以是更简单的:Base* objs[N];
virtual void looper() {
for (auto obj : objs) {
obj->func();
}
}
virtual void invoker() {
this->victim_vfunc();
}
virtual void loader() {
size_t var; // 寄存器变量
var = this->data; // 也可以是 var = *(this->addr) 之类
// 总之是从对象写入寄存器
}
virtual void collector(size_t arg) {
this->data = arg; // 也可以是 *(this->addr) = arg; *(this->addr) = 1234; 之类
// 总之是从寄存器或立即数写入对象
}
virtual void victim_vfunc() {
// 这里的函数体不是重点,反正会被篡改成另一个函数。
}
};
其实为了给这些 vfgadgets 再分类,原论文用了很多概念和抽象,我写得还是过于不严谨了(应该比较直观吧)。这几种 vfgadgets 中最重要的是 Main Loop Gadget(Looper),它将所有真正有用的 vfgadgets 串联起来,构成链式调用,达到 ROP 不断返回到各种代码片段连成一串的效果。Looper 从当前对象字段中取出类似数组的容器,其中是有虚函数表的对象,并遍历执行每个对象的某个虚函数。这在开启了优化的大型 C++ 程序特别是 GUI 程序中比较常见,比如释放资源时调用虚析构函数,注册/注销事件之类。
COOP 的攻击条件是已知基址且能够伪造对象,例如 UAF 或者堆缓冲区溢出等漏洞,可以控制堆上分配的对象。我们需要伪造的东西有:
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 攻击的虚函数。
缓解措施
如果细粒度前向控制流完整性保护启用了的话,每次虚函数调用都要校验函数指针(而非只是表)的签名,那 COOP 应该就废了。不过这样激进的保护应该不太可能大面积应用。
……
参考资料
Bypassing Intel CET with Counterfeit Objects