C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路
inquisiter 2026-10-25 17:59 上海

看雪论坛作者ID:inquisiter
一条从「对象首 8 字节」出发,一路走到 setcontext 的完整路径。
全文以 Itanium C++ ABI(GCC / Clang)为准,MSVC 差异单独标注;glibc 部分以 2.23 ~ 2.37 为时间轴。
一、引子:虚表解决的是什么问题
C++ 要在只知道基类指针的前提下,调用到派生类的实现。这件事在编译期无法确定,只能推迟到运行期。
虚表(vtable)就是这套延迟绑定的实现方案:
每个多态类在只读数据段里有一张函数指针数组;
每个多态对象在内存开头藏一个指针,指向那张表;
调用虚函数时,编译器生成的是「取出指针 → 加固定偏移 → 间接调用」,偏移在编译期就算死。
反过来说,这也决定了它的攻击面:表是只读的,但对象首字段是可写的。这个不对称,是近二十年虚表攻防的全部战场。
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 可执行段"]图 1 正常虚调用的两次解引用
关键点:整个过程没有分支、没有校验,是纯数据解引用。这正是它能被劫持、也正因此,加固思路只能是「约束跳转目标」而不能是「加检查指令」。
二、正常运行机制:两次纯数据解引用
2.1 分派三要素
要素 | 内容 | 谁决定 |
|---|---|---|
vptr 由谁写 | 构造函数,按编译期算好的偏移写入 | 编译器插桩 |
vtable 放哪 | .data.rel.ro,链接后经 RELRO 变只读 | 链接器 |
表项内容 | 真实函数地址(或 thunk),偏移常量硬编码进指令 | 编译期 |
三个要素里,只有「表基址」和「表内容」是可攻击变量——偏移是编译期常量,谁也改不了。所以所有虚表劫持手法,最终都落在两个动作上:改 vptr 指向别的表,或者往可写内存里放一张伪造的表。
2.2 有没有虚函数的对象布局对比
struct Plain { int x; }; // 非多态
struct Poly { virtualvoidf(); int x; }; // 多态维度 | Plain(无虚函数) | Poly(有虚函数) |
|---|---|---|
偏移 0x00 | int x | _vptr(8 字节) |
sizeof(64 位) | 4 | 16( |
调用方式 | 编译期直接 | [vptr+偏移]间接跳转,难内联 |
RTTI | 无( | 有 |
二进制安全 | 可 | 属 UB,跨编译器/ABI 不保证 |
ABI 敏感度 | 加成员即变布局 | 加一个 virtual 就整体重排 |
工程上的硬结论:给已发布的库里的类补一个
virtual(哪怕只是虚析构),等于改 ABI,所有调用方必须重编。所以「要不要虚函数」是设计期的一次性决定。
三、_vptr 到底是什么
3.1 它不是「指向自己」的指针
这是最容易搞混的一点。准确表述是:_vptr 是「住在对象里、却指向对象外面」的指针。
- 它的地址
在对象偏移
0x00,数值上恰好等于&obj; - 它存放的值
是 vtable 的地址,位于
.data.rel.ro,全类所有对象完全相同; 所以
*(void**)obj != obj。指针方向恒为 对象 → 表 → 实现,全部向外,没有自指。
东西 | 性质 | 存的值 | 是否指向自己 |
|---|---|---|---|
this | 隐式形参(寄存器传递),不是成员 | 当前对象首地址 | 是 ,唯一真正自指的 |
_vptr | 对象成员 ,偏移 | vtable 地址点 | 不是(指向类共享只读表) |
offset-to-top | vtable 负索引里的一项 | 有符号偏移 (可为负) | 不是指针,是「相对 this 的位移」 |
表 1 this / _vptr / offset-to-top 对照
3.2 地址点:vptr 指向表内的「中间位置」
很多人以为 vptr 指向表头,其实它指向表内的第一个虚函数槽,这个位置叫 address point(地址点):
相对地址点 | 内容 | 说明 |
|---|---|---|
-0x10 | offset-to-top | 由基类子对象 |
-0x08 | &_ZTI7Derived | RTTI 类型信息指针 |
+0x00 | ~Derived(D1) | 地址点,vptr 指向这里 |
+0x08 | ~Derived(D0) | 删除析构 |
+0x10 | Derived::f() | 后续虚函数按声明顺序排列 |
而符号 _ZTV7Derived 是从 -0x10 那一格开始算的——所以 vptr 的值 = &_ZTV7Derived + 16。这解释了两个现象:
gdb 打印
_vptr$Derived = 0x... <vtable for Derived+16>,那个+16是相对_ZTV符号的偏移,不是对象偏移;vptr[-1]就是
&_ZTI,这是在调试器里判断对象真实类型最直接的技巧。
含虚基类时,地址点再往负方向(
-0x18、-0x20…)还会排上虚基类偏移。
3.3 为什么不做成「自指 / 自包含」
如果每个对象都存一份自己的类型信息加函数指针:
- 内存爆炸
一个虚函数 8 字节,十个就是 80 字节,乘以对象数量;
- 失去唯一那层保护
表跟着对象走,就必然落在可写段,「改不了」这个前提直接消失。
现行设计的本质是:每个对象付 8 字节,换 O(1) 派发 + 一张受只读保护的共享表。代价就是这 8 字节成了唯一的攻击点。
四、构造函数与析构函数在虚表里的位置
4.1 构造函数不在虚表里——它是 vptr 的写入者
C++ 没有「虚构造函数」这个概念。虚调用的前提是「对象已存在、首字段已有 vptr」,而构造函数运行的目的恰恰是建立这个对象。所以构造符只会出现在调用点的直接 call 或 PLT 里,永远不会占虚表槽位。
但编译期会在每个构造函数体前插一句:
movqwordptr [this], offset _ZTV7Derived ; 写入 vptr由此得到两条常被搞混的语义:构造/析构期间调用虚函数,只会派发到「当前这一层子对象」的实现——基类构造体里调 virtual f(),走的是 Base::f,不是 Derived::f。
4.2 虚析构函数在表里,而且占两格
符号(GCC / Itanium) | 含义 | 是否进虚表 |
|---|---|---|
_ZTV6Animal | 虚表本体 | — |
_ZTI6Animal | RTTI 类型信息 | 占 |
_ZN6AnimalC1Ev | 完整对象构造 | ✗ |
_ZN6AnimalC2Ev | 基类对象构造 | ✗ |
_ZN6AnimalD1Ev | 完整对象析构 | ✓ 槽 N |
_ZN6AnimalD0Ev | 删除析构 (内含 | ✓ 槽 N+1 |
_ZN6AnimalD2Ev | 基类对象析构(被 D1 直接调用) | ✗ |
MSVC 侧对应:??_7Animal@@6B@(vftable)、??0Animal@@(构造)、??1Animal@@(D1)、??_GAnimal@@(标量删除析构 D0)。
两个实务提醒:
一旦声明
virtual ~T(),它之后的虚函数槽位会整体后移 8 字节——IDA 里对不上偏移时先查这里;只有声明
virtual ~T(),delete base_ptr才能经表找到正确的 D0,跳过正确的析构层级并以正确的this调operator delete。
4.3 vptr 在对象生命周期里被改写 4 次
flowchart TB
A["① 进入 Base 构造函数体前<br/>vptr ← _ZTV4Base"] --> B["② 进入 Derived 构造函数体前<br/>vptr ← _ZTV7Derived"]
B --> C["③ 进入 ~Derived 析构体<br/>vptr 保持 Derived 表"]
C --> D["④ 编译器插桩:进入 ~Base 前<br/>vptr 回退 ← _ZTV4Base"]图 2 vptr 在生命周期中的四次改写
攻击侧推论:既然只有析构在表里,那么「虚构造劫持」这种手法在理论上就不存在——想劫持构造只能走 PLT/GOT 覆写或直接改代码。而虚析构槽(尤其 D0)是劫持最自然的落点,因为它自带一次释放动作。
五、对象可以没有虚函数
5.1 判据是「类」,不是「对象」
虚函数是类的属性:类里有虚函数 ⇒ 该类的每一个对象都带 vptr。不存在「同一类的对象有的带有的不带」。栈上、全局、new 出来的、作为成员子对象嵌着的——四种布局逐字节相同。
顺带澄清一个高频误解:_vptr 不是「new 出来的对象的内部指针」。new Derived 展开成三步:
void* raw = operatornew(sizeof(Derived)); // 裸内存,_vptr 是垃圾值
Derived* p = new (raw) Derived(); // 编译器生成的构造调用在此写 _vptr
// 返回 p真正由 new 间接带来、且「在对象之外」的元数据是 malloc chunk header(用户指针之前 16 字节),不是 _vptr。这个区别在 UAF 场景下很关键:free 之后块前 16 字节会被 tcache 的 next / key 覆盖,_vptr 位置恰好被冲掉——所以想劫持虚表,通常得先把对象原样「占回来」。
5.2 三条让类「被迫」变成多态的路径
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图 3 三条变多态的路径
三个容易踩的点:
- 虚析构最隐蔽
只想「
delete派生类不泄漏」,写了virtual ~T(),类立刻变多态,sizeof跟着变; - 派生类不需要再写
virtual继承链上有虚函数,整条链所有类的对象都带
vptr; - 多继承
只要任一基类多态,派生类就是多态,而且每个多态基类子对象各带一个
vptr。
5.3 特例:有 vptr 不等于多态
含虚基类、但一个虚函数都没有的类,GCC/Itanium 下也可能生成一张只记录虚基类偏移的 vtable、并在对象头放指针。但它没有虚派发、没有 RTTI,std::is_polymorphic 仍为 false。
多态的判据是「有没有虚函数」,不是「对象里有没有指针」。
5.4 多态也可以被主动关掉
obj.Base::f()显式限定 → 直接
call;final类 + 类型静态可知 → 去虚化(devirtualization);
派生类对象按值传给基类参数 → 对象切片;
-fno-rtti;模板 / CRTP 提供的是编译期多态,一个虚函数都不用。
虚函数只是「运行时多态」的一种实现手段,不是多态的必要条件。
六、到底有多少张虚表,创建规则是什么
6.1 数量规则
三句话定调:按「类 × 子对象位」生成、编译期静态确定、链接后进程内每类只有一份。100 万个对象共享 1 张表。
设类 D 有 k 个多态基类:
情形 | 该类的 vtable 数量 |
|---|---|
无虚函数、无虚基类 | 0 张 (对象里也没有 |
单继承 / 仅 1 个多态基类 | 1 张主表 |
多继承 | k 张(1 主表 + |
含虚基类(构造期需切换) | 另加构造虚表 |
抽象类(含纯虚) | 仍 1 张,纯虚槽内填 |
虚函数全内联(无关键函数) | 每个使用的 TU 各发一份 weak 符号,链接期合并 |
6.2 多继承:多出的是「表段」,GCC 下未必多出符号
以 class D : public A, public B(A、B 均多态)为例:
flowchartLR
V1["对象 D · vptr①<br/>偏移 0x00"] -->|"指向"|S1["主表段 · 供 A 位使用<br/>offset-to-top = 0"]
V2["对象 D · vptr②<br/>偏移 0x08"] -->|"指向"|S2["次表段 · 供 B 位使用<br/>offset-to-top = -0x08"]图 4 多继承下两个 vptr 与两段表
- GCC / Itanium
把同一类的所有表段打进同一个
_ZTV1D符号,每段各自带一份 offset-to-top + RTTI 头,各自有自己的地址点(次表槽里常放 thunk 而非真实函数地址,负责this调整); - MSVC
每个基类各出一个符号
??_7D@@6BA@@@/??_7D@@6BB@@@。
上图中
vptr②落在0x08,与次表的offset-to-top = -0x08互为印证:从 B 子对象的this加上-0x08正好回到整体对象 D 的首地址。
6.3 关键函数(key function)规则——表由谁发出
flowchart TB
A["类含虚函数 ⇒ 需要 vtable"] --> B{"有没有关键函数?<br/>第一个非内联、非纯虚的虚函数"}
B -->|"有"| C["只在关键函数定义所在 TU 发出<br/>只此一份,.data.rel.ro"]
B -->|"没有(全内联 / 纯虚)"| D["每个使用的 TU 各发一份<br/>weak 符号,待链接器合并"]
C --> E["链接器按 COMDAT 合并<br/>⇒ 进程内该类只有一份"]
D --> E图 5 关键函数规则
这条规则的实际意义:它决定了你的某个 .cpp 是「表的主人」还是「表的借用者」,也决定了改一个虚函数定义会触发多少目标文件重编。
6.4 表内容怎么填
按「基类声明顺序 → 每个基类内虚函数声明顺序」铺槽;
override是同槽替换,所以槽位在继承链上必须一致——新虚函数只能加在末尾,否则槽位全线错位(即 ABI 破裂);
协变返回、多继承
this调整由 thunk 承担;表本体位于
.data.rel.ro,重定位解析完成后整段变只读(RELRO)——这是「表基址改不了」的物理来源。
6.5 「到底有多少张」的量级
一个普通进程(含 libstdc++、libc 等)通常有成百上千张_ZTV。但注意:别把 glibc 的 _IO_*_jumps 数进去——那是 C 手写的数据结构,只是与 C++ vtable 同构。见第八节。
七、vtable 劫持:正常机制与攻击机制的对比
7.1 机制层对比
维度 | 正常运行 | 攻击利用 |
|---|---|---|
vptr由谁写 | 构造函数,按编译期偏移写入 | 攻击者用写原语覆盖对象首 8 字节 |
vtable 位置 | .data.rel.ro,链接后只读 | 堆 / BSS / 任意可控缓冲区(必须可写) |
表项内容 | 真实函数地址 + | system/ one_gadget / 栈迁移 gadget |
偏移来源 | 编译期常量,硬编码进 | 改不了 ——只能改表基址,不能改偏移 |
调用过程 | 两次纯数据解引用,无分支无校验 | 同样两次解引用,只是落点被换 |
合法性依据 | 只有「表页只读」一条 | 有写原语即失效 |
失败表现 | — | 表不可写 → 伪造表;地址未知 → 先泄露;目标受限 → 借合法表 |
7.2 三个必要条件
- 有写原始语
能改到对象首字段——UAF 后被复用、堆溢出、越界写、类型混淆;
- 有可写落点放伪表
原表只读,所以伪表必须放堆 / BSS;若开了 CFI,伪表内的目标函数还得通过类型检查;
- 有触发点
delete虚析构、任意一次虚派发、dynamic_cast,或 glibc FSOP 里的_IO_OVERFLOW(fp, EOF)。
7.3 手法分类
手法 | 要点 | 前置条件 |
|---|---|---|
经典 | 首字段指向堆上伪表 | 任意堆写 + 一次虚调用 |
低字节部分覆盖 | 只改 | 目标表与当前表同页/邻近(PIE 低 12 位固定) |
类型混淆 | 按错误类型解释同一对象,取到另一张表 | UAF + 对象复用 |
组合式劫持 | 不伪造表,借对象自身状态机跳到合法入口 | 无写原语,靠字段组合 |
| 受限劫持(glibc IO) | 表必须落在 | 一次能改 |
八、glibc IO 的「虚表」:C 手写的跳转表
8.1 它不是 C++ 虚表,但机制同构
glibc 是 C 写的,没有编译器帮你生成 _vptr,所以它在结构体里显式放了一个字段:
struct _IO_FILE_plus {
struct _IO_FILE file; /* 到 0xd8 为止 */
conststruct _IO_jump_t *vtable; /* 偏移 0xd8,一个普通字段 */
};对比项 | C++ 虚表 | glibc IO 跳转表 |
|---|---|---|
指针在哪 | _vptr,编译器隐藏成员,偏移 0 | vtable,显式结构体字段,偏移 |
表本体 | _ZTV*, | _IO_*_jumps, |
表结构 | offset-to-top / RTTI + 虚函数槽 | __dummy / |
槽位顺序 | 按虚函数声明顺序,可变 | 按 |
析构对应物 | 虚析构 D1 / D0(两格) | __finish(一格) |
主派发入口 | 任意虚函数 | __overflow(FSOP 唯一的弹点) |
类型信息 | _ZTI,供 | 无 RTTI,靠「指向哪张表」表达身份 |
一致性校验 | CFI / VTV | IO_validate_vtable 区间判断 + |
机制证据(glibc 2.31 源码)——每张 _IO_*_jumps 都是用 libio_vtable 宏声明的,这个宏把变量塞进名为 __libc_IO_vtables 的自定义段:
/* libio/libioP.h */
#define libio_vtable __attribute__ ((section ("__libc_IO_vtables")))
symbol_set_declare (__libc_IO_vtables)
/* 每次 IO 调用都会先过这个函数 */
staticinlineconststruct_IO_jump_t *
IO_validate_vtable (conststruct _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_str_jumps 是合法目标」的机制级原因:不是因为它特殊,而是因为它和所有 _IO_*_jumps 一样住在这段里。
「指向哪张表」就是 FILE 的动态类型,而这一步在源码里是一次赋值——不是调用。fopen 的实现(libio/iofopen.c:72-76)就四行:
_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);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 的)表:
构造入口 | 写入的表 | 源码位置 |
|---|---|---|
fopen | _IO_file_jumps | libio/iofopen.c:73 |
__fopen_maybe_mmap | _IO_file_jumps_maybe_mmap | libio/iofopen.c:45 |
fopencookie | _IO_cookie_jumps | libio/iofopncook.c:155 |
fmemopen(≥ 2.22) | _IO_cookie_jumps(内部经 | libio/fmemopen.c:214 |
open_memstream | _IO_mem_jumps | libio/memstream.c:90 |
obstack_vprintf | _IO_obstack_jumps | libio/obprintf.c:136 |
_IO_str_init_readonly | _IO_str_jumps | libio/strfile.h:94 |
fwide | _IO_wfile_jumps(把 | libio/iofwide.c:99 |
⚠️ 勘误(三处):
本节早期版本写「
fmemopen给_IO_str_jumps」——在 2.31 源码里不成立。fmemopen(__fmemopen,GLIBC_2_22)与兼容符号__old_fmemopen(GLIBC_2_2,libio/oldfmemopen.c:262)都走_IO_fopencookie→_IO_cookie_jumps。_IO_str_jumps在 2.31 的唯一写入点是strfile.h:94的_IO_str_init_readonly()(只读字符串流)。fwide的动作是复制/提升而非"指向新表":
_IO_JUMPS_FILE_plus (fp) = fp->_wide_data->_wide_vtable;—— 把构造时(_IO_no_init第 5 参,genops.c:581)就已存进_wide_data的宽表提升为+0xd8处的主表。所以同一个字段一生被写两次:构造时窄表,fwide之后宽表。这正是house_of_apple/house_of_cat借宽字符路径的地基。一个利用相关的细节:
_IO_obstack_jumps的 19 个槽里除__overflow/__xsputn全是 NULL(libio/obprintf.c:94-114,连__finish都是 NULL)——借表打 finish 链时它用不了,只有_IO_str_jumps一类才有可用的__finish槽。选表不只是"过校验",还要看目标槽是否非 NULL。
伪造 FILE 时选哪张表,就等于「把这个对象伪装成哪个类」。
8.2 _IO_jump_t 的 21 个槽
__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)
8.3 为什么这层差别在安全上是决定性的
C++ 侧的保护有两条腿:表在只读段 + vptr 是编译器管理的隐藏成员、难以精确命中。IO 侧只剩一条腿——表确实也进了 __libc_IO_vtables 只读段,但指针是个普通字段,偏移 0xd8 公开写死,任何能写到 _IO_FILE_plus 的原语都能直接改它。
所以 GNU 必须额外补一层 _IO_vtable_check:用链接器生成的段边界符号校验 vtable 是否落在本段内,把「任意劫持」压成「在 18 张合法表里挑一张、再挑一格」。
九、防护与绕过的军备竞赛
9.1 防护谱系
版本 | GNU 动作 | 攻击侧对策 |
|---|---|---|
≤ 2.23 | 无检查 | vtable 整体搬到堆上改 |
2.24 | _IO_vtable_check越界即拒 | 改用段内合法跳表 |
| 2.26 | _IO_str_finish 改为直接 | _IO_str_jumps 的 |
2.36 / 2.37 | 仍未给宽字符跳表加保护 | house_of_apple / |
版本勘误(已核对源码):把这个改动记作 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)。
9.2 三个可复用的「抓手」
- 合法区间只有
0xd60——
_IO_helper_jumps→_IO_str_jumps之间共 18 张跳表。所以 vtable 劫持从「任意写」退化成「在合法跳表里挑一张、再挑其中一个 entry」;_IO_obstack_jumps、_IO_str_jumps、_IO_help_jump_0就是这么被反复借用的。 - 可以正面打检查函数本身
——
IO_accept_foreign_vtables基本恒为 0,泄露ptr_guard反算后改掉,或直接把 guard 改成&_IO_vtable_check,就能让检查函数「自己放行自己」。代价是需要 ld 文件。 - 宽字符跳表是保护缺口的量化证据
——2.36 下 19 个
_IO_W*宏里,真正有引用的只有 4 个:_IO_WOVERFLOW、_IO_WUFLOW、_IO_WSETBUF(仅setbuf用)、_IO_WDOALLOCATE。
作者结论:GNU 一定会沿 2.37 思路继续收紧 IO,编译级 / 巧合级手法会陆续死掉,只剩数据结构层面的硬控制能活。
9.2.1 「挑 entry」限制的是入口,不是终点
第 1 条常被误读成「只能拿既有函数拼链路」。准确说法是:_IO_vtable_check 校验的对象是表指针这个字段的落点(第一跳入口),不是表内容、也不是最终 RIP。
把链路拆成四跳就很清楚:
跳 | 动作 | 是否受约束 |
|---|---|---|
① | fp->vtable字段的落点 | 受限 :值必须落在 |
② | 表项 = 函数地址 | 冻结 :段只读,内容是既有函数( |
③ | 既有函数体内的间接调用 | 自由 : |
④ | 最终 RIP | 自由 : |
所以「表」的角色是跳板库(给你一个合法的起步点),不是「攻击函数候选池」。第一跳的函数签名固定为 int f(FILE *, int),你只能决定"是哪张表的哪个槽",不能决定"这个函数干什么"——而这已经够用,因为参数(你的伪造对象)是你自己写的:
/* libio/iofopncook.c:33-45 —— 表项是既有函数,但函数体会去对象里取函数指针 */
staticssize_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
returnread_cb (cfile->__cookie, buf, size); /* ← 任意地址调用 */
}于是真正的门槛从「能不能造函数」变成三件事:
- 挑得到合适的槽
——不仅要"不越界",还要非 NULL:
_IO_obstack_jumps的 19 槽里只有__overflow/__xsputn非空(libio/obprintf.c:94-114),连__finish都是 NULL; - 摆得动对象字段
——对象在堆上、字段随你写,但必须能被那个既有函数按固定偏移取到(结构要匹配:
_IO_cookie_file.__io_functions、_IO_strfile._s._free_buffer); - 注意字段级加密
——
cookie->read这类字段被包在#ifdef PTR_DEMANGLE里,启用 pointer guard 的构建上必须先泄露 guard 再解出可用地址。
「任意构造攻击函数」也是能做到的,但那要走第 2 条的正面路线:改掉检查函数 / guard 变量,让表指针指向堆上自造伪表,此时第一跳也自由——代价是需要 ld 文件泄露。
9.3 FSOP:把「数据」变成「控制流」的开关
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/>某张合法跳表的某个槽"]图 6 FSOP 触发链
触发时机注意:abort 在 2.27 后不再刷新,__malloc_assert 在 2.36 后不再刷新,这两条老路已废。
9.4 终点:借虚表把控制流送进 setcontext
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 的汇编里:
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这里没有一次 call [reg],没有虚表,没有任何间接跳转——纯数据结构解引用。所以只要能让 rdx 指向自己伪造的 ucontext,就等于拿到全套寄存器,且与编译器、与版本演进都无关。
伪造 ucontext_t 只需管 4 个字段:
字段 | 偏移 | 注意 |
|---|---|---|
uc_mcontext.gregs | 0x28 | 提供寄存器值(含 RIP / RSP) |
fpregs指针 | 0xe0 | 会因 |
uc_sigmask | 0x128 | 填 0 即可 |
MXCSR | 0x1c0 | 填 0 即可 |
完整利用范式:
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"]图 7 house_of_一骑当千 完整攻击链
一句话概括这套攻击的核心:虚表劫持只是引信,setcontext 才是弹头。虚表负责把控制流从 IO 路径「借道」进 libc,setcontext 负责一次性接管寄存器并绕开所有编译器耦合。
9.5 ucontext 是什么:它不是攻击函数,是「第二级参数载体」
__setcontext 是攻击函数(借来的、合法的、不用伪造);ucontext 是攻击参数(你伪造的、纯数据)。 而且它不是普通标量参数——rdx 里存的是它的地址,真正的寄存器值全在那块内存里,所以它是「间接参数」,本质是一份寄存器快照结构体。
/* glibc sysdeps/unix/sysv/linux/x86/sys/ucontext.h(x86-64) */
typedefstructucontext_t
{
unsignedlongint uc_flags; /* +0x000 */
structucontext_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 的写入目标 */
unsignedlonglongint __ssp[4]; /* +0x3a8 CET 影子栈开启时才读 */
} ucontext_t; /* sizeof = 0x3c8 */对比项 | 攻击函数 | 攻击参数( |
|---|---|---|
本质 | 机器码 | 纯数据:14 个 64 位整数 + 指针 / 掩码 |
位置 | libc | 堆( |
来源 | 既有导出符号 | 本来就由攻击者伪造 |
约束 | 第一跳要落在 | 只要求地址能被 |
作用 | 把控制流交给你 | 决定交出之后整套 CPU 状态 |
对照 9.2 的结论会更清楚:_IO_FILE_plus 之于 IO 跳表项,与 ucontext 之于 __setcontext,是同一范式套了两层——"借来的既有函数,去读你写的结构体"。
flowchart LR
A["伪造 _IO_FILE_plus<br/>堆 · RW-"] -->|"表项读对象字段"| B["IO 跳表项<br/>libc · R-X"]
B -->|"控制流借道"| C["__setcontext<br/>libc · R-X"]
D["伪造 ucontext<br/>堆 · RW-"] -->|"rdx 传址"| C
C -->|"14 槽 + RAX 清零"| E["新 CPU 状态<br/>RIP / RSP / RDI / RSI / RDX"]图 8 代码与数据的两次借道
为什么必须用「参数」而不能直接构造「代码」
沙盒下要执行 ORW,第一步得 mprotect(heap, 0x20000, 7)——这要求 RIP、RDI、RSI、RDX 四个寄存器同时正确。ROP 得一个 gadget 一个 gadget 去凑,而 __setcontext 是 libc 里唯一「一次性给整套寄存器赋值」的原子操作。它天生只接受一份「寄存器花名册」作为输入,所以攻击的最终产物必然是数据结构,不可能是代码。这正是「编译级 / 巧合级手法会死,只剩数据结构层面的硬控制能活」的机制性原因。
rdx 从哪来:源码注释说明这是「有意为之」
/* Pop the pointer into RDX. The choice is arbitrary, but
leaving RDI and RSI available for use later can avoid
shuffling values. */
popq %rdxGNU 明说"选 rdx 是任意的",理由是要把 RDI / RSI 腾出来留给自己后面恢复用——恰好白送攻击者一个完全可控的寄存器。而 IO 攻击链本来就握有对象指针(常以 rdi 传入),把对象某个字段摆成 ucontext 地址即可让 rdx 拿到它。
三个反直觉点(都有源码依据)
# | 事实 | 依据 |
|---|---|---|
1 | uc_stack 完全不参与——新栈顶来自 | setcontext.S 全文没有任何 |
2 | 不是 23 个槽全读 : | 恢复列表仅 RSP/RBX/RBP/R12~R15/RSI/RDI/RCX/R8/R9/RIP/RDX |
3 | 新 RSP 必须可写:源码用 | pushq oRIP写的是切换后的栈 |
另外两个必须预埋的内存条件:fpregs(0xe0)是指针,fldenv (%rcx) 会写它指向的内存 ⇒ 必须指向可写内存(最省事就是指向自身的 __fpregs_mem,0x1a8);uc_sigmask(0x128)在寄存器恢复之前就被 rt_sigprocmask 读走,填 0 即可。
完整 gregs 偏移表(x86-64 glibc 2.31 核对)
槽 | 偏移 | setcontext 动作 | 槽 | 偏移 | setcontext 动作 |
|---|---|---|---|---|---|
R8 | 0x28 | 恢复 | RBP | 0x78 | 恢复 |
R9 | 0x30 | 恢复 | RBX | 0x80 | 恢复 |
R10 | 0x38 | 不读 | RDX | 0x88 | 恢复 |
R11 | 0x40 | 不读 | RAX | 0x90 | 不读 ,结尾清零 |
R12 | 0x48 | 恢复 | RCX | 0x98 | 恢复 |
R13 | 0x50 | 恢复 | RSP | 0xa0 | 恢复(新栈顶) |
R14 | 0x58 | 恢复 | RIP | 0xa8 | 恢复(新指令地址) |
R15 | 0x60 | 恢复 | EFL | 0xb0 | 不读 |
RDI | 0x68 | 恢复( | CSGSFS ~ | 0xb8 ~ | 内核字段,用户态不读 |
RSI | 0x70 | 恢复 | fpregs | 0xe0 | 取指针后 |
偏移核对方式:按上表结构用 ctypes 复算,与 glibc 自带的 sysdeps/unix/sysv/linux/x86_64/ucontext_i.sym 逐项一致(oSIGMASK 0x128、oFPREGS 0xe0、oMXCSR 0x1c0、oSSP 0x3a8)。
现代变数:CET 影子栈
2.31 的 setcontext.S 里有整段 #if SHSTK_ENABLED 分支:会读 oSSP(%rdx)(0x3a8)与 oSSP+8,做 rdsspq / rstorssp / incsspq 的比对与展开;CET 构建下 RIP 也走 %r10 而非 %rcx。在开启 SHSTK 的发行版上,伪造 ucontext 还必须让影子栈状态自洽——这是 setcontext 这条终点路线在现代环境下的新门槛。
十、速查清单
对象与表
判据是「类有没有虚函数」,不是「对象怎么来的」;
_vptr在偏移
0x00,它的地址 =&obj,但它的值 ≠&obj;vptr指向表内地址点,故 gdb 显示
vtable for T+16;vptr[-1]=
&_ZTI(RTTI),vptr[-2]=offset-to-top;同类所有对象的
vptr值完全相同 → 泄露一次 ≈ 全类失守。
构造 / 析构
构造函数不在虚表里,它是
vptr的写入者;虚析构在表里且占两格(D1 / D0),声明后后续槽位后移 8 字节;
生命周期中
vptr被改写 4 次,构造/析构期虚调用只派发到当前层;没有「虚构造劫持」这种手法。
数量与生成
无虚函数 ⇒ 0 张表、无
vptr;多继承k个多态基类 ⇒k段表;GCC 把多段表打包进一个
_ZTV符号,MSVC 每基类一个符号;关键函数规则决定表在哪个 TU 发出;全内联/纯虚类每 TU 各发一份,链接期 COMDAT 合并;
表在
.data.rel.ro,RELRO 后只读;偏移是编译期常量,只能改表基址与表内容。
利用与加固
三个必要条件:可写
vptr的原语 + 可写落点放伪表 + 一个触发点;glibc IO 的
_IO_FILE_plus.vtable(0xd8)是普通字段,被_IO_vtable_check限定在__libc_IO_vtables段内(18 张表 /0xd60);FSOP 唯一弹点是
__overflow,触发于exit/main返回;绕过终点是转打数据面:
setcontext+61的movq oXXX(%rdx), %reg全是直接内存读,不走间接跳转,因此不受 CFI 约束。
附录 A:术语与符号对照表
概念 | Itanium(GCC/Clang) | MSVC |
|---|---|---|
虚表符号 | _ZTV<Class> | ??_7Class@@6B@(每基类一个) |
RTTI | _ZTI<Class> | ??_R0系列 |
类型名 | _ZTS<Class> | ??_R0内的字符串 |
完整对象构造 | C1 | ??0Class@@ |
基类对象构造 | C2 | ??0Class@@变体 |
完整对象析构 | D1 | ??1Class@@ |
删除析构 | D0 | ??_GClass@@(标量删除析构) |
基类对象析构 | D2 | — |
构造虚表 | _ZTC... | — |
VTT | _ZTT... | — |
对象内指针名 | _vptr$Class(DWARF) | __vfptr(PDB) |
纯虚槽填充 | __cxa_pure_virtual | _purecall |
glibc IO 跳转表 | struct _IO_jump_t,21 槽 | — |
glibc IO 表字段 | _IO_FILE_plus.vtable,偏移 | — |
附录 B:可执行验证命令
# 数一数某个动态库里的虚表符号
nm -C --defined-only libstdc++.so.6 | grep -c ' _ZTV'
readelf -sW your_binary | grep -E '_ZTV|_ZTC|_ZTT'
# 逐类列出 vtable 布局(含 offset-to-top / RTTI / 槽位)
g++ -fdump-lang-class a.cpp
clang++ -Xclang -fdump-vtable-layouts -fsyntax-only a.cpp
# 看某个对象的 vptr 与它指向的表
gdb -q ./a.out
(gdb) p *obj # 会打印 _vptr$T = 0x... <vtable for T+16>
(gdb) p ((void**)(*(void**)obj))[-1] # vptr[-1] → &_ZTI,判断真实类型
(gdb) x/8gx *(void**)obj # 直接 dump 地址点前后几格
# 把虚表所在的段确认为只读
readelf -SW a.out | grep -E 'data.rel.ro|relro'最小验证样例(用于观察 _ZTV 的负索引格与虚析构两格):
#include<cstdio>
structBase { virtualvoidf(){ std::puts("Base::f"); } virtual ~Base() {} };
structDerived : Base { voidf()override{ std::puts("Derived::f"); } ~Derived() override {} };
intmain(){ Derived d; Base* p = &d; p->f(); return0; }参考
Itanium C++ ABI, §5.2.3 Virtual Table Layout
glibc
libio/libioP.h(_IO_jump_t定义)、libio/vtables.c(_IO_vtable_check)glibc
sysdeps/unix/sysv/linux/x86_64/setcontext.S看雪 Pwn 系列《无路远征——GLIBC 2.37 后时代的 IO 攻击之道》(零 / 一 / 二 / 三 / 四 / 五)
2022 强网拟态决赛
_vpn(pminote_mc)题解
本文所有结论均基于 ABI 规范与公开题解整理,用于防御性安全研究。文中涉及的具体利用手法请勿用于未授权场景。
看雪ID:inquisiter
*本文为看雪论坛优秀文章,由 inquisiter原创,转载请注明来自看雪社区


球分享

球点赞

球在看

点击阅读原文查看更多
本文转载自微信公众号「看雪学院」,查看原文。版权归原作者所有,如有侵权请联系删除。

