格式化字符串漏洞全面总结
格式化字符串漏洞是 Pwn 中一种极其灵活、威力巨大的漏洞。它不需要栈溢出,只要开发者在使用输出函数时“偷了懒”,攻击者就能利用它实现任意内存读取和任意内存修改。
基础知识(格式化字符串):
这里我们了解一下格式化字符串的格式,其基本格式如下
%[parameter][flags][field width][.precision][length]type
| 组成部分 | 符号与写法 | 含义与实战场景 (算法/Pwn) |
|---|---|---|
| parameter | n$ |
(Pwn 绝对核心) 取第 n 个参数。例如 %6$p |
| flags | - |
左对齐。默认是右对齐,算法竞赛打印矩阵常用。 |
+ |
强制显示正负号(正数前加 +)。 |
|
0 |
空位用 0 填充(默认用空格)。算法:打印时间 09:05 用 %02d;Pwn:用 %012d 快速造出特定长度的数据。 |
|
# |
八进制前自动加 0,十六进制前自动加 0x。 |
|
| width | 数字 |
最小输出宽度。例如 %5d,即使数字是 1,也会输出 4 个空格 + 1 个 1。 |
| .precision | .数字 |
(算法高频) 浮点数保留几位小数(会自动四舍五入),如 %.5f。如果是给字符串用 %.5s,则代表最多只输出 5 个字符。 |
| length | hh / h |
修饰长度:hh 是单字节 (char),h 是双字节 (short)。Pwn 中配合 %n 极其常用 (%hhn),用于逐字节修改大地址。 |
l / ll |
修饰长度:l 是 long,ll 是 long long。 |
|
z |
用于 size_t 类型数据,在 64 位下等价于无符号 64 位整型。 |
|
| type | d/i, u |
十进制有符号整数 / 无符号整数。 |
f, lf |
浮点数 (float 用 %f,double 用 %lf)。 |
|
x/X, o |
16 进制小写/大写,8 进制无符号数。 | |
s, c |
字符串,单个字符。 | |
p |
打印内存地址指针 (自带 0x 前缀)。Pwn用于泄露栈上地址 %p。 |
|
n |
不输出字符,而是将目前为止成功打印的字符总数,写入对应的指针变量中 |
Pwn 漏洞利用核心组合
在 Pwn 领域,我们把上面的参数像魔法一样组合,化身为内存操控工具。
核心组合一:基础读取与参数定位 (
parameter+type)例子:
%6$p实战含义:直接打印出栈上第 6 个参数的值。这让我们能够越过前面的垃圾数据,精准泄露远处的某一个地址。
核心组合二:精准控制写入长度 (
parameter+length+type)例子:
%8$hhn实战含义:向栈上第 8 个参数所指向的地址里,仅仅写入一个单字节的数据。由于一次性用
%n写几百万的大数字会导致程序崩溃或等待极长,使用%hhn逐字节写入是改写大地址的最高端打法。
核心组合三:用宽度造数据,用 %n 写内存 (
width+parameter+n)例子:
%012d%6$n(结合你的w_stack_addr.py笔记)执行逻辑:先执行
%012d,强制printf在屏幕上打印了 12 个字符。紧接着遇到%6$n,printf内部计数器数了一下刚才一共打印了 12 个字符,于是把数字12(即十六进制的0xc) 写入到了第 6 个参数指向的内存地址中。
【泄露速查表】到底哪些格式符会“解引用”?
解引用的意味着它把参数当指针处理,否则意味着它只把参数当数值处理。
| 格式符 | 是否解引用 | 作用原理 | 漏洞利用用途 |
|---|---|---|---|
%s |
是 | 读取指针指向的字符串 | 泄露远端内存内容(如泄露 GOT 表) |
%n |
是 | 向指针指向的位置写入总字符数 | 修改内存(如劫持 GOT 表、改判断标志) |
%hn |
是 | 同 %n,但只写 2 字节 |
精确修改内存 (配合分批写入绕过崩溃) |
%hhn |
是 | 同 %n,但只写 1 字节 |
更精确修改内存 (fmtstr_payload的底层核心) |
%p |
否 | 原样输出指针所在的数值 | 寻找参数偏移、泄露栈上残留地址 |
%d |
否 | 原样输出整数 | 配合控制输出宽度 (%012d) 辅助 %n 写数字 |
%x |
否 | 原样输出十六进制 | 同 %p,用于寻找参数偏移 |
%c |
否 | 原样输出字符 | 也是用于控制输出宽度 |
格式化字符串漏洞:
漏洞的根源在于:程序将用户可控的输入,直接作为了格式化字符串函数的参数。
经典漏洞代码:
1 | char buf[100]; |
除了 printf,这类函数还包括:sprintf, fprintf, snprintf, vprintf 等。只要你能控制这些函数的第一个参数(格式化字符串),就能触发该漏洞。
格式化字符串的四种核心用途:
格式化字符串漏洞可以精准划分为四大实战用途:
用途一:泄露栈上数据 / 寻找参数偏移
这是利用漏洞的第一步,通过输入特定的格式符,强迫 printf 打印出栈上的内容,从而找到我们输入的 payload 在栈上的第几个参数位置。
方法:输入 AAAA.%x.%x.%x.%x.%x.%x... 或 AAAA.%p.%p.%p...
原理:%x 或 %p 会依次打印出栈上的值。当我们看到输出中出现了 41414141 (AAAA 的十六进制),数一下它是第几个打印出来的,这就是偏移量 (offset)。
示例:如果输出是 deadbeef.0.1.41414141.2.3,41414141 是第 4 个被打印的,说明偏移量就是 4。如果你想用 %s 泄露,这里就用 %4$s
用途二:读取任意地址内存
找到偏移后,我们可以配合 %s 格式符,读取任意给定内存地址里的内容。常用来泄露 libc 基址或 GOT 表真实地址。
方法:利用 %[offset]$s 格式符。
实战代码 (read_stack_data.py & r_every_mem.md):
1 | # 假设偏移是 6,要泄露 0x0804a020 地址内的数据 |
用途三:改写栈上变量 / 写小数据
利用 %n 格式符,printf 不仅能“读”,还能“写”。%n 会将目前为止成功打印的字符个数,写入到对应的指针地址中。
方法:构造特定长度的打印输出配合 %n。
实战代码 (w_stack_addr.py):
1 | # 假设我们要把栈上的某个地址修改为数值 16 (0x10),偏移是 6 |
用途四:覆写任意地址 / 劫持 GOT 表获取 Shell
由于一次性打印几百万个字符会导致程序崩溃,所以我们通常利用 pwntools 的大杀器 fmtstr_payload 或者按字节分批写入 (%hhn)
一般格式化字符串没办法获取shell,但是可以借助got表来改写printf为system,再输入/bin/sh即可获取shell。
劫持 GOT 获取 Shell 实战 (fmt_getshell.md):
1 | from pwn import * |
fmtstr_payload 生成的 Payload 到底有多长?如果程序输入限制在 50 字节以内怎么办?
32位系统下:大约需要 40 ~ 50 字节。
64位系统下:通常需要 80 ~ 120 字节!
Pwntools 提供了一个黄金参数:write_size='short'
1 | # 默认写法 (非常长): |
用途五(进阶):劫持 .fini_array 实现无限循环 (破除 One-Shot 限制)
使用场景:在很多高难度题目中,程序只调用了一次 printf,然后就直接 exit() 或者 return 0 退出了。但是拿 Shell 往往需要两步(第一步泄露 libc,第二步劫持 GOT 表)。一次机会怎么做两件事?
漏洞原理:在 Linux 的 ELF 可执行文件中,有一个叫 .fini_array 的段。这里面存放着一个函数指针数组。当 main 函数结束,程序准备退出时,系统会自动去遍历执行 .fini_array 里面存放的函数(用于清理善后工作)。
黑客魔法:既然我们在唯一的一次 printf 中拥有任意地址写的能力,我们不仅可以泄露 libc,还可以同时利用 %n 把 .fini_array 里的指针,强行改成 main 函数的地址!
执行后果: main 函数结束 -> 准备退出 -> 查看 .fini_array -> 发现里面写着 main 的地址 -> 再次执行 main 函数! 这样,你就硬生生地把一个“只能玩一次”的游戏,变成了一个无限死循环,想输入多少次 payload 就输入多少次
Pwntools 实战写法:
1 | # 获取 main 函数的地址 |
实战套路:栈残留盲打全流程
这是 AWDP/解题赛中,处理 64 位程序和 One-Shot(只能玩一次)题目的最高效杀招,直接跳过 %s 泄露的坑
什么时候必须去栈上找残留信息来“盲打“
One-Shot 限制:程序只有一次
printf机会就直接退出。你必须在同一次 Payload 中完成【泄露】和【续命/攻击】,此时无法通过%s发送目标地址(因为\x00截断会破坏后面的续命 Payload)。严格的长度/字符限制:你的 Payload 容不下手动塞入带有
\x00的大地址去泄露。
第一阶段:通过 GDB算偏移
目标:在栈底寻找 __libc_start_main_ret (或相关启动函数) 的地址,并算出 %n$p 偏移。
在 Pwntools 里加上
gdb.attach(io)启动程序。程序停在
printf前,在 gdb 终端输入stack 50。往下翻,盯住
0x7f...开头的地址,寻找带有__libc_start_main或__libc_start_call_main标识的行。- 示例:
27:0138│+008 0x7fffffffd6f8 —▸ 0x7ffff7c29d90 (__libc_start_call_main+128)
- 示例:
计算偏移公式:拿最左边的十六进制序号转成十进制,加上寄存器偏移(例如这里偏移是6)
- 示例:
0x27=39,39 + 6 = 45。所以目标偏移是%45$p。
- 示例:
第二阶段:算出基址后干什么
拿到
%45$p吐出来的残留地址(如0x7ffff7c29d90)后,减去它在本地 libc 里的静态偏移,得到libc_base(Libc基址)。有了基址,直接算出
system_addr = libc_base + libc.sym['system']。发动第二回合:利用
fmtstr_payload,将puts或printf的 GOT 表,直接覆盖为system_addr。
一波流打法模板
1 | from pwn import * |
实战避坑指南
架构差异传参坑 (x86 vs x64)
x86 (32位):参数全部通过栈传递。用
fmtarg工具或%x可以很直观地看到 payload 直接落在栈上。x64 (64位) :注意:前 6 个参数是通过寄存器传递的(
rdi放了格式化字符串本身,接下来 5 个参数是RSI, RDX, RCX, R8, R9)。这意味着,你的 payload 在 x64 下,偏移量至少是 6 起步(因为前 5 个坑位被寄存器占了)。
\x00 截断坑 (0字节陷阱)
这是初学者最容易死的地方:printf 处理字符串时,只要遇到 \x00 就会认为字符串结束,直接停止解析
64 位下:
payload = p64(0x401234) + b"%6$s"。因为0x401234存为 64 位时是\x34\x12\x40\x00\x00\x00\x00\x00,printf读到第四个字节\x00就停了,后面的%6$s根本不会执行正确写法:必须把包含大量
\x00的内存地址放在 payload 的最后面,把格式化符号放前面1
payload = b"%7$saaaa" + p64(0x401234) # 用 aaaa 对齐 8 字节,地址放最后
- 本文链接: http://example.com/2026/03/19/PWN/pwn_attack/format_string/fmt_string总结/
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
欢迎关注我的其它发布渠道