来自近期做题的灵感,这篇文章想解决一个很具体的问题:在 glibc 2.34 以后,老一代 __malloc_hook、__free_hook 基本退出利用舞台,为什么很多人把注意力转回了 libio,而且集中盯上 _IO_wfile_jumps 这条路径
先说结论。现在主 vtable 的检查已经很严格,直接伪造主跳表通常会被拦住;但宽字符侧链里的 _wide_vtable 在关键路径上还没有做同级校验。于是就会出现一种很典型的组合:入口看起来完全合法,真正的控制流变化发生在后面的侧链分发。
如果你之前做过堆题,应该对 __malloc_hook、__free_hook 不陌生。以前拿到任意写,改这些位置触发起来很直接。变化点在 glibc 2.34,官方在 NEWS 里明确写了这些分配器 hook 已经从 API 语义上移除。兼容符号可能还在,但新程序已经不能再靠它们影响核心分配流程。于是利用思路自然往“程序一定会走到的运行时路径”迁移,stdio 清理链路就是其中之一
exit() 这条线在源码里很清楚:exit -> __run_exit_handlers -> _IO_cleanup,再到 libio/genops.c 的 _IO_flush_all。这里的价值不在于花哨,而在于稳定。正常 main 返回和显式 exit() 都会经过它。反过来,_exit()、abort()、致命信号终止不保证走这条链,所以很多复现失败不是链子错了,而是触发条件没选对
关键分水岭在 vtable 检查。libioP.h 里的 JUMP1 会走 IO_validate_vtable,而 IO_validate_vtable 要求主 vtable 必须在 __io_vtables 这块合法区域里,不然就会进 _IO_vtable_check,最后直接报 fatal。简单说,主跳表这条路现在卡得很死
_IO_wfile_jumps 的作用就在这里:它本来就是 libc 自带、合法的跳表,所以能通过主校验。以 Ubuntu 24.04 的 glibc 2.39 为例,readelf 可以看到 _IO_wfile_jumps 在 .data.rel.ro,运行时受 GNU_RELRO 保护;而 _IO_2_1_stderr_ 在 .data,依然可写。也就是“主表难改,状态和侧链更可能被动手脚”。
真正有意思的点是,_IO_flush_all 在处理宽字符流时,只要满足 fp->_mode > 0 且 fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base,就会调用 _IO_OVERFLOW(fp, EOF)。如果主表是 _IO_wfile_jumps,落点就是 _IO_wfile_overflow。而 _IO_wfile_overflow 发现 wide_data->_IO_write_base == NULL 时,会去 _IO_wdoallocbuf,再进一步走 _IO_WDOALLOCATE(fp)。
问题就出在 _IO_WDOALLOCATE 这一跳。它是 WJUMP0(__doallocate, fp),解引用的是 fp->_wide_data->_wide_vtable。这条宽字符侧链在当前实现里没有像主 vtable 那样经过 IO_validate_vtable。所以我们会看到一种结构:主路径完全合法,但侧链指针如果被改写,最终调用目标就会被带偏。
只看文字会有点抽象,下面把结构关系画开。
(主路径,受校验)
+-------------------------------------------------+
| struct _IO_FILE_plus |
| +0x0a0 _wide_data -------------------------+ |
| +0x0c0 _mode | |
| +0x0d8 vtable ----------------------------+ | |
+--------------------------------------------|--|--+
| |
v |
__io_vtables 中的合法表
_IO_wfile_jumps
|
v
_IO_wfile_overflow
|
v
(侧路径,当前实现里不做同级校验)
+--------------------------------------------|-----+
| struct _IO_wide_data | |
| +0x018 _IO_write_base | |
| +0x020 _IO_write_ptr | |
| +0x0e0 _wide_vtable ----------------------+-----+
+--------------------------------------------------+
|
v
wide jump table 的 __doallocate
触发流程可以再压成一条时间线:
exit()
-> __run_exit_handlers
-> _IO_cleanup
-> _IO_flush_all
-> if (fp->_mode > 0 &&
fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)
_IO_OVERFLOW(fp, EOF)
-> _IO_wfile_overflow
-> if (wide_write_base == NULL) _IO_wdoallocbuf
-> _IO_WDOALLOCATE(fp)
-> fp->_wide_data->_wide_vtable->__doallocate(fp)
在 x86_64 的 glibc 2.39 上,我本地测到的偏移是:struct _IO_FILE 大小 0xd8,_wide_data 在 0xa0,_mode 在 0xc0;_IO_FILE_plus 主 vtable 在 0xd8。struct _IO_wide_data 里,_IO_write_base 在 0x18,_IO_write_ptr 在 0x20,_wide_vtable 在 0xe0。struct _IO_jump_t 的 __doallocate 槽位是 0x68。这组数据和常见的“三段布局”能够对上。
这里也顺便强调一个常被忽略的细节:在 _IO_flush_all 这个触发点,宽字符分支要求的是“严格大于”,所以 _IO_write_ptr 不能和 _IO_write_base 相等。很多人把这两个都置零,结果路径根本进不去。
为了让理解更直观,我放了一个无害演示程序,只模拟控制流,不执行系统命令。它演示的是:主 vtable 不动,改侧链 _wide_vtable 后,调用就会落到另一个 doallocate。
#include <stdio.h>
typedef struct fake_file fake_file;
typedef int (*overflow_fn)(fake_file *fp, int ch);
typedef int (*doallocate_fn)(fake_file *fp);
typedef struct { overflow_fn overflow; } fake_jump_t;
typedef struct { doallocate_fn doallocate; } fake_wide_jump_t;
typedef struct {
char *write_base;
char *write_ptr;
fake_wide_jump_t *wide_vtable;
} fake_wide_data;
struct fake_file {
char label[32];
int mode;
fake_jump_t *vtable;
fake_wide_data *wide_data;
};
static int safe_doallocate(fake_file *fp) {
printf("[normal] doallocate on %s\n", fp->label);
return 0;
}
static int demo_hijack_sink(fake_file *fp) {
printf("[demo] control reaches wide_vtable->doallocate on %s\n", fp->label);
printf("[demo] this is where a real attack would try to abuse control flow\n");
return 0;
}
static int wdoallocbuf(fake_file *fp) {
return fp->wide_data->wide_vtable->doallocate(fp);
}
static int wfile_overflow(fake_file *fp, int ch) {
(void)ch;
if (fp->mode <= 0)
return 0;
if (fp->wide_data->write_base == NULL) {
puts("[path] write_base is NULL -> call wdoallocbuf");
return wdoallocbuf(fp);
}
return 0;
}
static int flush_one(fake_file *fp) {
if (fp->mode > 0 && fp->wide_data->write_ptr > fp->wide_data->write_base) {
puts("[flush] wide condition satisfied -> call overflow");
return fp->vtable->overflow(fp, -1);
}
return 0;
}
int main(void) {
fake_jump_t legal_wfile_jumps = { .overflow = wfile_overflow };
fake_wide_jump_t normal_wide_vtable = { .doallocate = safe_doallocate };
fake_wide_jump_t tampered_wide_vtable = { .doallocate = demo_hijack_sink };
fake_wide_data wd = { .write_base = NULL, .write_ptr = (char *)1, .wide_vtable = &normal_wide_vtable };
fake_file stderr_like = { .label = "stderr_like", .mode = 1, .vtable = &legal_wfile_jumps, .wide_data = &wd };
puts("=== case A: normal path ===");
flush_one(&stderr_like);
puts("\n=== case B: side pointer tampered ===");
wd.wide_vtable = &tampered_wide_vtable;
flush_one(&stderr_like);
return 0;
}
gcc -Wall -Wextra -O2 fsop_wfile_chain_demo.c -o fsop_wfile_chain_demo
./fsop_wfile_chain_demo
=== case A: normal path ===
[flush] wide condition satisfied -> call overflow
[path] write_base is NULL -> call wdoallocbuf
[normal] doallocate on stderr_like
=== case B: side pointer tampered ===
[flush] wide condition satisfied -> call overflow
[path] write_base is NULL -> call wdoallocbuf
[demo] control reaches wide_vtable->doallocate on stderr_like
[demo] this is where a real attack would try to abuse control flow
从防守角度看,这条链最有启发的地方是:不能只看“主 vtable 已校验”就下结论。当前真正的风险点在主流程后的侧链一致性。更稳妥的方向,是把 _wide_vtable 也纳入同等级来源校验,或者在关键路径增加结构体状态一致性检查,减少“合法主表 + 异常侧表”这种组合出现的可能。
现在可能看起来不那么新奇,但我认为还是有价值的,因为它正好卡在 libc 兼容性和安全性之间的缝隙。我认为理解这条链的意义,不只是为了做利用,更是为了把防守假设补完整。
参考源码与文档
- https://sourceware.org/git/?p=glibc.git;a=blob;f=stdlib/exit.c;hb=glibc-2.41
- https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/genops.c;hb=glibc-2.41
- https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/bits/types/struct_FILE.h;hb=glibc-2.41
- https://sourceware.org/git/?p=glibc.git;a=blob;f=NEWS;hb=glibc-2.41









