1. 为什么写这篇文章:一场关于IO_FILE的认知升级
说来惭愧,我接触House of orange也有一段时间了,但真正搞懂它,是在一次被面试官连环追问之后。当时对面抛来一个问题:“你说House of orange利用了IO_FILE,那IO_FILE结构体为什么能控?vtable在里面扮演什么角色?就这么空口说‘伪造一个IO_FILE结构体’,你考虑过glibc的校验吗?”我当场有点卡壳。
后来我把glibc源码翻了个底朝天,才意识到一个关键事实:在glibc 2.23及更早版本里,IO_FILE的攻击面其实极为开阔,因为vtable的调用几乎没有任何校验;而2.24加入IO_validate_vtable之后,原来的House of orange经典打法直接失效。 这意味着如果你不理解IO_FILE结构体本身,只想背一个攻击流程,那你大概率会在新环境里寸步难行。
所以这篇博文的定位就很明确了:从结构体定义出发,先讲清楚IO_FILE在glibc的stdio体系里长什么样,它的字段偏移、vtable指针如何被调用,再去拆解House of orange那条经典利用链——为什么作者当初要伪造 unsorted bin、为什么要触发 _IO_list_all 上的 _IO_str_overflow,每一步背后的约束条件是什么。适合的人群是:已经会基本的堆利用(至少知道fastbin、unsorted bin、unlink是怎么回事),但看到IO_FILE就头大的朋友;以及想在glibc 2.23/2.24/2.27等不同版本下做对比研究的CTF学习者。
至于安全的边界,我多说一句。这种技术拆解本质上是对glibc内部机制的源码级理解,研究它有利于我们理解文件流底层实现,也有利于在做漏洞挖掘或防御时分析攻击面。文章只做原理和CTF场景的分析,不会涉及任何实际攻击目标,也请在合规环境下实验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从结构体本身说起:IO_FILE的字段布局与vtable机制
IO_FILE结构体在glibc的libio/stdio.h中定义,它其实是C标准库FILE类型背后的真实结构。大多数人在学C语言时,只是把FILE*当成了一个不透明的文件句柄来用,但如果你走到二进制利用这个层面,就必须把它当成一块可以精确操控的内存布局。
2.1 一张表看清核心字段偏移
以64位glibc 2.23为例,我经常用的一张速查表长这样:
| 偏移 | 字段 | 作用 | 备注 |
|---|---|---|---|
| 0x00 | _flags | 文件状态标志 | 也常被当作低4字节的合法检查用 |
| 0x08 | _IO_read_ptr | 读缓冲区的当前读指针 | 与写偏移联动时会有趣 |
| 0x10 | _IO_read_end | 读缓冲区的结束地址 | |
| 0x18 | _IO_read_base | 读缓冲区的起始地址 | |
| 0x20 | _IO_write_base | 写缓冲区的起始地址 | |
| 0x28 | _IO_write_ptr | 写缓冲区的当前写指针 | |
| 0x30 | _IO_write_end | 写缓冲区结束地址 | |
| 0x38 | _IO_buf_base | 缓冲区基址 | 与缓冲操作有关 |
| 0x40 | _IO_buf_end | 缓冲区结尾 | |
| 0x48 | _IO_save_base | 备份缓冲区起始 | 一般很少用到 |
| 0x50 | _IO_backup_base | 备份缓冲区指针 | |
| 0x58 | _IO_save_end | 备份缓冲区结束 | |
| 0x60 | _markers | 链表标记 | 偏内部 |
| 0x68 | _chain | IO链表中下一个节点 | FSOP的关键通道 |
| 0x70 | _fileno | 文件描述符 | 可伪装成0 |
| 0x78 | _flags2 | 额外标志 | |
| 0x80 | _old_offset | 原偏移 | |
| 0x88 | _cur_column | 当前列 | |
| 0x90 | _vtable_offset | vtable偏移 | 64位下通常为0 |
| 0x98 | _shortbuf | 短缓冲区数组 | |
| 0xa0 | _lock | 锁指针 | 在glibc中往往指向自身附近 |
| 0xa8 | _offset | 文件偏移 | |
| 0xb0 | _codecvt | 编码转换结构 | |
| 0xb8 | _wide_data | 宽字符数据指针 | |
| 0xc0 | _freeres_list | 释放列表 | |
| 0xc8 | _freeres_buf | 释放缓冲区 | |
| 0xd0 | __pad5 | 填充 | |
| 0xd8 | _mode | 模式 | |
| 0xe0 | _unused2 | 对齐填充 |
这张表的大多数偏移,在高版本glibc里其实变动不大,主要的差异是字段细节和校验逻辑。所以这张表可以当作一份通用参考。
2.2 vtable指针:IO_FILE结构体里最危险的一个字段
在真实的结构体定义中,文件流对象最末尾(通常在偏移0xd8之后)会有一个vtable指针,指向_IO_jump_t类型的跳转表。_IO_jump_t里保存了一系列函数指针,比如:
_IO_file_finish_IO_overflow_IO_underflow_IO_uflow_IO_pbackfail_IO_xsgetn_IO_xsputn_IO_seekoff_IO_seekpos_IO_setbuf_IO_sync_IO_doallocate_IO_read_IO_write_IO_seek_IO_close_IO_stat_IO_showmanyc_IO_imbue
当我们调用fread、fwrite、fclose、fflush、exit等函数时,glibc内部最终会通过IO_OVERFLOW(fp)、IO_UNDERFLOW(fp)这样的宏,从结构体末尾取vtable,然后索引到具体函数指针并跳转。
如果攻击者能把一块可控内存伪造成IO_FILE结构体,并把vtable指针指向自己伪造的跳转表,那么一旦某个函数触发了这条IO调用链,就相当于获得了控制流劫持的能力。这就是所有IO_FILE类攻击的根基。
不过你可能想知道,为什么偏偏是vtable而不是直接覆盖某个函数指针?因为glibc的设计里,函数指针并不是直接存在结构体中,而是在跳转表中,这样同一个类型的所有文件对象可以共享一套实现。这个设计在软件工程上很优雅,但给利用带来了一个绕不开的约束:_IO_jump_t中每个函数指针的偏移是固定的。也就是说,如果你要伪造vtable,必须保证在攻击触发位置,索引计算得到的函数指针落在你布置的地址上。
2.3 链式存储:_chain字段与IO_list_all的关系
很多人在学数据结构时听说过链表,但未必会把“链式存储”这个概念和glibc联系起来。实际上,glibc内部用_IO_list_all这个全局变量(类型为IO_FILE*)维护了一个所有打开文件流的双向链表。每个IO_FILE结构体中的`_
