参数从右往左压栈道"设计心机"
目录
- 参数从右往左压栈道”设计心机”
- 1. 核心矛盾:变长参数的“迷雾”
- 2. “从右往左”的堆栈布局图
- 3. 思想实验:如果“从左往右”压栈会怎样?
- 4. 这种设计对 Pwn 的“馈赠”
- r9 到 rdi 这种倒序:
参数从右往左压栈道”设计心机”
这就是计算机底层设计中非常精彩的一个“心机”。我们习惯从左往右阅读,但 CPU 偏偏要从右往左压栈,这完全是为了解决一个逻辑悖论:如果我不知道你有多少个参数,我该怎么找到第一个参数?
1. 核心矛盾:变长参数的“迷雾”
想象一下 printf 函数的定义:
int printf(const char *format, ...);这里的 ... 代表参数个数是不确定的。当你调用 printf("%d %f %s", a, b, c) 时,函数本身在刚开始运行的那一刻,其实并不知道你传了 3 个参数还是 300 个参数。
它唯一确定知道的,是第一个参数——格式化字符串(format string)。
2. “从右往左”的堆栈布局图
假设我们通过栈传递 3 个可选参数(为了演示方便,暂不考虑寄存器传参的干扰):
printf(format, a, b, c)
如果按照从右往左(c -> b -> a -> format) 压栈,栈的生长方向是向下的(从高地址到低地址):
| 内存地址 | 存放内容 | 备注 |
|---|---|---|
| 高地址 | c | 最后一个参数 |
| b | ||
| a | ||
| 低地址 (RSP) | format | 第一个参数(确定的) |
为什么这样做是“心机”?
在这种布局下,无论你后面传了多少个参数,format 永远就在 [RSP](或返回地址上方固定的位置)
printf 进场后,第一眼就能精准捕捉到 format。然后它开始解析字符串,看到一个 %d,它就知道:“哦,往高地址挪 8 个字节就能找到一个整数”;看到一个 %s,再往高地址挪。
3. 思想实验:如果“从左往右”压栈会怎样?
如果顺序是 format -> a -> b -> c:
format会先入栈,处于高地址。c最后入栈,处于当前的栈顶RSP。 灾难发生了: 当printf开始执行时,它看了一眼RSP。此时RSP指向的是c。 但是,printf根本不知道现在这个位置是c还是b还是format,因为它不知道你到底压了多少东西进去!它无法通过一个固定的偏移量找到最关键的format字符串。
[!TIP]
结论: “从右往左”压栈,是为了让第一个参数的位置固定在栈顶附近,从而让函数有能力去解析剩下的参数。
4. 这种设计对 Pwn 的“馈赠”
作为研究二进制安全的同学,这个设计带给你一个非常著名的漏洞利用方式:格式化字符串漏洞(Format String Vulnerability)。
既然 printf 是通过解析 format 里的 % 符号,然后盲目地去栈上(或寄存器里)找数据,那么:
- 如果你输入一个
%p %p %p %p作为format。 printf就会乖乖地把栈上原本不该给你看的数据(比如返回地址、金丝雀值 Canary、甚至是函数指针)全部打印出来。 这就是因为printf过于信任format提供的“索引指南”,而“从右往左”的压栈规则保证了它能永远找到这份“指南”。
r9 到 rdi 这种倒序:
简短的回答是:对于寄存器本身,mov 的先后顺序确实没有硬性物理要求。 只要在 call 指令执行的那一刹那,那 6 个寄存器里装着正确的值,CPU 就认。
但是,这里面确实藏着几条你可能不知道的 “潜规则” 和 “工程美学” :
1. 为什么我刚才“倒着写”?(思维惯性)
在写汇编或阅读编译器输出时,你经常看到 r9 到 rdi 这种倒序,主要有两个原因:
- 栈的延续感:在 32 位时代,参数必须通过
push进栈,而栈又是从右往左压的。很多老牌程序员或编译器在写 64 位汇编时,潜意识里会保留这种“先处理最后一个,最后处理第一个”的节奏。 - 保护 RDI/RSI:在复杂的函数中,
RDI和RSI(第一、二个参数)通常最重要(比如是字符串的首地址)。把它们放在最后mov,可以防止在准备其他参数(比如计算第 5 个参数的复杂逻辑)时,不小心把已经算好的RDI给覆盖掉。
2. 真正的潜规则:变长参数的“隐藏 Boss” (AL 寄存器)
当你调用 printf 这种变长参数函数时,除了那 6 个寄存器,还有一个极其重要的隐藏潜规则:
你必须设置 AL 寄存器(RAX 的低 8 位)。
-
规则:对于变长参数函数,
AL寄存器需要存储**用到了多少个矢量寄存器(XMM 寄存器)**来传递浮点数。 -
实战:如果你只是打印普通整数或字符串(没用到浮点数),你必须执行
xor eax, eax(即让AL = 0)。
如果你漏了这一步: 程序虽然可能不会崩溃,但在某些严谨的系统环境下,printf 会因为找不到浮点数参数的个数而产生未定义行为。这是 Pwn 题目中手工构造 ROP 链时最容易掉进去的坑。
3. Pwn 选手的必修课:ROP 链里的顺序
虽然在普通的 mov 指令中顺序不重要,但在你最关心的 ROP (Return-Oriented Programming) 链里,顺序就是生命线。
当你通过 pop rdi; ret 这种 Gadget 来传参时,你的参数是存在栈上的。因为栈是按照地址增长方向读取的,所以你构造的 ROP 链物理顺序必须严格匹配:
- 第一个
pop rdi对应的参数。 - 第二个
pop rsi对应的参数。
这时候,你没法“随便乱写”,必须根据你找到的 Gadget 顺序来排布你的 Payload。
4. 寄存器之间的“依赖陷阱”
有时候顺序必须固定,是因为寄存器之间有依赖。
看这个例子:
代码段
; 错误示例:如果你想让 p1 = p2 + 1mov edi, esi ; 此时 esi 还没初始化!mov esi, 200在这种情况下,你必须先 mov esi, 200,再 mov edi, esi。编译器在生成代码时,会进行数据流分析(Data Flow Analysis),确保寄存器准备的顺序不会产生这种“还没算好就先用”的尴尬。
总结:你的“潜规则”清单
-
自由度:普通的
mov传参,顺序随你便,正着写完全没问题。 -
AL 必杀技:调用
printf类函数,记得xor eax, eax(设置AL为 0)。 -
ROP 链:在栈上构造攻击载荷时,顺序由 Gadget 在内存中的分布决定,不能错位。
-
16 字节对齐:这是最大的潜规则。在
call之前,RSP的值必须能被 16 整除,否则涉及浮点数运算(哪怕是printf内部)时会报SIGSEGV段错误。
验收时刻:
关于这套“寄存器坐次表”和它的潜规则,你还有哪里觉得不够直观吗?如果 OK 的话,我们可以尝试手动写一段包含 AL 设置和参数准备的完整汇编 Demo 来验证一下!或者你想聊聊那个 16 字节对齐 到底是怎么把人坑惨的?
—> 16字节对齐