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结构体中的_chain字段(偏移0x68)指向下一个文件流,形成一个单链。
这个链表的意义非常重大。因为exit函数在进程退出时会调用_IO_cleanup,遍历_IO_list_all链表,对每一个文件流执行一系列清理操作,包括flush缓冲区、调用vtable中的函数等。House of orange的FSOP(File Stream Oriented Programming)思路,本质就是往这个链表上挂一个伪造的文件流结构,让程序在退出或某种IO调用发生时,自动跳向攻击者预设的地址。
这里我多说一个知识点,也是热词里提到的“结构体变量的定义”和“结构体的链式存储”之间的联系:在C语言层面,链式存储经常是struct node { int val; struct node *next; };,而glibc的IO_FILE链表就是类似思想,只是它的节点内容多了几十个字段,尤其是vtable。理解了_chain的作用,你再看House of orange的利用构造,就会明白为什么攻击者要把伪造的IO_FILE结构体放在堆地址上,并且让_chain恰好满足链表遍历逻辑。
3. 从堆布局到IO_FILE:House of orange为什么需要伪造unsorted bin
一句话概括House of orange的思路:修改top chunk的大小,分配一个超出系统剩余空间的chunk,迫使glibc把旧的top chunk释放进unsorted bin,然后再利用这个释放行为去破坏_IO_list_all,最终通过FSOP控制程序流程。 但这句话背后其实埋了三个独立的问题:
- 为什么需要修改top chunk大小?
- 为什么被释放的top chunk会进入unsorted bin而不是fastbin?
- 释放后如何和
_IO_list_all产生联系?
3.1 常规House of orange中,unsorted bin是必经之路
CTF里有一道经典题目叫houseoforange,它给了一个很典型的菜单程序,漏洞是off-by-one,可以修改堆块大小,但程序内部缺少free功能——也就是说,你没办法直接调用free来制造UAF或double free,只能不断分配。
这时候能打的点就只剩top chunk了。因为程序不能主动释放,常规的泄露libc、打free_hook的路线全都不通。利用者被迫想到:既然不能主动free,能不能让glibc替我们free一次?答案就是修改top chunk的size,把它改小或者改得极其不合理,然后申请一个超过当前top剩下大小的chunk。malloc在发现top剩余空间不足时,会调用sysmalloc中的_int_free把旧top chunk释放到unsorted bin,再重新映射一块新的top。
这个操作叫作house of orange的第一步:松掉top chunk。它是整个利用链的起手式,也是很多人第一次接触“原来malloc还能这样帮我们制造free”的时刻。我这里把具体的修改方式展开一下。
假设初始top chunk的size字段在堆基址+0xbe1附近,由于程序有off-by-one,我们可以溢出改写这个size。这里有几个关键的约束:
- size的值必须8字节对齐,且要满足glibc对chunk size的最低要求(一般是0x20)。
- size的值不能太大,否则会被页对齐逻辑直接拉到一个奇怪的位置,导致后续映射失败。
- size的值要保证在释放旧top时,
chunk2size计算得到的长度不会让系统认为它非法。
很多文章会让改到0xf81或者0xfc1之类的值,配合伪造一个伪chunk头,目的是让旧top释放后成为一个合法的unsorted chunk,其fd和bk指针能够被后续操作改写。
3.2 unsorted bin攻击:为什么被释放的top chunk会落在_IO_list_all前面
House of orange最关键的一步,就是利用unsorted bin的unlink逻辑去改写_IO_list_all。这里需要你先接受一个事实:_IO_list_all符号的地址,在libc中往往位于某个libc全局数据区,而unsorted bin的main_arena+88附近也在这个数据区。 攻击者通过unsorted bin的bk指针控制,可以把一个伪造的chunk地址(通常是堆地址)写到_IO_list_all上。
具体的流程是:
- 申请一个大小略小于刚刚释放的unsorted chunk的堆块,比如释放了一个size为0xfb0的chunk,然后申请0x3f8的chunk。旧chunk中剩余部分会被切割,形成一个小的unsorted chunk残留。
- 控制残留chunk的fd和bk指针。经典的House of orange会把fd指向
_IO_list_all - 0x10附近,bk指向main_arena+88。 - 再次分配时,malloc从unsorted bin中取出这个残留chunk,会做unlink检查。由于fd和bk是可控制的,在一些glibc版本(尤其是2.23下)没有对fd->bk和bk->fd做足够严格的校验,就可能把
_IO_list_all写成一个堆地址。
这里有个绕不开的细节:glibc 2.23的unlink检查是有的,但只检查了P->fd->bk == P和P->bk->fd == P这一层,并没有做P->fd_nextsize、P->bk_nextsize的完整性校验。所以只要两个条件等式成立,就可以通过。如果把fd设置为_IO_list_all - 0x10,那么fd->bk实际上就是读取_IO_list_all处的值,如果我们能控制它等于P(也就是残留chunk地址),检查就能绕过。这一条实践中往往通过精心布置chunk地址和堆泄露来满足。
3.3 从“控制_IO_list_all”到“伪造IO_FILE”之间,还要过一道关
当_IO_list_all被改成堆地址之后,后续程序一旦触发IO清理流程(比如exit),就会把这个堆地址当作IO_FILE*链表的头,遍历_chain字段。攻击者需要在堆上伪造出一系列连续的字段,使得:
_IO_list_all指向的地址,被当作文件的flags、缓冲区指针、vtable指针;- 遍历链表时,
_chain指向的下一项也能被正确解析; - 在某个特定操作(如
_IO_OVERFLOW)时,vtable索引被计算到我们控制的system或one_gadget。
这个阶段很容易让人迷失,因为你根本不需要把结构体所有字段都填对,只要让触发点用到的字段合法即可。其他字段可以是任意值。
我把这个阶段拆成三个要点:
_IO_list_all被改成堆地址后,程序再次读到链表头时会从该堆地址读取_chain(偏移0x68),所以要保证这个偏移处指向伪造结构的下一段或不影响利用。- 伪造IO_FILE时,需要让
unsorted bin的bk指针指向_IO_list_all - 0x10,这样在unlink写入时会把堆地址写到_IO_list_all上。 - 最关键的是vtable指针要指向一个可跳过校验的合法跳转表或伪造跳转表,并且让触发点在特定偏移处取到我们想要的函数地址。
4. FSOP的入口:exit、malloc_printerr与_IO_OVERFLOW触发链
有了IO_FILE和vtable,接下来要回答一个问题:什么代码路径会真正触碰这些伪造字段? House of orange的经典利用主要依赖两条触发链:_IO_OVERFLOW和_IO_str_overflow。下面分别讲清楚。
4.1 触发点一:malloc报错路径上的_IO_list_all遍历
在程序发生某些错误时会调用malloc_printerr,这个函数最终会调用__libc_message,进而调用_IO_list_all上的文件流刷新操作。具体的调用链大致是:
malloc_printerr__libc_message_IO_flush_all_lockp- 遍历
_IO_list_all - 对每个
IO_FILE调用_IO_OVERFLOW(fp, EOF) - 从vtable中取
_IO_overflow函数指针并调用
在glibc 2.23中,_IO_OVERFLOW宏直接展开为__io_overflow或vtable索引调用,没有对vtable地址做合法性校验。因此攻击者只需要把vtable指针设置到一个可写的地址,并在vtable+offset处放上system地址,就能在报错路径上获得RCE。这里的“offset”对应_IO_overflow在_IO_jump_t中的索引,通常是第7个函数指针(64位下偏移是0x18)。
关于偏移的计算,我是用源码直接推的。_IO_jump_t结构体中,函数指针按顺序排列:
_IO_finish:0x0_IO_overflow:0x8_IO_underflow:0x10_IO_uflow:0x18_IO_pbackfail:0x20_IO_xsgetn:0x28_IO_xsputn:0x30_IO_seekoff:0x38- ...
在一些利用中,攻击者选择偏移0x18(也就是_IO_uflow的位置)来放system,然后想办法让触发点调用到对应索引。这取决于具体的IO操作路径。很多时候我们为了让条件简化,会把vtable直接指向IO_2_1_stdout_附近的合法跳转表,再结合偏移调整,或者直接伪造一整张跳转表在堆上。
我建议你把_IO_jump_t的布局打印出来研究一次,这会极大提升你对House of orange的理解深度。简单来说,源码里每个虚函数的位置决定了你往哪个偏移填地址。
4.2 触发点二:_IO_str_overflow在_IO_list_all伪造中的妙用
House of orange的原作者最终选择的不是直接用_IO_overflow,而是利用_IO_str_overflow(位于strops.c)这个函数。原因在于_IO_overflow需要伪造的vtable校验在2.23里虽然宽松,但直接放system到偏移处会导致系统把参数当作IO_FILE*传入,等于你只能传入一个指针,而这指针指向的内容很难控制成/bin/sh字符串。
_IO_str_overflow不一样。它在执行过程中会把IO_FILE结构体中的_IO_buf_base当成缓冲区,并调用malloc或者free,然后把新的缓冲区指针写回结构体。如果攻击者精心控制结构体字段,可以让函数最后调用system(fp->_IO_buf_base),一旦_IO_buf_base被设置成/bin/sh字符串地址,就实现了提权。
用白话描述就是:_IO_str_overflow帮我们做了一次“往IO_FILE字段里取参数然后调用函数指针”的桥接,把只能处理一个IO_FILE*参数的限制,变成可以处理任意字符串参数。这是它价值所在。
在伪造_IO_list_all链的时候,攻击者会把_chain指向一个伪造的IO_FILE结构体,而这个结构体的字段设置如下:
_IO_buf_base= 指向/bin/sh字符串的地址_IO_buf_end=_IO_buf_base + 长度_mode= 0(确保走_IO_str_overflow分支,而不是wide character分支)vtable= 指向伪造的跳转表,且跳转表+偏移0x18处是_IO_str_overflow地址
这样,当_IO_flush_all_lockp遍历到该伪造结构体并调用_IO_OVERFLOW时,实际跳到了_IO_str_overflow。_IO_str_overflow内部会根据_flags、_IO_buf_base等字段执行操作,最终把system的地址从伪造的vtable某处取出并调用,参数为字符串指针。
4.3 glibc 2.24之后的vtable校验:一道绕不开的坎
上面这套经典打法在glibc 2.23上能一击命中,但glibc 2.24版本更新后,加入了IO_validate_vtable宏,对所有从vtable取出的函数指针做了合法性检查。简单说,它会检查vtable指针是否落在__libc_IO_vtables段的合法范围内,如果不是,直接报错终止。
这意味着,在2.24及以上版本中,把vtable指向堆上的伪造跳转表会导致崩溃。为了绕过,研究圈衍生出很多新技巧:
- vtable劫持到合法偏移:把vtable指针指向一个合法的
_IO_str_jumps或_IO_wfile_jumps等已有跳转表,然后通过控制索引偏移跳到我们想要的地方。 - 利用IO_FILE结构体内的字段修改合法函数的行为:不改变vtable指向,而是让合法函数在处理结构体字段时产生我们想要的效果,典型如
_IO_str_finish,它在某些条件下会调用free或system。 - 借助
_IO_wfile_jumps和wide data的搭配:在高版本中,很多攻击者转战_wide_data字段,因为它和vtable的变化更多,校验覆盖不够完整。
我给你一个高版本下的实战建议:拿到一个开了新版本glibc的题目,先不要急着背payload,而是用gdb去看__libc_IO_vtables开始和结束的地址,以及_IO_str_jumps、_IO_wfile_jumps、_IO_file_jumps都在什么位置。很多利用的难点不在于找不到可跳跃转表,而在于vtable偏移和函数参数之间的配合。
House of orange在高版本下其实已经很少作为首选攻击了,因为vtable校验把路堵得很死。但它的思想——利用IO_FILE链、控制全局链表指针、把堆地址挂到glibc全局数据区——是理解后续所有FSOP类技巧的地基。
5. 从源码到实战:伪造IO_FILE时需要满足的条件清单
把上面的原理落实到一次实际构造中,我习惯列一个条件清单,每次写exp之前都对着检查。这样能少踩很多坑。
5.1 伪造IO_FILE的最小字段集合
假设目标是glibc 2.23,使用_IO_str_overflow路线,伪造结构体在堆地址fake_file处。最小字段规划如下:
| 字段 | 值 | 目的 |
|---|---|---|
_flags |
0x0或一个可写标志 | 避免触发某些分支判断异常 |
_IO_read_ptr |
任意 | 在_IO_str_overflow中可能被用作old_buf,需要合理设置 |
_IO_read_end |
任意 | 很少参与该路径 |
_IO_read_base |
任意 | |
_IO_write_base |
任意 | |
_IO_write_ptr |
任意 | |
_IO_write_end |
任意 | |
_IO_buf_base |
/bin/sh字符串地址 |
最终传给system的字符串 |
_IO_buf_end |
_IO_buf_base + len |
避免大小异常 |
_chain |
0或任意 | 遍历时可能被指向下一项,结束即可 |
_mode |
0x0 | 走窄字符路径 |
vtable |
fake_vtable |
指向可控跳转表 |
fake_vtable + 0x18 |
_IO_str_overflow |
触发点使用 |
fake_vtable + 0x38 |
system |
_IO_str_overflow内部可能从该偏移取出并调用 |
这里fake_vtable + 0x38对应的其实是_IO_xsputn或_IO_seekoff等位置,不同glibc版本内部调用偏移不同,但我建议你用调试器确认_IO_str_overflow中最终调用函数指针的位置,再决定在fake vtable的哪个偏移放system。我见过很多新手栽在这里,以为放一个system就行,结果_IO_str_overflow内部压根不走那个偏移。
5.2 glibc版本的差异对照
我把2.23、2.24、2.27这几个常见版本作为一个对照表,方便大家在做题时快速定位:
| 版本 | vtable校验 | 经典House of orange | 主流替代方案 |
|---|---|---|---|
| 2.23 | 无 | 直接可用,伪造vtable到堆上 | 很多题目都用这种 |
| 2.24 | 有 | 需要合法vtable地址或IO_validate_vtable绕过 | 劫持到_IO_str_jumps等 |
| 2.27 | 有 | 难用,通常需要其他一劫手段 | tcache stashing unlink、large bin attack等 |
| 2.31+ | 更严格 | 基本不可用 | 换用setcontext、hook、exit hook等 |
如果你是一个准备CTF比赛的新手,我特别建议你从glibc 2.23的House of orange开始练,因为它没有tcache,unsorted bin行为更接近经典内存管理模型,而且vtable校验缺失能让整个IO_FILE伪造过程像拼图一样清晰。等吃透了再上2.27,你会发现“同样一个IO_FILE结构体,在2.27里多了很多禁忌”。
5.3 调试心得:用gdb验证IO_FILE链表的遍历过程
我给一个很实用的调试建议。伪造完IO_FILE后,不要急着跑exp去拿shell,先在gdb里设置断点:
- 在
_IO_flush_all_lockp上下断点,查看_IO_list_all的值是否已经指向你伪造的堆地址。 - 在
_IO_list_all被改成堆地址后,用p *((struct _IO_FILE*)fake_file)检查结构体字段是否符合预期。 - 在
IO_validate_vtable处断点(如果版本较新),观察vtable是否合法。 - 单步步入
_IO_OVERFLOW宏展开后的调用点,看它实际去的地址和参数。
我自己的习惯是分段验证:先验证_IO_list_all是否被成功覆盖成堆地址,再验证_IO_OVERFLOW触发时是否跳到_IO_str_overflow,最后验证system的参数是否是/bin/sh。每验证通过一个阶段,才继续下一步。如果直接一把梭,出错了你根本不知道是没绕过vtable校验,还是字符串参数不对,排查效率极低。
另外_IO_list_all符号在libc中的地址,可以通过p &_IO_list_all获取;但在实际exp中,通常会通过libc基地址加偏移算出来。这个偏移值在不同版本的libc中不同,建议用readelf -s或者本地的libc-database工具来查。
6. 绕过高版本vtable校验的常见思路与边界
前面说了2.24之后有IO_validate_vtable,但研究社区并没有因此止步。这里我整理几个经典思路,这些思路虽然不完全属于House of orange,但都是IO_FILE攻击的延续,非常适合作为进阶学习方向。
6.1 利用合法vtable段内的偏移
IO_validate_vtable只检查vtable指针是否在__libc_IO_vtables段内,并不检查vtable的索引是否越界。因此攻击者可以考虑把一个已知合法跳转表的地址加上某个偏移,使得索引计算后指向一个可控的、被写入堆地址的位置,从而让函数指针落在攻击者控制的地址上。
这种方法的关键在于,你需要精确计算vtable + N落在__libc_IO_vtables段内,且N偏移处的函数指针是你可以通过某种方式写入的。但写libc只读段通常很难,所以这个思路往往需要结合其他漏洞,比如任意地址写。
6.2 劫持到_IO_str_finish等特殊函数
另一个思路是,不去修改vtable,而是让IO清理路径调用某个合法函数,该函数本身就带有危险操作。例如_IO_str_finish用于字符串流的收尾,它最终会调用free(fp->_IO_buf_base)。如果攻击者控制_IO_buf_base指向任意地址,就相当于获得了任意地址free的能力;如果配合把_IO_buf_base改到__free_hook附近,甚至有机会把__free_hook覆盖成system。
这类思路对IO_FILE结构体的理解要求更高,因为你必须知道free的参数如何传递、_IO_buf_base在函数执行中的偏移、以及如何控制__free_hook的写入。它也逐渐演化成了后来的house of emma、house of apple等技巧。
6.3 从House of orange到更为现代的FSOP变体
在较新的glibc(2.31及以上)上,研究圈流行用_wide_data和_IO_wfile_jumps组合绕过校验。你可能会看到house of apple这类技巧,它本质上是在IO_FILE结构体的_wide_data字段里再做一层布局,让vtable校验通过的同时,利用宽字符处理函数里的分支去执行恶意调用。
如果你已经掌握了House of orange的字段布局和vtable机制,再去看这些变体就会发现,它们逃不开几个核心概念:
_IO_list_all链表是FSOP的入口。_chain字段用于串起多个伪造结构体。_mode、_wide_data、_codecvt这些平时被忽略的字段,在高版本攻击中反而成了关键。- vtable校验虽然存在,但合法跳转表里的函数行为是可以被结构体字段“扭曲”的。
我建议你在学高版本变体之前,把基础打得非常扎实:能用纸笔画出_IO_jump_t的布局,能背出IO_FILE结构体里十几个关键字段的偏移,能解释_IO_str_overflow为什么能桥接字符串参数。做到这三个“能”,你面对任何IO_FILE相关题目都不会懵。
7. 一个最小示例:在本地环境验证House of orange执行链
为了让你不至于只看理论,我写了一个最小示例的思路,基于glibc 2.23环境。这个示例不涉及真实程序漏洞,而是模拟“已经拿到堆溢出和libc泄露”之后的IO_FILE伪造阶段,用来验证整条调用链能否打通。
7.1 假设条件
- 存在一个堆溢出,可以修改一个已分配chunk的内容。
- 已泄露libc基址。
- 目标环境是glibc 2.23。
- 我们希望在程序退出时,通过FSOP执行
system("/bin/sh")。
7.2 核心步骤
- 计算
_IO_list_all地址:libc_base + offset_IO_list_all。 - 计算
_IO_str_overflow地址:libc_base + offset_IO_str_overflow。 - 计算
system地址:libc_base + offset_system。 - 在堆上布置一个
fake_file结构体,字段如上表所列。 - 在
fake_file + 0xd8的位置设置vtable指针,指向fake_vtable。 - 在
fake_vtable + 0x18处填入_IO_str_overflow地址,在fake_vtable + 0x38处填入system地址。 - 通过堆溢出将
_IO_list_all覆盖为fake_file(或者通过unsorted bin unlink链式写入)。 - 触发
exit或任何会调用_IO_flush_all_lockp的操作。
7.3 验证脚本片段
用Python的pwn库写exp骨架时,我会这样组织:
python复制from pwn import *
libc = ELF('./libc-2.23.so')
# 假设已经拿到堆地址和libc基址
libc_base = leak_libc_base()
fake_file = heap_base + 0x ... # 堆上可控制区域
payload = b''
payload += p64(0) # _flags
payload = payload.ljust(0x20, b'\x00')
payload += p64(binsh_addr) # _IO_buf_base
payload += p64(binsh_addr + 8) # _IO_buf_end
payload = payload.ljust(0x68, b'\x00')
payload += p64(0) # _chain
payload = payload.ljust(0xc0, b'\x00')
payload += p64(0) # _mode
payload = payload.ljust(0xd8, b'\x00')
vtable_addr = fake_file + len(payload)
payload += p64(vtable_addr) # vtable pointer
payload += p64(0) * 2 # 对齐填充
payload += p64(libc_base + libc.symbols['system'])
# 注意这个位置取决于你实际使用的_IO_overflow索引
# 然后把payload写入可控堆块,并触发exit
这段代码只是一个骨架,实际使用中你必须用gdb确认每个字段的偏移是否和你的libc匹配。我特别想强调的是:不要直接照抄网上的偏移,因为每个libc版本、每个编译选项都会影响结构体内存的排布。除非你非常确定环境完全一致,否则一定要动态调试。
7.4 常见失败原因排查
我在带新人的时候,总结了几个最高频的翻车点:
| 现象 | 可能原因 |
|---|---|
触发时报Fatal error: glibc detected an invalid stdio handle |
_IO_list_all没有按预期指向fake_file,或者fake_file的某个字段非法 |
跳到_IO_str_overflow后崩溃 |
_IO_buf_base或_IO_buf_end设置不合理,导致内部判断走错分支 |
system被调用但参数不是/bin/sh |
vtable偏移设置错误,函数指针复用位置不对,参数寄存器不同 |
在2.24+上报invalid vtable |
vtable指针被校验拦截,需要改成合法跳转表或换用其他路线 |
| 堆地址写到了错误位置 | unsorted bin的unlink步骤中fd/bk设置错误,导致_IO_list_all被写成不可控值 |
这些坑我在学习阶段都踩了一遍,最有效的解决办法就是:在_IO_flush_all_lockp上下断点,一步步查看寄存器,确认跳转目标、参数、堆地址之间的链条是否闭合。不要被“伪造结构体”这几个字吓住,本质就是一场精细的布局游戏。
8. 站在更高的视角:IO_FILE结构体教会了我们什么
如果你完整跟完了前面的内容,你会发现IO_FILE结构体的攻击价值,远不止一个CTF题目那么简单。它揭示了C标准库在设计文件流时,为了性能与灵活性所作出的好几个关键权衡——
- 把所有操作集中到vtable,换来多态能力,但代价是内存中存在全局可跳转的函数指针区域。
- 用
_IO_list_all管理所有打开的文件流,方便统一清理,但代价是链表节点本身变成攻击目标。 - 用
_chain把多个结构体串起来,遍历时天然支持复杂IO状态,但代价是任何可控的堆地址都有可能被当作链表头。
这种“功能即攻击面”的逻辑,在操作系统和库的很多角落都在上演。你能做的最有价值的事,不是死记某一个攻击链,而是学会用源码视角去审视任意一个结构体和全局变量,看它哪些字段会被谁在什么时机读取、写入、调用。一旦建立起这种思维,House of orange对你来说就不再是“某个神奇的技巧”,而是一类问题的代表:当你拥有内存写能力时,如何在庞大而严密的库代码中找到一条通向控制流的路径。
我在实际做漏洞研究时,经常把IO_FILE相关技巧当作“内存写原语到代码执行”的桥梁。很多时候漏洞本身只能让你写几个字节,但如果你能精准地把这几个字节写到_IO_list_all或某个IO_FILE字段上,它就能自动放大成一次完整的函数调用。这也是为什么很多现代堆利用链里,FSOP仍然是最后一步的常客——它把“写内存”变成了“调函数”。
以后你再看到类似的题目,可以先问自己三个问题:
- 目标程序里的IO_FILE结构体,哪些字段是我们可以控制的?
- 在这些可控字段中,哪些字段最终会被某个库函数读取并作为指针、长度或调用目标?
- 从触发点到目标函数之间,还需要绕过什么校验?
把这套“逆向思维”练熟,比单纯刷一百道House of orange变种题都管用。毕竟,漏洞利用的核心从来不是payload本身,而是对目标运行机制的深刻理解。
