C++ 虚表全解剖:从对象内存布局到 glibc IO 攻击链路

· 2026-10-25 17:59 · 31 阅读

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 + x + padding)

调用方式

编译期直接 call,可内联

[vptr+偏移]

 间接跳转,难内联

RTTI

无(typeid 编译期定值)

有 _ZTI,支持 dynamic_cast

二进制安全

可 memcpy,可与 C struct 互通

属 UB,跨编译器/ABI 不保证

ABI 敏感度

加成员即变布局

加一个 virtual 就整体重排

工程上的硬结论:给已发布的库里的类补一个 virtual(哪怕只是虚析构),等于改 ABI,所有调用方必须重编。所以「要不要虚函数」是设计期的一次性决定。

三、_vptr 到底是什么

3.1 它不是「指向自己」的指针

这是最容易搞混的一点。准确表述是:_vptr 是「住在对象里、却指向对象外面」的指针。

  • 它的地址

    在对象偏移 0x00,数值上恰好等于 &obj;

  • 它存放的值

    是 vtable 的地址,位于 .data.rel.ro,全类所有对象完全相同;

  • 所以 *(void**)obj != obj。指针方向恒为 对象 → 表 → 实现,全部向外,没有自指。

东西

性质

存的值

是否指向自己

this

隐式形参(寄存器传递),不是成员

当前对象首地址

是

,唯一真正自指的

_vptr对象成员

,偏移 0x00

vtable 地址点

不是(指向类共享只读表)

offset-to-top

vtable 负索引里的一项

有符号偏移

(可为负)

不是指针,是「相对 this 的位移」

表 1 this / _vptr / offset-to-top 对照

3.2 地址点:vptr 指向表内的「中间位置」

很多人以为 vptr 指向表头,其实它指向表内的第一个虚函数槽,这个位置叫 address point(地址点):

相对地址点

内容

说明

-0x10offset-to-top

由基类子对象 this 回到整体对象首地址的位移,可为负

-0x08&_ZTI7Derived

RTTI 类型信息指针

+0x00~Derived

 (D1)

地址点,vptr 指向这里
+0x08~Derived

 (D0)

删除析构

+0x10Derived::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 类型信息

占 -0x08 格

_ZN6AnimalC1Ev

完整对象构造

✗

_ZN6AnimalC2Ev

基类对象构造

✗

_ZN6AnimalD1Ev完整对象析构

✓ 槽 N

_ZN6AnimalD0Ev删除析构

(内含 operator delete)

✓ 槽 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 张

(对象里也没有 vptr)

单继承 / 仅 1 个多态基类

1 张主表

多继承 k 个多态基类

k 张

(1 主表 + k-1 张次表)

含虚基类(构造期需切换)

另加构造虚表 _ZTC...(每个虚基类一张)+ 一张 VTT _ZTT...

抽象类(含纯虚)

仍 1 张,纯虚槽内填 __cxa_pure_virtual

虚函数全内联(无关键函数)

每个使用的 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 / 任意可控缓冲区(必须可写)

表项内容

真实函数地址 + &typeinfo

system

 / one_gadget / 栈迁移 gadget

偏移来源

编译期常量,硬编码进 call [rax+0x10]

改不了

——只能改表基址,不能改偏移

调用过程

两次纯数据解引用,无分支无校验

同样两次解引用,只是落点被换

合法性依据

只有「表页只读」一条

有写原语即失效

失败表现

—

表不可写 → 伪造表;地址未知 → 先泄露;目标受限 → 借合法表

7.2 三个必要条件

  • 有写原始语

    能改到对象首字段——UAF 后被复用、堆溢出、越界写、类型混淆;

  • 有可写落点放伪表

    原表只读,所以伪表必须放堆 / BSS;若开了 CFI,伪表内的目标函数还得通过类型检查;

  • 有触发点

    delete 虚析构、任意一次虚派发、dynamic_cast,或 glibc FSOP 里的 _IO_OVERFLOW(fp, EOF)。

7.3 手法分类

手法

要点

前置条件

经典 vptr 覆盖

首字段指向堆上伪表

任意堆写 + 一次虚调用

低字节部分覆盖

只改 vptr 低 2 字节,留在同一映射段

目标表与当前表同页/邻近(PIE 低 12 位固定)

类型混淆

按错误类型解释同一对象,取到另一张表

UAF + 对象复用

组合式劫持

不伪造表,借对象自身状态机跳到合法入口

无写原语,靠字段组合

受限劫持(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 为止 */
conststruct _IO_jump_t *vtable;   /* 偏移 0xd8,一个普通字段 */
};

对比项

C++ 虚表

glibc IO 跳转表

指针在哪

_vptr

,编译器隐藏成员,偏移 0

vtable

,显式结构体字段,偏移 0xd8

