:深入 _IO_FILE 调用链与结构布局——FSOP 的基石)
写在前面欢迎进入 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_FILEFSOP 概述_IO_FILE结构体布局精读核心调用链剖析fopen / fread / fwrite / fclose触发机制_IO_list_all与_IO_flush_all_lockp总结与下篇预告1. 为什么是 _IO_FILEFSOP 概述在早期的 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: 文件描述符 (如 0stdin, 1stdout, 2stderr) */ 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 的底气所在。