原理
格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据它解析之后的参数。
通俗来说,格式化字符串函数的作用是将计算机内存中的数据转化为人类可读的字符串格式。
一般来说,格式化字符串在利用的时候主要分为三个部分:
(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 |
以十六进制形式输出栈上的值 | %x→0xdeadbeef |
%p通常带0x前缀 |
%d/%u |
以十进制输出栈上的值 | %d→3735928559 |
|
%c |
输出栈上值的最低一个字节 | %c→'A' |
|
%s |
把栈上的值当作地址,输出该地址的字符串 | %s→"Hello" |
|
%hn |
写入 2 字节(short) | %hn→ 写入低 2 字节 |
|
%hhn |
写入 1 字节(char) | %hhn→ 写入低 1 字节 |
|
%n |
将已输出字符数写入栈上的地址 | %n→ 写入0x4到指定地址 |
|
%$p/%%$s |
指定第几个参数位置的值 | %4$p→ 第 4 个参数 |
|
%% |
输出一个%字符本身 |
%%→% |


当用户控制了 printf() 的第一个参数(即格式化字符串),而程序没有进行过滤或限制,就可能造成以下问题:
1 | char input[100]; |
此时,攻击者可以构造输入,如:
%x %x %x %x %s
程序会尝试从栈中读取参数,但由于没有提供参数列表,printf() 会继续从栈中“猜测”数据。这可能导致:
泄露栈内存内容(如返回地址、canary、函数指针等)
写入内存(通过
%n格式符)
虽然没有写任何参数,但还是会对应地解析参数,因为在格式化字符串中写了对应的解析方式,所以在后续的函数运行中会解析栈上的元
素。对于整型值和浮点值,这没有什么问题,不管栈上是什么数据都能够按照整型值和浮点值解析出来,而字符串形式就不一定能解析出来
了。如果是一个非法地址,比如NULL,那么在解析的时候就会报错,
因为这个地址按照字符串解析是无法解析出来的,程序就会崩溃。这就是格式化字符串漏洞的基本原理
利用
leakmemory(泄露栈上内容)

用了 %100s 来防止缓冲区溢出,但并没有限制用户输入的格式符内容 。也就是说:
用户可以输入
%x、%s、%n等格式符。printf会根据这些格式符从栈中“猜测”数据,从而造成信息泄露或任意地址写入。
leakmemory(泄露任意地址内存)
这里的目标设立泄露isoc99_scanf函数(scanf编译之后是isoc99_scanf在内存中的地址)。
%k$s
这里的p是可以更换的,比如更换为s就可以解析栈上的数据为字符串。
k:表示要打印的参数距离目前格式化字符串参数的距离。
因为在没有给printf提供对应的字符,但它是一个可变参数函数 ,它会从栈上依次读取数据来填充格式化字符串中的 %p、%x、%s、%n 等指令。
但你并不知道你输入的字符串在栈上的具体位置,所以你需要:
找到你的输入字符串在栈上的偏移量,才能精准控制读写的内容。

所以偏移量就是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 | from pwn import * |
printf 执行流程:
printf会从栈上读取参数;- 它看到
%4$s,会从第 4 个参数位置读取一个地址; - 这个地址是
0x804a018(你写入的 GOT 地址); - 然后从
0x804a018地址中读出一个指针(比如0xf7e01234,即scanf的运行时地址); %s会从0xf7e01234开始读取字符串,直到遇到\0;- 最终你接收到的数据中包含
0xf7e01234。
📌 总结
elf.got["scanf"]和elf.got["__isoc99_scanf"]一样吗? |
不一定,取决于编译器 |
为什么会有__isoc99_scanf? |
GCC 编译器为了支持 C99 标准引入的 |
| 怎么确认用哪个? | 用readelf -r或objdump -TR查看 GOT 表 |
| 能不能替换? | 只有 GOT 表中存在对应符号时才能替换 |
| 如果写错了会怎样? | 报错:KeyError |

函数调用完整过程
🌟 阶段 0️⃣:在 vulnfunc 里(调用 printf 之前)
这是已经在 vulnfunc 里,且栈帧已经建立好了。
栈顶在 esp,栈低在高地址方向。
1 | ↑ 高地址 |
当你刚进入 vulnfunc:
ebp→ 上一个 ebpebp+4→ 返回地址(返回到 main)ebp-xx→ vulnfunc 的局部变量esp→ 最低处,指向局部变量
🌟 阶段 1️⃣:调用 printf(format)
当 vulnfunc 调用:
1 |
|
调用者把返回地址 & 参数压入栈:
1 | ↑ 高地址 |
此时 esp 指向 printf 的返回地址,再往上是 printf 的参数。
🌟 阶段 2️⃣:进入 printf 后
在 printf 开始执行后,它建立自己的栈帧:
1 | ↑ 高地址 |
这里:
printf把当前的 ebp 压栈- 把 ebp 设置为 esp
- esp 再向下留出局部变量空间

scanf函数会把读取到的数放在栈上一个空间,然后%p会把它泄露出来
- 使用
scanf读取一个字符串(最多 100 字节),存入format缓冲区; - 然后直接将这个字符串作为参数传给
printf,即:printf(format);
overwrite(覆盖任意地址内存)


所以这道题的重点是修改几个数的值
1)将a覆盖为0x10。
2)将b覆盖为2。
3)将c覆盖为0x12345678。
其中a是局部变量,b,c是全局变量
局部变量存储在栈上,全局变量存储在数据段中(分为已初始化rodata和未初始化bss两部分)
1 | +---------------------+ <--- 高内存地址 |
a的处理(局部变量)
首先,a是局部变量,局部变量是存储在栈上的,其地址会因为开启ASLR而变化,所以在函数中将a的地址打印出来了
脚本
1 | from pwn import * |
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的总数
工作原理:
- 当
printf(payload)执行时,payload字符串会被放到栈上。 printf开始解析payload。它会把p32(a_addr)视为一个普通的字符串(但它实际上是v1的地址)。- 然后遇到
%12c,它输出 12 个字符。 - 接着遇到
%8$n。此时printf已经输出了 12 个字符。它会去栈上寻找第 8 个参数的地址。由于p32(a_addr)放在了 payload 的开头,并且其长度是 4 字节,它会正好落在栈上第 8 个参数的位置上(这个偏移量是经过调试确定的)。 printf会将当前已输出的字符数12写入到p32(a_addr)所表示的地址,也就是局部变量v1的地址。- 最终,
v1的值被修改为12。
b的处理(全局小数)

