一条从「对象首 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 分派三要素
.data.rel.ro | ||
三个要素里,只有「表基址」和「表内容」是可攻击变量——偏移是编译期常量,谁也改不了。所以所有虚表劫持手法,最终都落在两个动作上:改 vptr 指向别的表,或者往可写内存里放一张伪造的表。
2.2 有没有虚函数的对象布局对比
struct Plain { int x; }; // 非多态
struct Poly { virtual void f(); int x; }; // 多态Plain | Poly | |
|---|---|---|
int x | _vptr | |
sizeof | _vptr + x + padding) | |
call,可内联 | [vptr+偏移] | |
typeid 编译期定值) | _ZTI,支持 dynamic_cast | |
memcpy,可与 C struct 互通 | ||
加一个 virtual 就整体重排 |
工程上的硬结论:给已发布的库里的类补一个
virtual(哪怕只是虚析构),等于改 ABI,所有调用方必须重编。所以「要不要虚函数」是设计期的一次性决定。
三、_vptr 到底是什么
3.1 它不是「指向自己」的指针
这是最容易搞混的一点。准确表述是:_vptr 是「住在对象里、却指向对象外面」的指针。
- 它的地址
在对象偏移 0x00,数值上恰好等于&obj; - 它存放的值
是 vtable 的地址,位于 .data.rel.ro,全类所有对象完全相同; 所以 *(void**)obj != obj。指针方向恒为 对象 → 表 → 实现,全部向外,没有自指。
this | 是 | ||
_vptr | 对象成员0x00 | ||
offset-to-top | 有符号偏移 |
表 1 this / _vptr / offset-to-top 对照
3.2 地址点:vptr 指向表内的「中间位置」
很多人以为 vptr 指向表头,其实它指向表内的第一个虚函数槽,这个位置叫 address point(地址点):
-0x10 | offset-to-top | this 回到整体对象首地址的位移,可为负 |
-0x08 | &_ZTI7Derived | |
+0x00 | ~Derived | 地址点,vptr 指向这里 |
+0x08 | ~Derived | |
+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 里,永远不会占虚表槽位。
但编译期会在每个构造函数体前插一句:
mov qword ptr [this], offset _ZTV7Derived ; 写入 vptr由此得到两条常被搞混的语义:构造/析构期间调用虚函数,只会派发到「当前这一层子对象」的实现——基类构造体里调 virtual f(),走的是 Base::f,不是 Derived::f。
4.2 虚析构函数在表里,而且占两格
_ZTV6Animal | ||
_ZTI6Animal | -0x08 格 | |
_ZN6AnimalC1Ev | ||
_ZN6AnimalC2Ev | ||
_ZN6AnimalD1Ev | 完整对象析构 | |
_ZN6AnimalD0Ev | 删除析构operator delete) | |
_ZN6AnimalD2Ev |
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 = operator new(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 个多态基类:
0 张vptr) | |
| 1 张主表 | |
k 个多态基类 | k 张k-1 张次表) |
_ZTC...(每个虚基类一张)+ 一张 VTT _ZTT... | |
__cxa_pure_virtual | |
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 | ||
.data.rel.ro | ||
&typeinfo | system | |
call [rax+0x10] | 改不了 | |
7.2 三个必要条件
- 有写原始语
能改到对象首字段——UAF 后被复用、堆溢出、越界写、类型混淆; - 有可写落点放伪表
原表只读,所以伪表必须放堆 / BSS;若开了 CFI,伪表内的目标函数还得通过类型检查; - 有触发点
delete虚析构、任意一次虚派发、dynamic_cast,或 glibc FSOP 里的_IO_OVERFLOW(fp, EOF)。
7.3 手法分类
vptr 覆盖 | ||
vptr 低 2 字节,留在同一映射段 | ||
| 受限劫持(glibc IO) | __libc_IO_vtables 段内 | _IO_FILE_plus.vtable 的写原语 |
八、glibc IO 的「虚表」:C 手写的跳转表
8.1 它不是 C++ 虚表,但机制同构
glibc 是 C 写的,没有编译器帮你生成 _vptr,所以它在结构体里显式放了一个字段:
struct _IO_FILE_plus {
struct _IO_FILE file; /* 到 0xd8 为止 */
const struct _IO_jump_t *vtable; /* 偏移 0xd8,一个普通字段 */
};_vptr | vtable0xd8 | |
_ZTV*.data.rel.ro | _IO_*_jumps__libc_IO_vtables 段 | |
__dummy__dummy2 + 19 个槽,固定 21 格 | ||
_IO_jump_t 定义,固定 | ||
__finish | ||
__overflow | ||
_ZTIdynamic_cast | ||
IO_validate_vtable_IO_vtable_check 兜底 |
机制证据(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 调用都会先过这个函数 */
staticinline conststruct _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 | _IO_cookie_jumps_IO_fopencookie) | 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_wide_data->_wide_vtable 提升为主表) | 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 防护谱系
_IO_vtable_check | _IO_str_jumps / _IO_wstr_jumps,借 __finish → _free_buffer 完成一次间接调用 | |
| 2.26 | _IO_str_finishfree;_allocate_buffer / _free_buffer 字段改名 _allocate_buffer_unused(保留占位以维持 ABI) | _IO_str_jumps__finish 链报废 |
house_of_applehouse_of_cat |
版本勘误(已核对源码):把这个改动记作 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 | 受限__libc_IO_vtables 段内 | |
冻结_IO_cookie_read / _IO_obstack_overflow / _IO_str_finish…) | ||
call [对象字段] | 自由cookie->read、_s._free_buffer、_wide_data 相关字段都在堆上 | |
自由setcontext 路径甚至没有跳转,全是 movq oXXX(%rdx) 内存读 |
所以「表」的角色是跳板库(给你一个合法的起步点),不是「攻击函数候选池」。第一跳的函数签名固定为 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
return read_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 | |
fpregs | 0xe0 | fldenv [rcx] 被写,必须指向可写内存 |
uc_sigmask | 0x128 | |
MXCSR | 0x1c0 |
完整利用范式:
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) */
typedef struct ucontext_t
{
unsignedlongint 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 的写入目标 */
unsignedlonglongint __ssp[4]; /* +0x3a8 CET 影子栈开启时才读 */
} ucontext_t; /* sizeof = 0x3c8 */ucontext) | ||
|---|---|---|
.text(R-X) | RW-,完全由攻击者掌控) | |
setcontext,无需伪造 | ||
__libc_IO_vtables 段内 | rdx 指到 | |
对照 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 拿到它。
三个反直觉点(都有源码依据)
uc_stack 完全不参与gregs[REG_RSP](0xa0),不是 uc_stack.ss_sp | setcontext.Suc_stack 引用 | |
不是 23 个槽全读RAX / R10 / R11 / EFL 不恢复,且 rax 在结尾被 xorl %eax,%eax 强制清零 | ||
新 RSP 必须可写pushq %rcx 把新 RIP 压进「新栈」再 ret | pushq oRIP |
另外两个必须预埋的内存条件:fpregs(0xe0)是指针,fldenv (%rcx) 会写它指向的内存 ⇒ 必须指向可写内存(最省事就是指向自身的 __fpregs_mem,0x1a8);uc_sigmask(0x128)在寄存器恢复之前就被 rt_sigprocmask 读走,填 0 即可。
完整 gregs 偏移表(x86-64 glibc 2.31 核对)
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 | mprotect 第 1 参) | CSGSFSCR2 | 0xb80xd8 | |
RSI | 0x70 | fpregs | 0xe0 | fldenv 写入 |
偏移核对方式:按上表结构用 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:术语与符号对照表
_ZTV<Class> | ??_7Class@@6B@ | |
_ZTI<Class> | ??_R0 | |
_ZTS<Class> | ??_R0 | |
C1 | ??0Class@@ | |
C2 | ??0Class@@ | |
D1 | ??1Class@@ | |
D0 | ??_GClass@@ | |
D2 | ||
_ZTC... | ||
_ZTT... | ||
_vptr$Class | __vfptr | |
__cxa_pure_virtual | _purecall | |
struct _IO_jump_t | ||
_IO_FILE_plus.vtable0xd8 |
附录 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>
struct Base { virtual voidf() { std::puts("Base::f"); } virtual ~Base() {} };
struct Derived : Base { voidf() override { std::puts("Derived::f"); } ~Derived() override {} };
intmain() { Derived d; Base* p = &d; p->f(); return 0; }参考
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
https://bbs.kanxue.com/user-home-734587.htm

# 往期推荐
一台"锁死"的 Pixel 7 自救记:把 GhostLock 内核漏洞移植到真机 root
Unreal Engine 4引擎基础及其2024第九届游戏安全竞赛-初赛-安卓客户端安全为例
Android MetaSec 签名链逆向:从 TTNet 到 Unidbg Native 还原


球分享

球点赞

球在看

点击阅读原文查看更多
