easy_str复现:从格式化字符串到house of corrosion的PWN利用链

easy_str复现:从格式化字符串到house of corrosion的PWN利用链 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只需要一次 scanfwrite 原语干净利落。3. 三步拿到 shell从泄露到劫持的完整利用链3.1 第一阶段用格式化字符串泄露 libc 和程序基址第一步是泄露地址。printf 格式化字符串有个天然优势%p可以按指针大小泄露栈上的值%s可以泄露指针指向的内容。但格式化字符串本身不长所以泄露目标要精挑细选。我习惯先输入%p.%p.%p...把栈上十几项全部打印出来然后根据偏移关系判断哪一项是 libc 地址哪一项是程序基址相关地址。常见的泄露目标是__libc_start_main的返回地址它在栈上的位置很固定偏移 0x240 左右减去 libc 中__libc_start_main240的偏移就能得到 libc 基址。泄露程序基址也一样找栈上指向程序段的指针通常在_start或main的栈帧附近减去对应偏移得到 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%byte1c%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 bA * 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写成setcontext61然后走 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 实现内存泄露或控制流劫持。你也可以把目标改成setcontext61附近的 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直接一把梭。