House of Orange实战:IO_FILE、FSOP与glibc堆利用详解

House of Orange实战:IO_FILE、FSOP与glibc堆利用详解 老读者应该知道我这两年大部分精力都花在堆利用上尤其是 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结构体那所有遍历这个链表的函数都会操作你伪造的数据。这就引出了后面要讲的 FSOPFile 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文件模式0xd8vtable虚函数表指针_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 chunkold_size是它的 size。如果你能通过漏洞把 top chunk 的 size 改成一个“看起来不正常”的值比如远小于实际大小这个 assert 就会触发进而调用malloc_printerr最终走到abort。但关键在于在触发 assert 之前glibc 会先把旧的 top chunk 释放进 unsorted binset_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 0xbcvtable在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: 触发 sysmalloctop chunk 进 unsorted bin malloc(0x1000) # step 3: 切分 unsorted bin改剩余 chunk 的 bk ptr malloc(0x40) malloc(0x40) edit(ptr, bA * 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 / exitFSOP 执行 system(/bin/sh)注意 step 3 中edit的数据布局要精确对齐到剩余 chunk 的 size 字段和 bk 字段具体偏移需要根据你第一次切割后剩余 chunk 的位置计算。4. 实操演示从调试到拿 shell4.1 搭建调试环境与信息泄露我在本地 Ubuntu 16.04 上复现时用的 libc 是 glibc 2.23CTF 题基本都基于这个版本。建议先把 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) # 触发 sysmalloctop 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 ---------- # 再次溢出修改剩余 chunksize 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在 0x10bk在 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 corruptionfake 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_vtablevtable 必须指向 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这套东西玩熟了之后看很多堆题都是同一个套路只是包装不同而已。