easy_str复现:从格式化字符串到house of corrosion的PWN利用链
2026/9/9 9:24:10 网站建设 项目流程

PolarCTF 2023 冬季个人挑战赛的 easy_str 是我当时印象很深的一道 PWN 题。表面上是格式化字符串漏洞的常规考察,结果最后一步却落在了 house of corrosion 上,属于那种“会者不难、难者不会”的题目。你如果刚入坑堆利用,或者已经能打 tcache 但一遇到 scanf 相关的技巧就发懵,那这道题值得认真复现一遍。

这道题的知识链路很清晰:通过格式化字符串泄露 libc 和栈地址,然后利用 house of corrosion 把 scanf 变成任意写原语,最后劫持 __free_hook 拿 shell。整个过程环环相扣,既考察基础漏洞分析能力,也考察对 glibc 文件结构体 FILE 的理解深度。

1. 拿到题目先别急着打:漏洞定位与保护检查

1.1 第一步永远是 checksec 和运行观察

拿到 easy_str 的二进制文件,我最先做的是 checksec,这个习惯一定要养成。CTF 里很多题目不是不会利用,而是保护策略没看清楚就盲目开打,白白浪费时间。

checksec easy_str

常见输出大概是这个形态:

Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled

不同环境跑出来的结果可能有差异,但关键信息就几个:64 位程序,PIE 开启,Partial RELRO,栈上没 canary。64 位意味着格式化字符串的参数要通过寄存器传递,偏移计算和 32 位不一样;PIE 开启意味着我们需要先泄露程序基址;Partial RELRO 意味着 GOT 表可写,如果后面想改 GOT 也不是不行,但这道题用不上。

然后把程序跑起来,看看交互逻辑。easy_str 这类题通常没有复杂菜单,要么是一个循环输入输出,要么是执行一次就退出。我印象里这道题是给了你一次 printf 输入内容的机会,然后程序还可能继续执行 scanf 等待输入。这正好是 house of corrosion 需要的条件:一个格式化字符串漏洞用来做第一轮布局,一个 scanf 用来做第二轮大范围写入。

1.2 逆向 main 函数:漏洞点到底在哪

用 IDA 或 Ghidra 打开二进制,定位到 main 函数。核心伪代码通常长这样:

