1. 内容整体设计与思路拆解
1.1 这10道题到底在练什么
先给结论:CTFshow的Pwn入门91到100,是格式化字符串漏洞从“会看”到“会用”的跨越题组。前面几题考的是格式化字符串的泄露能力,中间几题开始考写入能力,最后几题基本上就是在考你能不能把格式化字符串当成一个“任意地址读 + 任意地址写”的组合工具来用。
我在带新手打CTF的时候经常说一句话:格式化字符串漏洞本身不难,难的是你不知道它能在实战里干多少事。它不像栈溢出那样有个明确的“覆盖返回地址”的固定套路,格式化字符串的利用方式很灵活——你可以用它泄露栈上的libc地址,可以写GOT表,可以改返回地址,可以配合栈迁移打ROP链,甚至在某些情况下能直接改掉某个关键变量让题目秒变签到题。
这10道题的设计逻辑也很有意思,它不是把一道难题拆成10道让你反复刷,而是每一题都在往里面加一个新东西。我一开始以为就是简单的重复训练,刷到后面才发现,它其实是在用题目难度梯度逼着你把格式化字符串的几种利用姿势全部过一遍。
1.2 适合谁来刷,刷完能获得什么
如果你是刚看完格式化字符串漏洞原理、但还没动手做过题的新手,这个题组非常适合你。它不需要你懂太深的逆向功底,也不需要你会用复杂的堆利用技巧,它只需要你具备三样东西:能看懂C代码、会用pwntools发数据、肯花时间调offset。
刷完这10道题,你至少能掌握以下几项能力:
- 熟练使用
%p、%s、%n、%hn、%hhn等格式化字符,知道它们各自在什么时候用、有什么副作用。 - 能够快速确定格式化字符串的偏移,这是所有利用的基础,也是新手最头疼的问题。
- 理解GOT表覆写的原理,能在PIE开启和关闭两种情况下分别完成GOT表劫持。
- 知道什么时候该用
fmtstr_payload省事,什么时候必须手动构造format string——后者才是区分入门和进阶的关键。
说实话,我在带人刷这个题组的时候,发现大多数人卡住的原因不是不懂漏洞原理,而是不知道怎么把原理转化成具体的payload构造。所以这篇博文我不会只讲“这题用fmtstr_payload就完了”,我会把手动构造的过程也拆开讲,包括怎么算偏移、怎么对齐、怎么处理截断问题。
2. 格式化字符串漏洞原理回顾与门槛知识
2.1 格式化字符串为什么会变成漏洞
先说原理,但尽量不啰嗦。printf这类函数的第一个参数是格式化字符串,后面的参数是需要被格式化的变量。正常情况下,格式化字符串里的%d、%s这些占位符应该和后面的参数一一对应。但如果程序员把用户输入直接当成格式化字符串传给了printf,比如:
printf(buf);而不是:
printf("%s", buf);那问题就来了——当printf解析到格式化字符串里的%x、%p时,它根本不知道后面没有对应的参数,它会直接从栈上取数据来填充。这就是格式化字符串漏洞的根因:参数不匹配导致的信息泄露和任意写。
用人话说就是:printf是个“按单子取货”的仓库管理员,你塞给它的格式化字符串就是取货单。正常情况下一张单子对应几件货,但如果单子上写了10个取货项但仓库那边只准备了几件货,管理员还是会按照单子上的顺序一路取下去——哪怕那些位置上根本不是给你的货。
2.2 为什么能读:从栈上“越权取货”
假设栈上的布局是这样的:格式化字符串地址存放在某个位置,它的后面是调用printf时压入的其他参数或局部变量。当我们使用%p时,printf会依次读取栈上的数据并以指针格式打印出来。
举个例子,如果格式化字符串是%p.%p.%p.%p.%p.%p.%p.%p,你就相当于让printf一口气把栈上的8个位置都“看”了一遍。这些位置里可能藏着栈地址、libc地址、canary,甚至返回地址。
这里有个关键点:格式化字符串本身所在的位置,通常也在栈上。这意味着我们可以通过精确控制offset,让某个%p去读取格式化字符串本身的某个字节段。这就是后面所有利用手法的地基——既然能读到格式化字符串本身的内容,那我们把任意地址写在格式化字符串里,再用%s去读,就实现了“任意地址读”。
2.3 为什么能写:%n的魔法
%n的作用是:把当前已经打印的字符数写入到一个指针指向的地址中。这个指针从哪里来?还是从栈上取。所以我们只要把目标地址放在栈上,并且想办法让它出现在正确的位置,再配合%n(或%hn、%hhn),就能往任意地址写入一个可控的值。
写过一次就知道了,%n写入的是“已经输出的字符数量”,这个特性让我们可以精确控制写入值——多打印几个填充字符,写入值就变大。但在实战中,直接打印几万个字符不太优雅,所以我们常用%hn(写入2字节)和%hhn(写入1字节)来减少打印量,通过分段写入实现任意值的覆盖。
2.4 利用前的必备工具:pwntools和checksec
刷CTFshow的Pwn题,最常用的工具就两个:pwntools和checksec。前者负责构造payload和交互,后者负责查看程序保护。
在一个典型的题目环境中,用checksec你会看到类似这样的信息:
$ checksec pwn91 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)看到这些字段,心里就要自动生成一个判断:
- RELRO是Partial:GOT表可写,可以考虑覆写GOT表。
- No PIE:程序加载地址固定,GOT地址和函数地址直接写死,不用泄露。
- PIE开启:就需要先泄露地址再进行利用。
- Stack: No canary found:栈溢出相关题会更好打,但格式化字符串题里canary意义不大,因为我们不打栈溢出。
我刷91-100的时候,第一件事就是把这十道题的checksec结果全部跑一遍,然后在笔记里记下每道题的保护情况。这是一个值得养成的习惯——不同的保护组合决定了完全不同的利用策略。
3. 实操过程与核心环节实现
3.1 每道题的checksec与初次观察记录
我把自己刷这10道题时的checksec结果整理了一下,大概分布是这样的(具体的地址值会因为题目更新有变化,但保护策略基本稳定):
| 题号 | 保护情况 | 备注 |
|---|---|---|
| 91 | No PIE, Partial RELRO | 入门题,泄露flag |
| 92 | No PIE, Partial RELRO | 考验基础泄露加简单写入 |
| 93 | No PIE, Partial RELRO | 开始涉及GOT表 |
| 94 | No PIE, Partial RELRO | 结合栈变量修改 |
| 95 | PIE开启, Partial RELRO | 需要先泄露地址 |
| 96 | PIE开启, Partial RELRO | 泄露+写入综合 |
| 97 | No PIE, Full RELRO | 不能改GOT,换思路 |
| 98 | No PIE, Partial RELRO | 手动构造复杂fmtstr |
| 99 | PIE开启, Partial RELRO | 需要精细布局 |
| 100 | 综合 | 收官题,综合前面所有技能 |
当然这是我在自己刷题时记录的样本,平台题目可能有更新,大家以自己实际跑出来的结果为准。但一个共同的规律是:这10道题都是64位程序,所以格式化字符串的offset通常在6到10之间,很少出现需要跑十几个偏移的情况。
3.2 从零开始:91题的完整利用流程
91题是我最喜欢拿来教学的题目,因为它足够简单,但又完整地展示了格式化字符串利用的全流程。
先看伪代码逻辑(我根据自己的做题记录还原,细节可能因题目版本稍有出入,但核心逻辑一致):
#include <stdio.h> #include <string.h> int main() { char buf[0x100]; setbuf(stdout, NULL); puts("Hello CTFer!"); puts("Please input your message:"); read(0, buf, 0x100); printf(buf); // 漏洞点 puts("\nBye!"); return 0; }能看到,printf(buf)直接把用户输入当格式化字符串用了,没有任何过滤。而且这个程序环境里通常已经cat /flag或者在内存里读文件,我们的目标是泄露出flag字符串。
第一步:确定偏移。我们需要知道格式化字符串在栈上的第几个参数位置。提交下面这种payload:
from pwn import * p = process('./pwn91') p.recvuntil(b'input your message:') payload = b'AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p' p.sendline(payload) print(p.recvall().decode())运行后你会看到类似这样的输出:
AAAA.0x7fffffffe280.0x7f.....(nil).0x7f....0x41414141...如果某个输出是0x41414141,那就说明格式化字符串的第6个参数位置正好对应我们的输入开头。这个例子中AAAA刚好在offset 6的位置——后面的0x41414141就是AAAA的十六进制表示。
第二步:泄露地址。既然知道了offset,我们就可以把目标地址放到格式化字符串里,然后用%s去读取。在64位程序里,一个格式化参数占8字节,所以如果我们把目标地址放在第6个参数的位置,直接用%6$s就能以字符串形式读取该地址处的数据。
第三步:泄露flag。CTFshow的题多半会把flag读进内存或直接放在栈上,所以先试试用一系列%p把栈上的数据全部dump出来:
payload = b'%p.' * 30如果flag在栈上,你会在输出的某一段看到它的ASCII码形式。如果不上栈,就需要反编译看flag的读取逻辑了。
我在刷题群里看到很多人卡在这一步,问题往往出在payload长度上。如果一次性发太多%p,某些版本的printf会因为输出缓冲过长导致数据不完整,这时候recvuntil就等不到预期内容。我的习惯是一次先发15个%p,确认输出正常后再逐步增加。
3.3 手动构造还是用fmtstr_payload?各自适合什么场景
到了94、95题之后,单纯的泄露不够了,需要往内存里写东西。pwntools提供了一个非常方便的函数:fmtstr_payload。它的用法非常简单:
from pwn import * payload = fmtstr_payload(offset, {target_addr: target_value})这个函数会自动帮你构造格式化字符串,把target_value写入到target_addr对应的地址。它内部通过%hhn分段写入,避免了打印海量字符的问题。
但是它有个很大的问题:payload体积大,且不可控。在某些限制输入长度或者有字符过滤的题目里,fmtstr_payload生成的东西往往没法直接用。另外,它默认生成的payload会很长,如果你需要同时写入多个地址,它就变得更加臃肿。
所以我的建议是:
- 题目没限制输入长度、没过滤字符:直接用
fmtstr_payload,省时间。 - 题目限制长度、有过滤、或需要精确控制写入值:手动构造。
手动构造的通用公式如下,写入0x1234到地址A、写入0x5678到地址B:
# 先安排好两个目标地址在栈上的位置 # 假设地址A在offset 6,地址B在offset 8 # 目标值:A = 0x1234, B = 0x5678 payload = b'%4660c%6$hn' # 写入0x1234给A这里4660怎么来的?0x1234的十进制是4660。%4660c会打印4660个字符,这样当前打印字符数累计到4660,然后%6$hn把它写入offset 6处的指针指向的地址。如果要连续写多个值,就需要处理进位问题——%hn写入的是2字节,后续值如果小于当前累计值,需要加上0x10000再计算差值。
这个过程第一次上手很容易绕晕,我的建议是自己拿张纸把每个写入步骤的累计字符数算一遍再敲代码,别一上来就抄网上的脚本。
3.4 进阶:GOT表覆写的完整实战记录
到了94题左右,题目开始要求修改GOT表来劫持程序流程。我以自己的刷题记录为例,还原一个典型的GOT覆写场景。
假设程序的伪代码如下:
int main() { char buf[0x100]; puts("Welcome to pwn94!"); printf("The address of printf is: %p\n", printf); read(0, buf, 0x100); printf(buf); return 0; }程序很“贴心”地把printf的真实地址直接打印出来了,这明显是让我们算libc基址,然后改GOT表让后续某个函数调用变成system("/bin/sh")。
利用思路是:
- 从输出拿到
printf的实际地址。 - 用LibcSearcher或本地libc库算出
system和/bin/sh的偏移,进而算出system的真实地址。 - 找到
printf在GOT表中的位置(这个可以用objdump -R pwn94或者readelf -r pwn94查到)。 - 用格式化字符串把GOT表中
printf条目改成system的地址。
在No PIE + Partial RELRO的情况下,GOT地址是固定的,比如0x601018。那我们的写入目标就是:
got_printf = 0x601018 system_addr = libc_base + libc.symbols['system']然后:
payload = fmtstr_payload(6, {got_printf: system_addr})发送之后,程序原本下一次调用printf的地方,实际执行的是system。如果运气好,后续代码里正好有printf(buf)这类调用,而我们的buf内容恰好是/bin/sh,那就直接拿到shell了。
这里有一个我在带新手时常说的坑:改GOT表不是改了立刻生效,而是等到该函数下一次被调用时才生效。所以你要想清楚劫持哪个函数、它的下一个调用点在哪、调用时参数是什么。如果瞎改一气,很可能程序直接崩溃。
3.5 PIE开启怎么办:先泄露再写入
到了95、96题,PIE开启了,GOT地址不再固定。这时候需要两步走:
第一步,泄露程序基址。格式化字符串可以读取栈上保存的返回地址,返回地址指向程序自身的代码段。拿到这个地址后,减去对应的偏移就得到PIE基址。
第二次交互时再用算出的基址去覆写GOT表。
我刷到95题的时候,曾经卡了很久,因为我对PIE的理解停留在“地址是随机的”这个层面,而忘了程序基址一旦确定,内部所有地址的相对偏移是固定的。所以只要泄露一次,就全部确定了。
实操中可以发送类似这样的payload:
payload = b'%7$p' # 假设offset 7位置是返回地址拿到返回地址后,减去你从反编译里看到的对应偏移:
pie_base = leaked_addr - 0x9a8 # 0x9a8是返回地址在二进制中的偏移有了基址之后,所有GOT地址都可以算出来:
got_printf = pie_base + 0x3018 # 具体偏移用objdump查后面的步骤就和No PIE的情况一样了。
3.6 不能改GOT怎么办:Full RELRO的替代方案
97题开始,题目增加了Full RELRO保护。这意味着GOT表变成只读的,你不能通过覆写GOT表来劫持流程。这时候就要换思路了。
常见的替代方案有:
- 覆写返回地址,让程序在函数返回时跳转到ROP链。
- 覆写
__free_hook或__malloc_hook(如果程序有堆操作,且libc版本较老)。 - 覆写栈上的函数指针。
- 改写某个关键栈变量,让程序的判断逻辑被绕过。
我在做97题时实际用的是覆写返回地址的方式。思路是:先用格式化字符串泄露canary和栈地址,然后把栈上保存的返回地址改成one_gadget或ROP链的地址。
这里的难点在于,格式化字符串一次只能写在栈上已知地址的位置,而返回地址在栈上的位置其实是相对固定的,只是栈地址随机。所以要先用%p泄露栈地址,再算返回地址在栈上的精确位置,最后通过格式化字符串对这个地址进行写入。
有些题目可能不允许这么复杂,如果你发现程序里有全局变量控制着关键逻辑,考虑直接改那个全局变量——我见过不少题目就是用这种方式降低难度的,注意观察。
4. 常见问题与排查技巧实录
4.1 偏移算错了怎么快速定位
这是刷题群里的日经问题。%p打出来一堆地址,但不知道哪个对应自己的输入开头。我的排查方法是:
在payload开头放一个明显的标记,比如AAAA或BBBBBBBB,然后发送:
payload = b'AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p'哪个位置的输出是0x41414141,哪个就是输入开头的偏移。注意如果标记是AAAA,在64位下读取会显示0x41414141;如果是8个A,就是0x4141414141414141,更容易辨认。
一个小细节:有些程序会调用read而不是gets来读入输入,这样字符串末尾可能没有\x00截断,导致格式化字符串后面跟着垃圾数据。建议在payload末尾加\x00来截断,避免影响输出结果。
4.2 fmtstr_payload写入失败?先检查这三件事
如果fmtstr_payload发送后程序崩溃或者什么都没发生,按顺序排查:
- 第一,offset是否填对了?
fmtstr_payload的第一个参数是格式化字符串在参数列表中的位置,不能凭感觉填。 - 第二,目标地址是否可写?如果目标地址在只读段或者GOT被RELRO保护,写入会触发段错误。
- 第三,写入的字节会不会影响程序的关键逻辑?很多新手把某个函数地址覆盖成另一个函数地址后,程序在下次调用这个函数时参数对不上,直接崩溃。
4.3printf输出太长导致交互超时
当你用%c打一大段填充字符时,输出可能有几万甚至几十万字节。如果直接用recvuntil(b'$')这种等待方式,可能会因为输出还在传输中而导致超时。
解决办法是在发送payload后,用sleep配合recv(timeout=2)来分批接收,或者用p.recvall()等着收完。还有一种做法是尽量用%hn/%hhn分段写入,让单次输出控制在几千字节以内。
4.4 手动构造时计算累计字符数老出错
前面提到,连续使用%hn时要考虑“已经打印的字符数”,因为%n写入的是累计值。如果第二个要写的值比已打印的少,就必须加上0x10000再算差值。
我用一个小工具函数来辅助计算,这在多字节写入时特别好用:
def fmt_count(current, target): # current为当前已打印字符数,target为目标写入值 if target < current: target += 0x10000 return target - current然后用%<diff>c%<idx>$hn的格式去构造每一段。如果没有这个小函数,我打98题时至少会算错三次。
4.5 在本地打通了,远程却打不通
这是所有CTF玩家都会遇到的经典问题。原因通常有两个:
- libc版本不同,导致libc基址或函数偏移对不上。解决办法是提前拿到远程环境的libc,或者用DynELF之类的动态泄露方式。
- 交互时机不对,比如远程有网络延迟,你发数据发早了,程序还没走到
read就结束了。
我自己的习惯是:本地用process打通后,先把exp脚本里的地址相关部分全部改成动态计算,再上远程打。远程每打一次就调整一下时间和接收逻辑,不要一上来就把recvuntil写死。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
%p输出没有0x41414141 | offset没找对 | 增加%p数量,或在payload开头放8字节标记 |
| 写入后程序崩溃 | 目标地址不可写或写入值格式不对 | 确认地址段权限,检查RELRO,确认value小于0x10000(用%hn时) |
| 程序二次调用函数时行为异常 | 劫持的函数不对或参数不匹配 | 反编译确认调用点参数,选参数最合适的函数来劫持 |
| 远程打不通,本地可以 | libc版本不一致或交互时序问题 | 获取远程libc,或改为动态地址推算 |
| 输出内容超长导致超时 | %c打印过多字符 | 用recvall接收,或改用%hn分段写入 |
5. 一些值得养成的做题习惯和避坑心得
5.1 先把程序跑一遍,再去看writeup
我在带新人的时候反复强调一件事:拿到题先自己跑,哪怕是随便输入一串%p,看看程序反应再去看writeup。因为writeup通常直接告诉你正确答案,而你自己跑一遍能看到程序的实际交互流程、输出的具体细节,这些是writeup里不会写的东西。
比如有些题会先在printf之前调用别的函数,把某个地址的值改了,导致你按writeup里的offset去打结果不对。这些坑写writeup的人根本不会写进去,只有自己跑过才知道。
5.2 所有地址都记下来,养成随手标注的习惯
我刷91-100时,笔记本上密密麻麻写满了GOT地址、函数偏移、offset值。到后面的题要用前面的经验时,直接翻笔记就行了。比每次重新逆一遍快太多。
5.3 看汇编比看伪代码更可靠
有些时候反编译器给出的伪代码会和实际汇编行为有细微差别,比如它可能把两次内存访问合并成一次,让你误以为某个值不需要泄露。遇到写入类题目,我强烈建议你切到汇编视图,亲眼确认GOT表项是在哪里被调用的,参数是从哪里传进去的。这个习惯在100题这种综合题里能救命。
5.4 fmtstr_payload不是万能的
说实话,我在带新人刷题时的标准流程是:先用fmtstr_payload打通,再尝试手动构造。这样既能快速拿到flag建立信心,又能反过来理解工具背后的构造逻辑。而且在真实CTF比赛中,很多时候工具生成的payload因为长度或过滤问题没法直接用,手动构造就成了必选项。所以不要因为能用工具就跳过手动的学习。
我在刷完91-100之后的最大感受是:格式化字符串漏洞的利用本质上就两件事——“定位”和“写入”。定位是确定目标地址在栈上的哪个位置能被我们控制,写入是用%n系列把要写的值精确送进去。所有题目各种花哨的玩法,都逃不过这两步。接下来的进阶方向,不管是堆题里配合格式化字符串泄露地址,还是更复杂的ROP链构造,核心思路都是一样的。
最后分享一个实战小技巧:如果你在某道题上卡了半小时以上,不妨把程序用objdump -d把整个汇编导出来,对着汇编把每一个调用printf的地方都标出来,重点看它调用前后栈上发生了什么。这个方法看起来笨,但在定位“下一次调用发生在哪、参数是什么”这类问题时特别有效,我打100题时就是这么硬啃下来的。