写在前面:欢迎进入 Week13 的学习!在前两周的现代堆利用(House of 系列)中,我们多次看到
IO_FILE、FSOP、_IO_list_all等名词。在 glibc 2.34 移除了__free_hook等传统劫持点后,伪造 IO 结构体(FSOP)已经成为现代 CTF PWN 拿 Shell 的绝对主流。然而,如果不深入理解_IO_FILE的内部结构和fopen/fread/fwrite/fclose的底层调用链,生搬硬套 House of Apple 或 Pig 的模板,在面对稍微变形的题目时必然会束手无策。本周,我们将花最大的精力把 IO 调用链彻底搞懂。今天,首篇将带你精读结构体布局并剖析核心调用链。
📑 目录
- 为什么是 _IO_FILE?FSOP 概述
_IO_FILE结构体布局精读- 核心调用链剖析:fopen / fread / fwrite / fclose
- 触发机制:
_IO_list_all与_IO_flush_all_lockp - 总结与下篇预告
1. 为什么是 _IO_FILE?FSOP 概述
在早期的 glibc 中,标准的文件操作(如printf,scanf)是基于 C 标准库的FILE结构体实现的。glibc 为了实现多态和面向对象的封装,使用了 C 语言的一种常见技巧:结构体头部包含函数指针表(虚表)。
在 glibc 内部,FILE实际上被定义为_IO_FILE结构体。当我们调用诸如fwrite或fclose时,底层的 glibc 代码并不是直接调用写死的系统调用,而是通过读取_IO_FILE结构体中的vtable指针,去调用对应的虚函数(如__write,__overflow等)。
FSOP (File Stream Oriented Programming)的核心思想就是:
如果存在内存破坏漏洞(如堆溢出、UAF等),我们可以伪造一个恶意的_IO_FILE结构体,将其链接进 glibc 维护的流链表(_IO_list_all)中,或者直接篡改现有的stdout/stderr。当程序执行退出(exit)或刷新流(如malloc_printerr触发abort)时,glibc 会遍历并调用这些结构体里的虚函数。由于我们篡改了虚表指针,最终会导致控制流劫持。
2._IO_FILE结构体布局精读
要玩转 FSOP,必须像熟悉malloc_chunk一样熟悉_IO_FILE。其核心布局如下(64位系统下,大小通常为 0xe0 字节):
struct _IO_FILE { int _flags; /* 偏移 0x00: 高低位标志位,决定了流的读写权限等 */ /* 读写指针区域 (High) */ char *_IO_read_ptr; /* 偏移 0x08: 当前读取位置 */ char *_IO_read_end; /* 偏移 0x10: 读取缓冲区结束位置 */ char *_IO_read_base; /* 偏移 0x18: 读取缓冲区起始位置 */ char *_IO_write_base; /* 偏移 0x20: 写入缓冲区起始位置 */ char *_IO_write_ptr; /* 偏移 0x28: 当前写入位置 */ char *_IO_write_end; /* 偏移 0x30: 写入缓冲区结束位置 */ /* 底层缓冲区控制 */ char *_IO_buf_base; /* 偏移 0x38: 物理缓冲区起始位置 */ char *_IO_buf_end; /* 偏移 0x40: 物理缓冲区结束位置 */ /* 其他保存字段 (通常在利用时置零) */ char *_IO_save_base; /* 0x48 */ char *_IO_backup_base;/* 0x50 */ char *_IO_save_end; /* 0x58 */ struct _IO_marker *_markers; /* 0x60 */ /* 链表指针:这是 FSOP 遍历的核心! */ struct _IO_FILE *_chain; /* 偏移 0x68: 指向下一个 _IO_FILE 结构体 */ int _fileno; /* 0x70: 文件描述符 (如 0=stdin, 1=stdout, 2=stderr) */ int _flags2; /* 0x74 */ _IO_off_t _old_offset;/* 0x78 */ /* 略过部分不重要字段... */ _IO_lock_t *_lock; /* 偏移 0x88: 线程锁指针,必须指向可写内存,否则触发崩溃 */ /* 以下是 glibc 2.24+ 引入的 _IO_FILE_plus 扩展部分 */ /* 如果是 _IO_FILE_plus 结构体,则后面跟着: */ // const struct _IO_jump_t *vtable; /* 偏移 0xd8: 虚表指针!核心中的核心! */ };🎯 伪造时的关键字段:
_flags(0x00):必须确保没有设置_IO_NO_WRITES等阻塞标志。在做system("/bin/sh")时,由于fp作为第一个参数传入,这个位置有时还需要布置成/bin/sh\x00的前几个字节。_IO_write_base与_IO_write_ptr(0x20, 0x28):在触发overflow时,glibc 通常会检查ptr > base,以此判断缓冲区有数据需要刷新。这是我们触发虚函数的先决条件。_chain(0x68):指向下一个 FILE 结构体。_IO_list_all是链表头。如果我们能修改链表头,或者修改前一个节点的_chain,就能把伪造的结构体插入遍历路径中。_lock(0x88):在多线程环境下,刷新流之前会获取锁。_lock必须指向一个合法的、可写的内存地址(通常是指向堆上或 libc 上的某个零值内存),否则会在_IO_acquire_lock处直接段错误。vtable(0xd8):决定了要调用的函数表。在 glibc 2.24 之后,这个地址必须落在合法的__libc_IO_vtables段内,否则触发IO_vtable_check报错。
3. 核心调用链剖析:fopen / fread / fwrite / fclose
理解调用链,就是理解数据是如何在缓冲区流转的,以及虚函数是在哪一步被调用的。
3.1 fopen
- 调用
malloc分配一块大小为0x1e0(包含_IO_FILE_plus和_IO_wide_data)的内存。 - 初始化
_IO_FILE的各个字段(设置_fileno,清空缓冲区指针等)。 - 设置
vtable为_IO_file_jumps。 - 链表插入:将新结构体的
_chain指向旧的_IO_list_all,然后更新_IO_list_all指向新结构体。(头插法)
3.2 fread
调用fread(buf, size, count, fp)时,底层调用fp->vtable->__read(fp, buf, size)。
但在真正调用__read(即系统调用read) 之前,会经历复杂的缓冲区管理:
- 检查
_IO_read_ptr是否小于_IO_read_end。如果是,说明缓冲区还有数据,直接memcpy到用户buf中,不触发系统调用。 - 如果缓冲区空了,调用虚函数
__underflow。 __underflow会调用__read从文件描述符读取数据到_IO_buf_base到_IO_buf_end之间的物理缓冲区中,然后更新_IO_read_ptr等指针。
3.3 fwrite
调用fwrite(buf, size, count, fp)时,底层调用fp->vtable->__write(fp, buf, size)。
缓冲区逻辑:
- 将用户数据
memcpy到_IO_write_ptr指向的位置,并移动_IO_write_ptr。 - 如果
_IO_write_ptr >= _IO_write_end,说明写缓冲区满了,触发虚函数__overflow。 __overflow会调用__write将缓冲区数据真正写入内核(系统调用write),然后将_IO_write_ptr重置回_IO_write_base。
💡 利用启示:在伪造结构体时,为了让程序走到
__overflow,我们通常需要设置_IO_write_ptr > _IO_write_base,让 glibc 误以为有数据需要刷新。
3.4 fclose
- 调用虚函数
vtable->__close(fp)。 - 刷新缓冲区:如果写缓冲区有数据,调用
__overflow刷入内核。 - 链表卸载:遍历
_IO_list_all,找到当前fp,将其从链表中摘除(修改前一个节点的_chain)。 - 释放
_IO_FILE结构体及其缓冲区内存(free)。
4. 触发机制:_IO_list_all与_IO_flush_all_lockp
我们伪造了结构体,如何让 glibc 去调用它?最常用的触发点是_IO_flush_all_lockp函数。这个函数的作用是遍历整个_IO_list_all链表,对每一个 FILE 结构体执行刷新操作。
源码逻辑简化如下:
void _IO_flush_all_lockp (int do_lock) { struct _IO_FILE *fp; // 从链表头开始遍历 for (fp = (_IO_FILE *) _IO_list_all; fp != NULL; fp = fp->_chain) { // 关键判断!如果以下条件满足,就会调用 overflow 虚函数! if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && ...)) && _IO_OVERFLOW (fp, EOF) == EOF) break; } }触发_IO_flush_all_lockp的三大路径:
exit():程序正常退出时,会调用__libc_atexit注册的函数,其中就包括刷新所有 IO 流。abort():当 glibc 遇到致命错误(如堆破坏触发malloc_printerr)时,最终会调用abort,在abort也会刷新流。_IO_cleanup():显式调用清理操作。
攻击模型:
无论我们是通过 House of Botcake 还是 Largebin Attack,只要能把伪造的堆块地址写入_IO_list_all,并保证伪造的 chunk 满足_IO_write_ptr > _IO_write_base等条件,程序在退出或崩溃时,就会乖乖地遍历到我们的 Fake FILE,并调用我们精心准备的vtable->overflow(如 House of Apple 中的_IO_wfile_overflow)。
5. 总结与下篇预告
5.1 核心知识点总结
- 结构布局:
_IO_FILE是 glibc 对文件流的抽象,其内部的_chain构成单向链表,vtable实现多态。 - 调用链:
fread/fwrite本质上是对缓冲区指针的操作,当缓冲区满或空时,通过vtable调用底层系统调用。 - 触发原理:FSOP 的核心是劫持
_IO_list_all链表,并依赖exit()或abort()触发_IO_flush_all_lockp遍历链表,最终调用伪造的overflow虚函数。
5.2 下篇预告
在下一篇中,我们将利用今天学到的结构布局知识,进行实战伪造演练。
- 精讲如何通过控制
_IO_buf_base和_IO_buf_end实现任意地址读写。 - 分析在 glibc 2.23 时代,如何利用
_IO_str_jumps绕过初步检查(为后续理解高版本绕过打下基础)。 - 结合版本演进表,梳理 vtable 校验机制的变迁。
结语:磨刀不误砍柴工。搞清楚
_IO_FILE的每一行结构、每一条调用链,是你在现代 CTF PWN 中面对千变万化的 IO 题目时,能够举一反三、构造出精妙 Exp 的底气所在。