第3章:栈上的格式化字符串:参数传递机制
格式化字符串漏洞,说白了就是攻击者控制了格式化函数的格式参数。但很多人只停留在「用 %x 读栈」的层面,对参数到底怎么从栈上取、取多少、顺序如何,其实是一笔糊涂账。
我刚开始接触这个漏洞时也踩过坑。明明看到栈上有数据,但 %x 读出来的值就是不对。后来才明白——参数传递机制没搞透。这一章,我们就把它彻底讲清楚。
3.1 格式化函数的参数传递约定
先看一个最基础的例子:
printf("Hello, %s, you are %d years old.\n", name, age);
这里 "Hello, %s, you are %d years old.\n" 是格式字符串,name 和 age 是可变参数。问题是:printf 怎么知道这些参数在哪儿?
答案很简单——栈上。在 x86 32 位架构下,所有可变参数都通过栈传递。格式字符串本身也放在栈上(如果它是局部变量的话)。
具体来说,printf 内部会维护一个指针,指向当前要读取的参数位置。每遇到一个 % 格式符,它就按类型从栈上取一个值,然后指针后移。
核心要点:格式化函数不检查参数个数是否匹配。你写 10 个 %x,它就读 10 次栈。这就是漏洞的根源。
3.2 参数在栈上的布局
假设我们调用:
printf("Format: %x %x %x\n", 0x41, 0x42, 0x43);
栈上的布局大致如下(从低地址到高地址):
| 栈地址(低→高) | 内容 | 说明 |
|---|---|---|
| ESP + 0 | 格式字符串地址 | 指向 "Format: %x %x %x\n" |
| ESP + 4 | 0x00000043 | 第3个可变参数(0x43 = 'C') |
| ESP + 8 | 0x00000042 | 第2个可变参数(0x42 = 'B') |
| ESP + 12 | 0x00000041 | 第1个可变参数(0x41 = 'A') |
注意顺序:参数是从右向左压栈的。所以第一个可变参数(0x41)反而在栈顶附近。
但这里有个陷阱——第一个 %x 读的不是 0x41。为什么?
因为 printf 内部实现里,格式字符串本身也算一个参数。实际上,printf 的调用约定是:
- 第1个参数(固定参数):格式字符串指针
- 第2个参数(可变):第一个可变参数
- 第3个参数(可变):第二个可变参数
- ……以此类推
所以 %1$x 读的是第一个可变参数,%2$x 读第二个。但如果你直接用 %x 不加位置参数,它会从第一个可变参数开始依次读取。
我曾经踩过的坑:在调试一个 CTF 题目时,我用了 10 个 %x 想读栈上的 flag,结果读出来的全是 0x0804xxxx 这种地址值。后来才发现——我忘了格式字符串本身也在栈上,前几个 %x 读的是格式字符串的地址,而不是我想要的 flag 数据。
3.3 位置参数:直接访问指定参数
GNU C 库的 printf 支持位置参数语法:%n$ 格式。这让我们可以直接访问第 n 个可变参数。
printf("%2$x %1$x\n", 0x41, 0x42);
// 输出:42 41
这里 %2$x 直接读第二个可变参数(0x42),%1$x 读第一个(0x41)。
这个特性在漏洞利用中极其重要。为什么?
- 你可以跳过前面的参数,直接读栈上任意位置
- 你可以用
%n$s把某个栈地址当作指针来解引用 - 你可以用
%n$n往某个栈地址写入数据
实战技巧:在 CTF 中,我习惯先用 %p %p %p %p %p %p %p %p 快速扫描栈上的指针,找到目标数据所在的偏移量,然后用 %n$s 精确读取。
3.4 参数偏移量的计算
这是最容易被搞混的地方。假设格式字符串本身也在栈上(比如它是一个局部 char 数组),那么参数偏移量怎么算?
看一个实际场景:
char buf[100];
fgets(buf, sizeof(buf), stdin);
printf(buf); // 漏洞点
此时栈上的布局:
- printf 的返回地址
- printf 的保存的 EBP
- 格式字符串指针(指向 buf)
- buf 的内容(即我们输入的格式字符串本身)
- ……其他局部变量
如果我们输入 AAAA%x %x %x %x,那么:
- 第1个 %x:读的是格式字符串指针(指向 buf 的地址)
- 第2个 %x:读的是 buf 的前 4 字节(即 "AAAA")
- 第3个 %x:读的是 buf 的第 5-8 字节
- ……
所以,buf 的内容从第 2 个可变参数开始。这意味着:
输入:AAAA%2$x
输出:AAAA41414141 // 0x41 = 'A'
这个偏移量(2)在不同环境下可能不同,取决于编译器、优化选项、函数调用深度。我一般用 %p 先扫一遍,找到自己输入数据的位置,再确定偏移。
3.5 不同架构下的差异
上面讲的主要是 x86 32 位。但我们的课程是《异架构Pwn》,所以必须看看其他架构。
| 架构 | 参数传递方式 | 对格式化字符串的影响 |
|---|---|---|
| x86 (32-bit) | 全部栈传递 | 参数从 ESP+4 开始,偏移容易计算 |
| x86-64 | 前6个参数用寄存器(RDI, RSI, RDX, RCX, R8, R9),其余栈传递 | 格式字符串在 RDI,可变参数从 RSI 开始。栈上的参数从第7个开始 |
| ARM (32-bit) | 前4个参数用寄存器(R0-R3),其余栈传递 | 格式字符串在 R0,可变参数从 R1 开始 |
| AArch64 | 前8个参数用寄存器(X0-X7),其余栈传递 | 格式字符串在 X0,可变参数从 X1 开始 |
在 x86-64 下,如果你用 %x 读参数,前几个读的是寄存器里的值,而不是栈上的。这会导致偏移量计算完全不同。
避坑指南:我曾经在 x86-64 的 CTF 题目里,用 32 位的思路去算偏移,结果怎么都读不到目标数据。后来才意识到——前 6 个参数在寄存器里,栈上的数据要从第 7 个参数开始算。这个教训让我养成了一个习惯:拿到题目先确定架构,再确定参数传递方式。
3.6 知识结构图
下面这张图总结了格式化字符串参数传递的核心逻辑:
3.7 实战:确定偏移量的标准流程
说了这么多理论,来点实际的。假设你拿到一个 x86 32 位的程序,有一个格式化字符串漏洞。你怎么确定目标数据在哪个参数位置?
我一般按这个流程走:
- 发送标记字符串:输入
AAAA%p.%p.%p.%p.%p.%p.%p.%p - 观察输出:找到
0x41414141出现的位置(0x41 = 'A') - 确定偏移:如果
0x41414141在第 n 个 %p 出现,那么偏移量就是 n - 验证:输入
AAAA%n$x,看输出是不是AAAA41414141
小技巧:我习惯用 %p 而不是 %x 来扫描,因为 %p 会输出完整的 8 字节(64位)或 4 字节(32位),而且带 0x 前缀,更容易识别指针值。
确定偏移后,你就可以用 %n$s 读任意地址的内容,或者用 %n$n 往任意地址写数据了。这些技巧我们会在后面的章节详细展开。
嗯,参数传递机制就讲到这里。记住一句话:格式化字符串漏洞的本质,就是攻击者控制了参数的数量和类型,让 printf 从栈上读(或写)了本不该被访问的数据。搞懂了参数怎么传、偏移怎么算,你就掌握了这个漏洞的钥匙。