打PolarCTF 2023冬季赛的时候,我在这道 easy_str 上卡了挺久。题目标题里把“corrosion”拼成了“corrsion”,一开始还以为是题目自己打的错别字,结果后面才发现这题是真真切切的 house of corrosion,只是名字写错了而已。整道题把格式化字符串和堆利用串在一起,前面送一个明晃晃的格式化字符串漏洞,后面藏着一个需要认真抠堆布局的 large bin 技巧,属于那种“会者不难,难者不会”的题。
这篇文章就按我实际复现这道题的顺序来写:先讲题目结构和漏洞点,再拆解 house of corrosion 的核心原理,然后给出完整的利用步骤和 exp 关键代码,最后把我在调试中踩过的坑整理成速查表。适合有一定 PWN 基础、想搞懂 large bin attack 和 fastbin 攻击配合的选手,如果你只是想找个能直接抄的 exp,第三、四节可以直接看。
1. 题目总览与考点定位
1.1 赛题环境与基本信息
这道题是 PolarCTF 2023冬季个人挑战赛的 PWN 方向题目,难度定位在中等偏上。题目在远程环境跑在 glibc 2.27 上,这一点非常关键,因为 house of corrosion 这个技巧在 2.27 下最顺手,到了 2.31 虽然还能用,但需要额外处理一些检查,到了 2.32 引入了 safe-linking 之后,整个利用链复杂度会上升一大截。
拿到文件先做基础检查,保护情况如下:
- 64 位 ELF,开启了 PIE
- 开启了 NX,栈不可执行
- 开启了 Canary,栈保护存在
- 没有开启 Full RELRO,GOT 表可写
从保护配置就能看出来,题目并不打算让你走栈溢出那条路,Canary 和 PIE 都开了,常规的栈劫持基本是死路。正确的攻击面在堆上,但是堆上又不像很多题目那样直接给你任意大小的 malloc,而是限制得很死。这种“洞给了但路很窄”的设计,反而是比赛题最喜欢考的点。
1.2 程序结构还原与漏洞点分析
我复现时用的交互流程大概是这样的:
程序启动后会先进入一个_menu函数,这个函数会读取一段输入,然后直接printf(buf),典型的格式化字符串漏洞。而且这个格式化字符串是在主流程最开始触发的,只触发一次,所以利用的时候必须在这一发之内把该泄露的信息全拿齐,不然就只能靠堆上的手段去泄露。
打完招呼之后进入主菜单,提供增删改查四个功能:
- add:可以申请堆块,但大小被限制成两种,分别对应 0x20 和 0x80 的用户区大小
- edit:编辑指定索引的堆块内容,但是长度检查不严,可以越界写入
- delete:释放堆块,但是释放后指针没有置空,存在 UAF
- show:打印指定索引的堆块内容,可以用来泄露堆上残留的数据
组合起来看,漏洞模型很清晰:
- 格式化字符串漏洞用于泄露 libc 基址、堆地址、canary
- UAF + 编辑越界用于篡改空闲堆块的指针字段
- 堆大小虽然被限制,但可以通过伪造 size 字段,把多个小堆块合并成一个大 chunk,从而制造出 large bin 需要的尺寸
这里要注意一个细节,add 函数虽然只让你申请两种大小,但如果你申请的是 0x80 那个尺寸,实际 malloc 出来的 chunk 用户区是 0x80,chunk 头加用户区总共 0x90。这个尺寸离 large bin 的最低要求(0x400 以上)还差得远,所以必须通过溢出改 size 来构造大 chunk。
1.3 为什么是 house of corrosion
很多人看到这个题的漏洞组合,第一反应是打 unsorted bin attack 配合 fastbin attack 改__free_hook。但实际做下来会发现,这个思路在这道题里很难走通,原因有两个。
第一个原因是 add 限制太死,你申请不出大尺寸的 chunk 来打常规的 large bin attack,必须自己伪造 size 造大 chunk,这就让堆布局变得很别扭。第二个原因是程序只有一次格式化字符串,泄露之后就没有第二次机会了,如果利用链中间某一步出的偏差导致程序崩溃,你只能重新连,不能靠格式化字符串无限调试。
house of corrosion 的优势在于,它只需要一次精准的写操作来改global_max_fast,之后整个 libc 的 data 段都变成 fastbin 的“猎场”,可以在任意地址附近构造 fake chunk。这个技巧在 glibc 2.27 下非常稳定,一旦global_max_fast被改大,后面的利用就变得很宽裕。
所以这道题本质上考查的是两件事:第一,你能不能把 house of corrosion 的原理搞清楚;第二,你能不能在一个受限的堆布局里,把 large bin attack 和 unsorted bin attack 两次写操作精确地串起来。
2. 核心技术:house of corrosion 原理剖析
2.1 global_max_fast 到底是什么
在 glibc 的 malloc 实现里,global_max_fast是一个位于 libc 数据段的全局变量,它表示 fastbin 机制能处理的 chunk 最大 size。默认值是DEFAULT_MXFAST,也就是 0x80。
global_max_fast在 malloc 和 free 两个路径里都有判断逻辑:
- free 的时候,如果被释放 chunk 的 size 小于等于
global_max_fast,它会被直接放进 fastbin,而不是进入 unsorted bin - malloc 的时候,如果请求的 size 小于等于
global_max_fast,分配器会优先去 fastbin 对应的链表里找空闲 chunk
这个变量是一个全局值,它存储的是 size 大小的上限,而不是某个地址。但它有一个非常特殊的性质:它本身的地址正好位于 libc 的 data 段,和 main_arena 挨得很近。这就给了攻击者一个机会——如果能把一个很大的值写进去,fastbin 的范围就会被无限放大。
试想一下,如果global_max_fast被改成了0x7fffffffffff这样的值,那么任何小于这个值的 chunk 在 free 时都会进入 fastbin。但真正关键的不是这个,而是 fastbin 的检查逻辑里有一个条件:
if (__glibc_unlikely (chunksize_nanomask (victim) != size)) malloc_printerr ("malloc(): memory corruption (fast)");这个检查只在 fastbin 链表非空时才会校验 size。在 2.27 里,fastbin 的 fd 指针是没有加密的,也就没有 safe-linking 保护。所以你只要能在内存里伪造一个看起来像 fastbin chunk 的结构,大小落在 fastbin 范围里,分配器就会把它当作一个合法的空闲 chunk 返回给你。
house of corrosion 的整个思路,就是把global_max_fast改成一个巨大的值,然后把 libc 数据段上那些“碰巧长得像 chunk”的内存利用起来,最终实现对任意地址的分配。
2.2 第一次写:large bin attack 写 heap 地址到 global_max_fast
house of corrosion 的第一跳,是通过 large bin attack 把一个 heap 地址写到global_max_fast。这一步的目的是为了“垫一个值”,让第二次写能通过安全检查。
问题来了,为什么需要先垫一个 heap 地址?这就涉及到 unsorted bin attack 的检查条件了。
在_int_malloc处理 unsorted bin 的循环中,有这么一段代码:
bck = victim->bk; if (__glibc_unlikely (bck->fd != victim)) malloc_printerr ("malloc(): corrupted unsorted chunks");这里victim是从 unsorted bin 取出的空闲 chunk,bck是victim->bk指向的下一个 chunk。如果我们想利用 unsorted bin attack 往global_max_fast写入 main_arena 地址,就需要把victim->bk改成一个特殊的目标地址,同时保证检查通过。
正常做法是把victim->bk改成global_max_fast - 0x10,这样后续执行bck->fd = unsorted_chunks(av)时,就会在 `global_max_fast -