House of Orange实战:IO_FILE、FSOP与glibc堆利用详解
2026/9/8 10:17:39 网站建设 项目流程

老读者应该知道,我这两年大部分精力都花在堆利用上,尤其是 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 标准库的“内部实现”,跟漏洞利用八竿子打不着。大错特错。程序里只要出现fopenfreadfwriteprintfscanf这类函数,背后操作的全是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.clibio/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这个函数,它会在程序exitabort或者某些错误路径中被调用。

_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 <= 0fp->_IO_write_ptr > fp->_IO_write_base,满足就直接进入_IO_OVERFLOW(fp, EOF)调用。
  • 或者_IO_vtable_offset(fp) != 0fp->_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 时,主要约束有这么几条:

  1. old_size必须大于MINSIZE,也就是大于等于0x10,否则 malloc 内部直接就走别的分支了。
  2. old_size必须小于等于原来的真实 top chunk size,否则old_size <= system_mem不成立,assert 不会失败。
  3. old_size必须满足对齐要求,glibc 会检查(old_size & (MALLOC_ALIGNMENT - 1)) == 0。64 位下MALLOC_ALIGNMENT通常是0x10
  4. 最关键的一点:old_sizePREV_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 一定是类似0xc010xd010xe01这种“页对齐-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,实际读取的_modemain_arena + 88 + 0xbcvtablemain_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指到任意可读内存。最简单的做法是:

  1. 在堆上伪造一个_IO_jump_t结构体。
  2. 在偏移0x18处放system的地址(因为_IO_OVERFLOW_IO_jump_t中偏移通常是3 * sizeof(void*)= 0x18)。
  3. 让伪FILEvtable指向这个伪造结构体。

第一个参数是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 配置好,配合pwndbggef,能省不少事。

首先要做的是信息泄露。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_arenainfo 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 corruptionfake chunk 的 size 或 fd/bk 布局错误在 gdb 中用heap bins查看当前空闲块,确认 fake chunk 的头部地址和值
assert 不触发,程序正常分配fake top size 是页对齐的,走了 mmap 分支把 size 改成0xc010xd01这类值,避免页对齐
_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,然后单步看fpfp->_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,让程序下一次printfputs把内存内容打出来。这个做法在堆题里叫“FSOP 泄露”,配合 House of orange 能让整个利用链更顺滑。多试几次,你会发现IO_FILE这套东西玩熟了之后,看很多堆题都是同一个套路,只是包装不同而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询