第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);  // 漏洞点

此时栈上的布局:

  1. printf 的返回地址
  2. printf 的保存的 EBP
  3. 格式字符串指针(指向 buf)
  4. buf 的内容(即我们输入的格式字符串本身)
  5. ……其他局部变量

如果我们输入 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 知识结构图

下面这张图总结了格式化字符串参数传递的核心逻辑:

格式化字符串参数传递机制 printf("格式串", arg1, arg2, ...) 参数传递方式取决于架构 x86 (32-bit) 全部参数在栈上 格式串 = 第1个栈参数 x86-64 前6个参数在寄存器 栈参数从第7个开始 ARM (32-bit) 前4个参数在寄存器 栈参数从第5个开始 顺序读取:%x %x %x 从第1个可变参数开始依次取 位置读取:%n$x 直接访问第n个可变参数 解引用读取:%n$s 把参数当指针,读其指向的内容 关键:偏移量 = 格式串位置 + 目标数据与格式串的栈上距离

3.7 实战:确定偏移量的标准流程

说了这么多理论,来点实际的。假设你拿到一个 x86 32 位的程序,有一个格式化字符串漏洞。你怎么确定目标数据在哪个参数位置?

我一般按这个流程走:

  1. 发送标记字符串:输入 AAAA%p.%p.%p.%p.%p.%p.%p.%p
  2. 观察输出:找到 0x41414141 出现的位置(0x41 = 'A')
  3. 确定偏移:如果 0x41414141 在第 n 个 %p 出现,那么偏移量就是 n
  4. 验证:输入 AAAA%n$x,看输出是不是 AAAA41414141

小技巧:我习惯用 %p 而不是 %x 来扫描,因为 %p 会输出完整的 8 字节(64位)或 4 字节(32位),而且带 0x 前缀,更容易识别指针值。

确定偏移后,你就可以用 %n$s 读任意地址的内容,或者用 %n$n 往任意地址写数据了。这些技巧我们会在后面的章节详细展开。

嗯,参数传递机制就讲到这里。记住一句话:格式化字符串漏洞的本质,就是攻击者控制了参数的数量和类型,让 printf 从栈上读(或写)了本不该被访问的数据。搞懂了参数怎么传、偏移怎么算,你就掌握了这个漏洞的钥匙。