一条从「对象首 8 字节」出发,一路走到 setcontext 的完整路径。
全文以 Itanium C++ ABI(GCC / Clang)为准,MSVC 差异单独标注;glibc 部分以 2.23 ~ 2.37 为时间轴。
C++ 要在只知道基类指针的前提下,调用到派生类的实现。这件事在编译期无法确定,只能推迟到运行期。虚表(vtable)就是这套延迟绑定的实现方案:
反过来说,这也决定了它的攻击面:表是只读的,但对象首字段是可写的。这个不对称,是近二十年虚表攻防的全部战场。
图 1 正常虚调用的两次解引用
关键点:整个过程没有分支、没有校验,是纯数据解引用。这正是它能被劫持、也正因此,加固思路只能是「约束跳转目标」而不能是「加检查指令」。
三个要素里,只有「表基址」和「表内容」是可攻击变量——偏移是编译期常量,谁也改不了。所以所有虚表劫持手法,最终都落在两个动作上:改 vptr 指向别的表,或者往可写内存里放一张伪造的表。
工程上的硬结论:给已发布的库里的类补一个 virtual(哪怕只是虚析构),等于改 ABI,所有调用方必须重编。所以「要不要虚函数」是设计期的一次性决定。
这是最容易搞混的一点。准确表述是:_vptr 是「住在对象里、却指向对象外面」的指针。
表 1 this / _vptr / offset-to-top 对照
很多人以为 vptr 指向表头,其实它指向表内的第一个虚函数槽,这个位置叫 address point(地址点):
而符号 _ZTV7Derived 是从 -0x10 那一格开始算的——所以 vptr 的值 = &_ZTV7Derived + 16。这解释了两个现象:
含虚基类时,地址点再往负方向(-0x18、-0x20 …)还会排上虚基类偏移。
如果每个对象都存一份自己的类型信息加函数指针:
现行设计的本质是:每个对象付 8 字节,换 O(1) 派发 + 一张受只读保护的共享表。代价就是这 8 字节成了唯一的攻击点。
C++ 没有「虚构造函数」这个概念。虚调用的前提是「对象已存在、首字段已有 vptr」,而构造函数运行的目的恰恰是建立这个对象。所以构造符只会出现在调用点的直接 call 或 PLT 里,永远不会占虚表槽位。
但编译期会在每个构造函数体前插一句:
由此得到两条常被搞混的语义:构造/析构期间调用虚函数,只会派发到「当前这一层子对象」的实现——基类构造体里调 virtual f(),走的是 Base::f,不是 Derived::f。
MSVC 侧对应:??_7Animal@@6B@(vftable)、??0Animal@@(构造)、??1Animal@@(D1)、??_GAnimal@@(标量删除析构 D0)。
两个实务提醒:
图 2 vptr 在生命周期中的四次改写
攻击侧推论:既然只有析构在表里,那么「虚构造劫持」这种手法在理论上就不存在——想劫持构造只能走 PLT/GOT 覆写或直接改代码。而虚析构槽(尤其 D0)是劫持最自然的落点,因为它自带一次释放动作。
虚函数是类的属性:类里有虚函数 ⇒ 该类的每一个对象都带 vptr。不存在「同一类的对象有的带有的不带」。栈上、全局、new 出来的、作为成员子对象嵌着的——四种布局逐字节相同。
顺带澄清一个高频误解:_vptr 不是「new 出来的对象的内部指针」。new Derived 展开成三步:
真正由 new 间接带来、且「在对象之外」的元数据是 malloc chunk header(用户指针之前 16 字节),不是 _vptr。这个区别在 UAF 场景下很关键:free 之后块前 16 字节会被 tcache 的 next / key 覆盖,_vptr 位置恰好被冲掉——所以想劫持虚表,通常得先把对象原样「占回来」。
图 3 三条变多态的路径
三个容易踩的点:
含虚基类、但一个虚函数都没有的类,GCC/Itanium 下也可能生成一张只记录虚基类偏移的 vtable、并在对象头放指针。但它没有虚派发、没有 RTTI,std::is_polymorphic 仍为 false。
多态的判据是「有没有虚函数」,不是「对象里有没有指针」。
虚函数只是「运行时多态」的一种实现手段,不是多态的必要条件。
三句话定调:按「类 × 子对象位」生成、编译期静态确定、链接后进程内每类只有一份。100 万个对象共享 1 张表。
设类 D 有 k 个多态基类:
以 class D : public A, public B(A、B 均多态)为例:
图 4 多继承下两个 vptr 与两段表
上图中 vptr② 落在 0x08,与次表的 offset-to-top = -0x08 互为印证:从 B 子对象的 this 加上 -0x08 正好回到整体对象 D 的首地址。
图 5 关键函数规则
这条规则的实际意义:它决定了你的某个 .cpp 是「表的主人」还是「表的借用者」,也决定了改一个虚函数定义会触发多少目标文件重编。
一个普通进程(含 libstdc++、libc 等)通常有成百上千张 _ZTV。但注意:别把 glibc 的 _IO_*_jumps 数进去——那是 C 手写的数据结构,只是与 C++ vtable 同构。见第八节。
glibc 是 C 写的,没有编译器帮你生成 _vptr,所以它在结构体里显式放了一个字段:
机制证据(glibc 2.31 源码)——每张 _IO_*_jumps 都是用 libio_vtable 宏声明的,这个宏把变量塞进名为 __libc_IO_vtables 的自定义段:
这就是「_IO_str_jumps 是合法目标」的机制级原因:不是因为它特殊,而是因为它和所有 _IO_*_jumps 一样住在这段里。
「指向哪张表」就是 FILE 的动态类型,而这一步在源码里是一次赋值——不是调用。fopen 的实现(libio/iofopen.c:72-76)就四行:
fopen 全程 0 次虚表派发:__open、_IO_mask_flags、_IO_link_in、_IO_SYSSEEK 全是直接调用/直接系统调用;表是留给「以后」的——源码注释写得很明白,_IO_file_jumps_maybe_mmap 的作用是「把 mmap 还是普通 IO 的决策延迟到首次 read」。真正第一次派发发生在 fread → __xsgetn、fwrite → __xsputn、fclose → _IO_OVERFLOW。
各构造入口登记的(即写入 +0xd8 的)表:
⚠️ 勘误(三处):
伪造 FILE 时选哪张表,就等于「把这个对象伪装成哪个类」。
__dummy / __dummy2 / __finish(0x10) / __overflow(0x18, FSOP 唯一弹点) / __underflow(0x20) / __uflow(0x28) / __pbackfail / __xsputn(0x38) / __xsgetn / __seekoff / __seekpos / __setbuf / __sync / __doallocate / __read / __write / __seek / __close(0x88) / __stat / __showmanyc / __imbue(0xa0)
C++ 侧的保护有两条腿:表在只读段 + vptr 是编译器管理的隐藏成员、难以精确命中。IO 侧只剩一条腿——表确实也进了 __libc_IO_vtables 只读段,但指针是个普通字段,偏移 0xd8 公开写死,任何能写到 _IO_FILE_plus 的原语都能直接改它。
所以 GNU 必须额外补一层 _IO_vtable_check:用链接器生成的段边界符号校验 vtable 是否落在本段内,把「任意劫持」压成「在 18 张合法表里挑一张、再挑一格」。
版本勘误(已核对源码):把这个改动记作 2.31 的说法流传很广,但对比 glibc 各 release 分支的 libio/strops.c 可以确认边界在 2.26——2.25 里是
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base),2.26 起变成 free (fp->_IO_buf_base);_IO_str_overflow 同理,从 (*fp->_s._allocate_buffer)(new_size) 改为 malloc (new_size)。
即:_IO_str_jumps 的经典打法只在 ≤ 2.25 有效。
更一般的加固代际:只读化(Full RELRO)→ 区间校验(_IO_vtable_check)→ 指针加密(PTR_MANGLE / vptr guard)→ 类型校验(CFI / VTV / CFG+XFG / kCFI)。
作者结论:GNU 一定会沿 2.37 思路继续收紧 IO,编译级 / 巧合级手法会陆续死掉,只剩数据结构层面的硬控制能活。
第 1 条常被误读成「只能拿既有函数拼链路」。准确说法是:_IO_vtable_check 校验的对象是表指针这个字段的落点(第一跳入口),不是表内容、也不是最终 RIP。把链路拆成四跳就很清楚:
所以「表」的角色是跳板库(给你一个合法的起步点),不是「攻击函数候选池」。第一跳的函数签名固定为 int f(FILE *, int),你只能决定"是哪张表的哪个槽",不能决定"这个函数干什么"——而这已经够用,因为参数(你的伪造对象)是你自己写的:
于是真正的门槛从「能不能造函数」变成三件事:
「任意构造攻击函数」也是能做到的,但那要走第 2 条的正面路线:改掉检查函数 / guard 变量,让表指针指向堆上自造伪表,此时第一跳也自由——代价是需要 ld 文件泄露。
图 6 FSOP 触发链
触发时机注意:abort 在 2.27 后不再刷新,__malloc_assert 在 2.36 后不再刷新,这两条老路已废。
setcontext+53 是老 pwn 的看家手段,靠它一次性给 rdi/rsi/rdx/rsp/rip 赋值再 mprotect + ORW。但 glibc 2.31 之后变成 setcontext+61,上下文指针从 rdi 换到了 rdx——而 IO_FILE 攻击链天然只能控制第一个参数 rdi,rdx 够不到。
house_of_KIWI / house_of_cat 的思路是「碰运气」,去挑那些编译器恰好用了 rdx 的代码(如 _IO_switch_to_wget_mode),属于编译级别的耦合,编译器换版本就废。
真正的破局点在 __setcontext 的汇编里:
这里没有一次 call [reg],没有虚表,没有任何间接跳转——纯数据结构解引用。所以只要能让 rdx 指向自己伪造的 ucontext,就等于拿到全套寄存器,且与编译器、与版本演进都无关。
伪造 ucontext_t 只需管 4 个字段:
完整利用范式:
图 7 house_of_一骑当千 完整攻击链
一句话概括这套攻击的核心:虚表劫持只是引信,setcontext 才是弹头。虚表负责把控制流从 IO 路径「借道」进 libc,setcontext 负责一次性接管寄存器并绕开所有编译器耦合。
__setcontext 是攻击函数(借来的、合法的、不用伪造);ucontext 是攻击参数(你伪造的、纯数据)。 而且它不是普通标量参数——rdx 里存的是它的地址,真正的寄存器值全在那块内存里,所以它是「间接参数」,本质是一份寄存器快照结构体。
对照 9.2 的结论会更清楚:_IO_FILE_plus 之于 IO 跳表项,与 ucontext 之于 __setcontext,是同一范式套了两层——"借来的既有函数,去读你写的结构体"。
图 8 代码与数据的两次借道
沙盒下要执行 ORW,第一步得 mprotect(heap, 0x20000, 7)——这要求 RIP、RDI、RSI、RDX 四个寄存器同时正确。ROP 得一个 gadget 一个 gadget 去凑,而 __setcontext 是 libc 里唯一「一次性给整套寄存器赋值」的原子操作。它天生只接受一份「寄存器花名册」作为输入,所以攻击的最终产物必然是数据结构,不可能是代码。这正是「编译级 / 巧合级手法会死,只剩数据结构层面的硬控制能活」的机制性原因。
GNU 明说"选 rdx 是任意的",理由是要把 RDI / RSI 腾出来留给自己后面恢复用——恰好白送攻击者一个完全可控的寄存器。而 IO 攻击链本来就握有对象指针(常以 rdi 传入),把对象某个字段摆成 ucontext 地址即可让 rdx 拿到它。
另外两个必须预埋的内存条件:fpregs(0xe0)是指针,fldenv (%rcx) 会写它指向的内存 ⇒ 必须指向可写内存(最省事就是指向自身的 __fpregs_mem,0x1a8);uc_sigmask(0x128)在寄存器恢复之前就被 rt_sigprocmask 读走,填 0 即可。
偏移核对方式:按上表结构用 ctypes 复算,与 glibc 自带的 sysdeps/unix/sysv/linux/x86_64/ucontext_i.sym 逐项一致(oSIGMASK 0x128、oFPREGS 0xe0、oMXCSR 0x1c0、oSSP 0x3a8)。
2.31 的 setcontext.S 里有整段 #if SHSTK_ENABLED 分支:会读 oSSP(%rdx)(0x3a8)与 oSSP+8,做 rdsspq / rstorssp / incsspq 的比对与展开;CET 构建下 RIP 也走 %r10 而非 %rcx。在开启 SHSTK 的发行版上,伪造 ucontext 还必须让影子栈状态自洽——这是 setcontext 这条终点路线在现代环境下的新门槛。
对象与表
flowchart LR
A["对象 obj<br/>偏移 0x00 = _vptr"] -->|"1. mov rax,[rdi]"| B["_ZTV7Derived<br/>.data.rel.ro 只读段"]
B -->|"2. call [rax+0x10]"| C["~Derived D1 / f()<br/>.text 可执行段"]
struct Plain { int x; }; // 非多态
struct Poly { virtual void f(); int x; }; // 多态
mov qword ptr [this], offset _ZTV7Derived ; 写入 vptr
flowchart TB
A["① 进入 Base 构造函数体前<br/>vptr ← _ZTV4Base"] --> B["② 进入 Derived 构造函数体前<br/>vptr ← _ZTV7Derived"]
B --> C["③ 进入 ~Derived 析构体<br/>vptr 保持 Derived 表"]
C --> D["④ 编译器插桩:进入 ~Base 前<br/>vptr 回退 ← _ZTV4Base"]
void* raw = operator new(sizeof(Derived)); // 裸内存,_vptr 是垃圾值
Derived* p = new (raw) Derived(); // 编译器生成的构造调用在此写 _vptr
// 返回 p
flowchart TB
T["class T { ... }"] --> D{"类里出现虚函数?"}
D -->|"显式声明"| P1["virtual void f();"]
D -->|"虚析构"| P2["virtual ~T();"]
D -->|"继承多态基类"| P3["struct D : Base {};"]
P1 --> R["T 成为多态类型<br/>is_polymorphic = true"]
P2 --> R
P3 --> R
flowchart LR
V1["对象 D · vptr①<br/>偏移 0x00"] -->|"指向"| S1["主表段 · 供 A 位使用<br/>offset-to-top = 0"]
V2["对象 D · vptr②<br/>偏移 0x08"] -->|"指向"| S2["次表段 · 供 B 位使用<br/>offset-to-top = -0x08"]
flowchart TB
A["类含虚函数 ⇒ 需要 vtable"] --> B{"有没有关键函数?<br/>第一个非内联、非纯虚的虚函数"}
B -->|"有"| C["只在关键函数定义所在 TU 发出<br/>只此一份,.data.rel.ro"]
B -->|"没有(全内联 / 纯虚)"| D["每个使用的 TU 各发一份<br/>weak 符号,待链接器合并"]
C --> E["链接器按 COMDAT 合并<br/>⇒ 进程内该类只有一份"]
D --> E
struct _IO_FILE_plus {
struct _IO_FILE file; /* 到 0xd8 为止 */
const struct _IO_jump_t *vtable; /* 偏移 0xd8,一个普通字段 */
};
/* libio/libioP.h */
#define libio_vtable __attribute__ ((section ("__libc_IO_vtables")))
symbol_set_declare (__libc_IO_vtables)
/* 每次 IO 调用都会先过这个函数 */
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
uintptr_t offset = (uintptr_t) vtable - (uintptr_t) __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
_IO_vtable_check (); /* 越界 → 正常路径下 __libc_fatal */
return vtable;
}
/* 调用点:先取表、再取槽 */
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
/* 其中 _IO_JUMPS_FUNC(THIS) 即 IO_validate_vtable(...) */
_IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps); /* 宽字符表 → _wide_data->_wide_vtable */
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps; /* ★ 主表 → fp+0xd8(_IO_JUMPS(THIS) 即 (THIS)->vtable) */
_IO_new_file_init_internal (&new_f->fp); /* 直接调用:_IO_link_in 挂入 _IO_list_all */
if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL) /* 直接调用具体实现(libc_hidden_ver),不查表 */
return __fopen_maybe_mmap (&new_f->fp.file);
/* libio/iofopncook.c:33-45 —— 表项是既有函数,但函数体会去对象里取函数指针 */
static ssize_t
_IO_cookie_read (FILE *fp, void *buf, ssize_t size)
{
struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
cookie_read_function_t *read_cb = cfile->__io_functions.read; /* ← 值来自伪造对象 */
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (read_cb); /* ← 启用指针加密时先解混淆 */
#endif
return read_cb (cfile->__cookie, buf, size); /* ← 任意地址调用 */
}
flowchart LR
A["exit() / main 返回"] --> B["_IO_flush_all_lockp<br/>从 _IO_list_all 出发"]
B --> C{"沿 fp->_chain 遍历<br/>条件:_mode ≤ 0 且 write_ptr 大于 write_base"}
C -->|"命中"| D["_IO_OVERFLOW(fp, EOF)<br/>经 vtable 取函数指针"]
D -->|"劫持后"| E["落到 __libc_IO_vtables 段内<br/>某张合法跳表的某个槽"]
ENTRY(__setcontext)
pushq %rdi # 先存下参数
leaq oSIGMASK(%rdi), %rsi
movl $SIG_SETMASK, %edi
movl $__NR_rt_sigprocmask, %eax
syscall # 信号掩码系统调用
popq %rdx # ★ rdi 变 rdx 的根源(GNU 有意为之)
...
/* 以下即 setcontext+61 */
movq oRSP(%rdx), %rsp # 全部是「(%rdx)+偏移」的直接内存读
movq oRBX(%rdx), %rbx
movq oRBP(%rdx), %rbp
movq oR12(%rdx), %r12 ... oR15
movq oRSI(%rdx), %rsi
movq oRDI(%rdx), %rdi
movq oRCX(%rdx), %rcx
movq oR8(%rdx), %r8 ... oR9
movq oRIP(%rdx), %r10 → push %r10; ret
movq oRDX(%rdx), %rdx
flowchart LR
A["largebin_attack / UAF<br/>伪造 _IO_FILE_plus"] --> B["改写 vtable 字段<br/>指向段内合法跳表"]
B --> C["触发 _IO_* 调用<br/>控制流进入 setcontext"]
C --> D["rdx 摆成堆上 ucontext 地址<br/>一次性接管全部寄存器"]
D --> E["RIP=mprotect, RDI=heap<br/>RSI=0x20000, RDX=7"]
E --> F["堆变 RWX<br/>跳 shellcode 执行 ORW"]
/* glibc sysdeps/unix/sysv/linux/x86/sys/ucontext.h(x86-64) */
typedef struct ucontext_t
{
unsigned long int uc_flags; /* +0x000 */
struct ucontext_t *uc_link; /* +0x008 */
stack_t uc_stack; /* +0x010 ← setcontext 不读 */
mcontext_t uc_mcontext; /* +0x028 ← 主战场 */
sigset_t uc_sigmask; /* +0x128 被 rt_sigprocmask 读 */
struct _libc_fpstate __fpregs_mem; /* +0x1a8 fldenv 的写入目标 */
unsigned long long int __ssp[4]; /* +0x3a8 CET 影子栈开启时才读 */
} ucontext_t; /* sizeof = 0x3c8 */