表本体

_ZTV*

,.data.rel.ro

_IO_*_jumps

,__libc_IO_vtables 段

表结构

offset-to-top / RTTI + 虚函数槽

__dummy

 / __dummy2 + 19 个槽,固定 21 格

槽位顺序

按虚函数声明顺序,可变

按 _IO_jump_t 定义,固定

析构对应物

虚析构 D1 / D0(两格)

__finish

(一格)

主派发入口

任意虚函数

__overflow

(FSOP 唯一的弹点)

类型信息

_ZTI

,供 dynamic_cast

无 RTTI,靠「指向哪张表」表达身份

一致性校验

CFI / VTV

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 调用都会先过这个函数 */
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_jumpslibio/iofopen.c:73
__fopen_maybe_mmap_IO_file_jumps_maybe_mmaplibio/iofopen.c:45
fopencookie_IO_cookie_jumpslibio/iofopncook.c:155
fmemopen

(≥ 2.22)

_IO_cookie_jumps

(内部经 _IO_fopencookie)

libio/fmemopen.c:214
open_memstream_IO_mem_jumpslibio/memstream.c:90
obstack_vprintf_IO_obstack_jumpslibio/obprintf.c:136
_IO_str_init_readonly_IO_str_jumpslibio/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 防护谱系

版本

GNU 动作

攻击侧对策

≤ 2.23

无检查

vtable 整体搬到堆上改

2.24

_IO_vtable_check

 越界即拒

改用段内合法跳表 _IO_str_jumps / _IO_wstr_jumps,借 __finish → _free_buffer 完成一次间接调用

2.26_IO_str_finish

 改为直接 free;_allocate_buffer / _free_buffer 字段改名 _allocate_buffer_unused(保留占位以维持 ABI)

_IO_str_jumps

 的 __finish 链报废

2.36 / 2.37

仍未给宽字符跳表加保护

house_of_apple

 / house_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 相关字段都在堆上

④

最终 RIP

自由

: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
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.gregs0x28

提供寄存器值(含 RIP / RSP)

fpregs

 指针

0xe0

会因 fldenv [rcx] 被写,必须指向可写内存

uc_sigmask0x128

填 0 即可

MXCSR0x1c0

填 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 */

对比项

攻击函数

攻击参数(ucontext)

本质

机器码

纯数据:14 个 64 位整数 + 指针 / 掩码

位置

libc .text(R-X)

堆(RW-,完全由攻击者掌控)

来源

既有导出符号 setcontext,无需伪造

本来就由攻击者伪造

约束

第一跳要落在 __libc_IO_vtables 段内

只要求地址能被 rdx 指到

作用

把控制流交给你

决定交出之后整套 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    %rdx

GNU 明说"选 rdx 是任意的",理由是要把 RDI / RSI 腾出来留给自己后面恢复用——恰好白送攻击者一个完全可控的寄存器。而 IO 攻击链本来就握有对象指针(常以 rdi 传入),把对象某个字段摆成 ucontext 地址即可让 rdx 拿到它。

三个反直觉点(都有源码依据)

#

事实

依据

1

uc_stack 完全不参与

——新栈顶来自 gregs[REG_RSP](0xa0),不是 uc_stack.ss_sp

setcontext.S

 全文没有任何 uc_stack 引用

2

不是 23 个槽全读

:RAX / R10 / R11 / EFL 不恢复,且 rax 在结尾被 xorl %eax,%eax 强制清零

恢复列表仅 RSP/RBX/RBP/R12~R15/RSI/RDI/RCX/R8/R9/RIP/RDX

3

新 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 核对)

槽

偏移

setcontext 动作

槽

偏移

setcontext 动作

R80x28

恢复

RBP0x78

恢复

R90x30

恢复

RBX0x80

恢复

R100x38不读RDX0x88

恢复

R110x40不读RAX0x90不读

,结尾清零

R120x48

恢复

RCX0x98

恢复

R130x50

恢复

RSP0xa0

恢复(新栈顶)

R140x58

恢复

RIP0xa8

恢复(新指令地址)

R150x60

恢复

EFL0xb0不读
RDI0x68

恢复(mprotect 第 1 参)

CSGSFS

 ~ CR2

0xb8

 ~ 0xd8

内核字段,用户态不读

RSI0x70

恢复

fpregs0xe0

取指针后 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:术语与符号对照表

概念

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

,偏移 0xd8

—

附录 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 规范与公开题解整理,用于防御性安全研究。文中涉及的具体利用手法请勿用于未授权场景。

*本文为看雪论坛优秀文章,由 inquisiter原创,转载请注明来自看雪社区

10月23日·上海环球港凯悦酒店7楼

图片

球分享

球点赞

球在看

点击阅读原文查看更多

阅读原文

跳转微信打开

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