int main() { char buf[0x100]; setbuf(stdin, 0); setbuf(stdout, 0); read(0, buf, 0x100); printf(buf); // 后面可能还有 scanf 或 free 相关操作 return 0; }

关键漏洞行就是printf(buf),用户输入被直接当作格式字符串输出。这是非常经典的格式化字符串漏洞,利用方式无非三种:泄露内存、写任意地址、配合栈上的已有数据做 ROP。但 easy_str 不一样的地方在于,单纯用格式化字符串去写 __free_hook 会很痛苦,因为要连续写很多字节,每写一个字节都要控制一次输出长度,稍微有点干扰就会前功尽弃。

所以这里更需要想清楚:程序有没有第二次输入?如果有 scanf,我们完全可以先只写两个关键指针,然后把剩下的写入工作交给 scanf 的缓冲区机制。这就是 house of corrosion 的核心思路。

1.3 确定格式化字符串偏移:数偏移是有技巧的

在写任何 payload 之前,一定要先把格式化字符串的偏移数清楚。我的做法是输入:

AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p

观察输出里哪一段展示了0x41414141,也就是AAAA的十六进制形式。64 位下前六个参数在 rsi、rdx、rcx、r8、r9、栈上,所以通常要在第六个参数之后才能看到输入内容。如果这道题是把输入放到栈上,那偏移一般落在 6 到 10 之间;如果输入放到了 bss 段或者堆上,偏移会更大。

我当时专门用 pwntools 的fmtstr_payload自动算过偏移,但手工确定一下更有底。后续不管是泄露还是写指针,偏移错了就全都白费。这道题的偏移我记得不是特别大,大概在 8 到 10 之间,具体要看题目给的二进制环境。建议你做题时先用暴力定位,再结合 GDB 验证。

2. house of corrosion 原理:为啥改两个指针就能随意写

2.1 先理解 scanf 在 glibc 里是怎么读数据的

很多新手只知道 scanf 会从 stdin 读数据,但不知道它底层其实依赖_IO_FILE结构体里的缓冲区。这里说的缓冲区不是你在 C 语言里定义的 char 数组,而是_IO_2_1_stdin_这个全局 FILE 对象内部的一组指针。

简单说,scanf 每次要读一个字符,调用的路径是vfscanf->_IO_getc->_IO_default_uflow->_IO_file_underflow_IO_file_underflow会检查_IO_buf_base是否为空,如果为空就分配一块缓冲区;如果不为空,就把_IO_buf_base_IO_buf_end之间的空间作为当前可用缓冲区。如果缓冲区里没有数据了,就会调用系统调用 read 从 fd 0 填充数据。

其中_IO_buf_base指向缓冲区起始地址,_IO_buf_end指向缓冲区结束地址,_IO_read_ptr指向当前读取位置。正常情况这些指针都指向 glibc 内部管理的一块堆内存。但如果我们能修改这三个指针,把它们指向任意地址,那么 scanf 再次读入数据时,就等于往任意地址写入数据。

2.2 关键点:把 _IO_buf_base 指向目标地址

house of corrosion 之所以叫“腐蚀”,我的理解是它像酸液侵蚀金属一样,把 scanf 原本的缓冲区边界腐蚀掉,让读写范围扩大到任意地址。

具体需要修改的是_IO_2_1_stdin_结构体里的三个字段:

  • _IO_buf_base:缓冲区起始地址,我们要改成目标地址 target
  • _IO_buf_end:缓冲区结束地址,改成 target + size
  • _IO_read_ptr:当前读位置,改成 target,确保后续读入从目标开始

在 64 位 glibc 2.31 环境下,_IO_2_1_stdin_的偏移是固定的,你可以通过本地 libc 计算出_IO_buf_base相对 libc 基址的偏移。不同 glibc 版本这个偏移不一样,但_IO_2_1_stdin_这个符号是导出的,直接用 pwntools 的libc.symbols['_IO_2_1_stdin_']找到起始地址,再加上结构体偏移 0x38 到 0x40 就能定位到_IO_buf_base_IO_buf_end

2.3 为什么选择劫持 __free_hook 而不是 GOT

这道题的常规目标是__free_hook。老版本 glibc 中__free_hook是一个函数指针,free 被调用时会先检查这个钩子是否为空,不为空就调用它。我们把__free_hook改成 system 地址,再让程序 free 一个内容为/bin/sh的堆块,就能执行system("/bin/sh")

选用__free_hook而不是 GOT 上的 free 表项,主要有几个考虑:第一,GOT 表项在 Partial RELRO 下虽然可写,但写入字节比较有限,而且如果程序 dump 了 libc 版本,GOT 地址可能被 PIE 随机化影响,计算麻烦。第二,__free_hook是 libc 里的固定符号,只要泄露 libc 基址就能直接算出,不依赖额外信息。第三,用 house of corrosion 写__free_hook只需要一次 scanf,write 原语干净利落。

3. 三步拿到 shell:从泄露到劫持的完整利用链

3.1 第一阶段:用格式化字符串泄露 libc 和程序基址

第一步是泄露地址。printf 格式化字符串有个天然优势:%p可以按指针大小泄露栈上的值,%s可以泄露指针指向的内容。但格式化字符串本身不长,所以泄露目标要精挑细选。

我习惯先输入%p.%p.%p...,把栈上十几项全部打印出来,然后根据偏移关系判断哪一项是 libc 地址,哪一项是程序基址相关地址。常见的泄露目标是__libc_start_main的返回地址,它在栈上的位置很固定,偏移 0x240 左右,减去 libc 中__libc_start_main+240的偏移就能得到 libc 基址。

泄露程序基址也一样,找栈上指向程序段的指针,通常在_startmain的栈帧附近,减去对应偏移得到 PIE 基址。这一步如果程序同时泄露了堆地址,也一并记录下来,后面构造 fake file 结构体时可能用得上。

在 easy_str 里,因为程序可能有循环或二次输入,所以泄露和利用可以分两轮。如果只有一次 printf 机会,那就要在一次输出里同时带出多个地址,payload 可以做成:

%<offset1>$p.%<offset2>$p.%<offset3>$p

然后用 pwntools 的正则解析输出中的十六进制值。

3.2 第二阶段:用格式化字符串写IO_2_1_stdin的指针

拿到 libc 基址后,计算_IO_2_1_stdin_的地址,以及它内部_IO_buf_base_IO_buf_end的地址。这里我通常用 gdb 配合 pwninit 或者本地 libc 文件来确定偏移。

在 glibc 2.31 里,_IO_2_1_stdin_的结构体偏移大致如下:

字段偏移
_IO_read_ptr0x0
_IO_read_end0x8
_IO_read_base0x10
_IO_write_base0x18
_IO_write_ptr0x20
_IO_write_end0x28
_IO_buf_base0x30
_IO_buf_end0x38

注意不同 glibc 版本偏移可能不同,做题前最好用p _IO_2_1_stdin_在 GDB 里确认。我们要写的是偏移 0x30 的_IO_buf_base和偏移 0x38 的_IO_buf_end

用格式化字符串写这两个字段,最稳妥的方式是%hhn逐字节写。比如目标地址是_IO_2_1_stdin_+0x30,先把它的低字节写到栈上的某个可控参数位置,然后用%hhn把这个字节写入目标内存。每写一个字节需要一次输出控制,但只需要背下来一个技巧:%hhn写入的是已经输出字符数模 256 的值,所以可以通过%c来微调。

理论上可以直接用 pwntools 的fmtstr_payload自动生成,但对于学习理解,我建议手动构造一遍:

payload = b"%<byte1>c%<pos>$hhn" + ...

注意 payload 本身也占据栈上的参数位置,如果 payload 太长,可能会影响%pos$的取值,所以需要把 payload 放在前面,用 padding 控制地址出现的位置。这个细节很容易被忽略,我第一次做的时候就是因为地址放在 payload 后面,导致偏移计算全部错位。

3.3 第三阶段:构造 scanf 输入覆盖 __free_hook

写好了_IO_buf_base_IO_buf_end,接下来就是见证奇迹的时刻。程序再次调用 scanf 读入数据时,scanf 会认为缓冲区从_IO_buf_base开始,到_IO_buf_end结束。如果我把_IO_buf_base设成__free_hook - 0x10,把_IO_buf_end设成__free_hook + 0x20,那么输入的数据会写入从__free_hook - 0x10__free_hook + 0x20的区域。

具体 payload 怎么构造呢?假设 system 地址是0x7f1234567890,那么我可以输入:

payload = b"A" * 0x10 # 填充到 __free_hook payload += p64(system_addr) # 覆盖 __free_hook 为 system payload += b"/bin/sh\x00" # 顺便写一个 /bin/sh 字符串

这样__free_hook就被覆盖成 system 了。然后找个机会让程序 free 一块数据,如果 free 的参数正好指向/bin/sh,那就直接执行system("/bin/sh")

这一步有一个关键陷阱:scanf 使用%s或者%[^\n]时,会在空白字符处停止,比如空格、换行、Tab。而 system 地址是二进制数据,可能包含\x20\x0a这样的字节。如果这些字节出现在地址里,scanf 就会提前截断,导致写入不完整。

实践中我一般这么解决:如果 system 地址没有空白字节,直接输入即可;如果有,就改用 one_gadget 或者用__free_hook写成setcontext+61然后走 ROP。不过 easy_str 这种题目大概率能用 one_gadget 一把梭,地址里常见字节不会恰好命中空白符。

3.4 第四阶段:触发 free 拿到 shell

触发层面的选择取决于题目有没有菜单。如果 easy_str 提供了 add/delete 功能,那直接调用 delete 释放一个内容为/bin/sh的堆块。如果题目没有菜单,只有一次 free 或退出时 free,那就要观察程序哪里会调用 free。

我当时做的版本里,程序在结束前会释放一块堆内存,所以只要提前把那个堆块的内容写成/bin/sh,或者利用 house of corrosion 把/bin/sh写到堆上,然后让程序在退出时 free 就达到了目的。

这里需要注意,free 的参数不能是任意地址,必须是有效堆块指针。所以如果程序只有free(ptr)且 ptr 指向的内容不可控,那需要先利用前面的任意写把该地址的内容改成/bin/sh,或者修改__free_hook后让 free 的参数刚好指向/bin/sh。很多题目会故意留一个可控的全局变量,用scanf写进去后作为 free 参数,这就很方便。

4. 实操中遇到的问题与踩坑记录

4.1 格式化字符串偏移总是差一点

这是新手最容易卡住的地方。偏移不准,泄露和写入都会乱套。我的调试方法很简单:本地起 GDB,断在 printf 调用处,用fmtarg插件或者手数参数位置,确认输入在栈上第几个参数。如果没有插件,用%p逐步打印,直到出现0x41414141,那个位置就是偏移。

还有一个常见坑:如果输入字符串放在栈上比较深的位置,而你的 payload 里又包含了地址,那么地址本身会占用参数槽位。比如你想用%10$hhn写一个地址,但 payload 里真正存放地址的位置是第 8 个参数槽,这时计算就全乱套了。建议先用AAAA-%p系列把每个槽位对应关系打印出来,再决定 payload 怎么排布。

4.2 写 _IO_buf_base 的时候,%n 的字节计算被 printf 的截断坑了

格式化字符串的%n写入的是“已输出字符数”,而不是“payload 长度”。如果你用%c控制输出长度,需要注意 printf 是否对输出长度有限制。有些题目会限制输出长度,或者输出里包含\x00导致截断。

在 easy_str 里,我用的是%hhn,每个字节单独写,这样单个字节最多写 255,既不需要关心总输出长度,也不容易触发截断。但%hhn需要精确控制每一位的输出字符数,payload 会很长。如果题目对输入长度有限制,可以考虑用%hn写两个字节,能省一半的 payload 长度。

4.3 scanf 写入时遇到空白字符,system 地址写入失败

这是 house of corrosion 最经典的一个坑。你可以用 pwntools 的send发送二进制数据,但如果本地 shell 直接把输入通过终端 echo 出来,\x0a会被转换成回车,导致 scanf 提前结束。解决办法是用管道或者p.sendlineafter发送完整 payload,并且确保 payload 中不包含空白字节。

如果真的无法避免空白字节,可以考虑把目标改成__malloc_hook,用 one_gadget 地址替换。one_gadget 地址往往比 system 地址更“干净”,不一定包含空白字节。另一个办法是分两次写,第一次把地址写到一个非目标位置,第二次利用偏移再写进去,但这样复杂度会高很多。

4.4 glibc 版本差异导致IO_2_1_stdin偏移不对

不同 glibc 版本的_IO_2_1_stdin_结构体偏移有变化。如果用错偏移,scanf 的缓冲区指针会指向错误位置,轻则利用失败,重则直接段错误。建议下载题目提供的 libc 文件,用pwninit配好本地环境,再用 gdb 验证偏移。

如果你拿到的是 glibc 2.35 甚至更高版本,__free_hook__malloc_hook可能已经不存在了。此时需要把目标改成_IO_list_all或者利用 vtable 劫持。但 2023 年冬季赛的题目环境大概率还是 glibc 2.31/2.35 之间的版本,遇到高版本时不要硬套 free_hook,先检查 libc 里面到底有哪些符号。

4.5 程序没有明显的 free 入口,拿不到 shell

有些版本的 easy_str 可能没有菜单,也没有显式 free,这时候触发点往往在程序退出时的__run_exit_handlers流程里。你可以用 gdb 在free上下断点,看看程序退出时有没有调用。如果没有,那可能就需要用exit函数触发的_IO_cleanup来劫持,那是另一套 house of corrosion 思路。

不过绝大多数情况下,只要__free_hook被改成 system,程序里随便一个 free 都能成为后门。哪怕没有/bin/sh字符串,你也可以通过 house of corrosion 在堆上写一个/bin/sh,然后让某个 free 的参数指向它。

5. 扩展思考:house of corrosion 还能用在哪些场景

5.1 不止是格式化字符串,堆溢出也能触发

house of corrosion 的前提是有任意写原语来修改_IO_2_1_stdin_的指针,不一定是格式化字符串。如果你有堆溢出,能覆盖到_IO_2_1_stdin_附近,同样可以构造出 scanf 任意写。这叫“把一次小范围写扩展成一次大范围写”,本质上是原语放大。

这类思路在大型堆题里非常常用。先说利用链的第一步往往是拿到一个受限的写原语,比如 largebin attack 或 tcache dup,一次只能改一个指针;然后利用这个原语去篡改_IO_buf_base,后续就能用 scanf 进行批量写,大大降低利用难度。

5.2 没有 free_hook 的高版本 glibc 怎么打

glibc 2.34 之后,malloc_hook、free_hook 被移除,house of corrosion 的目标就不再是 hook 了。一个常见替代方案是改写_IO_2_1_stdout_,利用 FILE 结构体和 vtable 实现内存泄露或控制流劫持。你也可以把目标改成setcontext+61附近的 gadget,把 free_hook 的“接班人”变成__malloc_assert或者_IO_wfile_jumps里的函数指针。

但万变不离其宗,house of corrosion 的核心是_IO_buf_base_IO_buf_end的可控性,只要这两个指针能改,scanf 就是天然 write 原语,甚至可以用来配合 ROP 链布置。

5.3 防御思路:为什么这类技巧能一直存在

house of corrosion 本质上是把“用户输入行为”和“FILE 内部结构”耦合在一起。防御方后来做的最多的是:

  • 限制 scanf 的输入长度
  • 在 FILE 结构体校验中增加_IO_buf_base指向堆块的合法性检查
  • 移除 __free_hook 这类公共函数指针

但 glibc 的 FILE 结构体一直在更新,很难完全堵死。我们做 CTF 的,理解这些底层机制比单纯背工具链更有价值。

最后再说一点个人体会。easy_str 这道题真正的难点不在格式化字符串本身,而在于你能不能想到把格式化字符串这个“小写能力”通过 house of corrosion 放大成“大写能力”。我当时卡了很久,直到把_IO_2_1_stdin_的源码流程理清楚,才意识到 scanf 就是题目故意留给你的“最后一根杠杆”。做 PWN 题,最快的成长方式就是慢下来看 glibc 源码,把每个字段的作用搞懂。希望这篇笔记能帮你少走一点弯路,下次再遇到 house of corrosion,直接一把梭。

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

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

立即咨询