b,c的地址可以直接找到
脚本
1 | from pwn import * |
为什么不能按照覆盖 a 的方法?(因为%n,b的地址已经超过2了,所以b的地址只能在%_$n之后)
- 偏移量计算的差异: 在覆盖
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) |
让我们计算每个部分的长度:
b"aa":2 字节b"%10$n":5 字节b"b":1 字节p32(b_addr):4 字节 (假设b_addr是0x0804A028)
总字节数 = 2 + 5 + 1 + 4 = 12 字节,在这里,b是为了补足字节
c的处理(全局大数)

这是一个很大的数,考虑逐字节写入
这里想把c覆盖为0x12345678,而c的地址是0x0804A02C,所以基本目标是:
1 | 0x0804A02C:\x78 |
脚本
1 | from pwn import * |
fmtstr_payload
fmtstr_payload 是 pwntools 提供的一个自动化工具 ,用于构造格式化字符串漏洞(Format String Vulnerability)的 payload 。
它的作用是:
自动计算字符数并构造 payload,用于写入任意地址的值(如 GOT 表、变量、返回地址等)
🧪 fmtstr_payload 的使用方法
- 基本语法
1 | from pwn import * |
- 参数说明
offset |
格式化字符串偏移量(你的输入在第几个%p的位置) |
writes |
一个字典,表示你要写入的地址和值,如{addr1: value1, addr2: value2} |
numbwritten |
已经输出的字符数(默认为 0,可以忽略) |
write_size |
写入大小,可选:'byte'、'short'、'int'(默认是'byte') |
🧩 示例 1:写入一个地址为 16
1 | from pwn import * |
🧩 示例 2:写入多个地址(如 a=16, b=2, c=0x12345678)
1 | from pwn import * |
fmt_demo(格式化字符串的综合利用)


可以先泄露libc的基址,得到system函数的地址
再根据此把printf的got表存储的地址换为system函数的地址
之后输入参数“/bin/sh”,就可以实现
脚本
1 | from pwn import * |
那为什么还要填补 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:但我其实不太理解