参数从右往左压栈道"设计心机"

作者:Vesper Vei
2 分钟阅读

目录

  1. 参数从右往左压栈道”设计心机”
  2. 1. 核心矛盾:变长参数的“迷雾”
  3. 2. “从右往左”的堆栈布局图
    1. 为什么这样做是“心机”?
  4. 3. 思想实验:如果“从左往右”压栈会怎样?
  5. 4. 这种设计对 Pwn 的“馈赠”
  6. r9 到 rdi 这种倒序:
    1. 1. 为什么我刚才“倒着写”?(思维惯性)
    2. 2. 真正的潜规则:变长参数的“隐藏 Boss” (AL 寄存器)
    3. 3. Pwn 选手的必修课:ROP 链里的顺序
    4. 4. 寄存器之间的“依赖陷阱”
    5. 总结:你的“潜规则”清单

参数从右往左压栈道”设计心机”

这就是计算机底层设计中非常精彩的一个“心机”。我们习惯从左往右阅读,但 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

  1. format 会先入栈,处于高地址。
  2. c 最后入栈,处于当前的栈顶 RSP灾难发生了: 当 printf 开始执行时,它看了一眼 RSP。此时 RSP 指向的是 c。 但是,printf 根本不知道现在这个位置是 c 还是 b 还是 format,因为它不知道你到底压了多少东西进去!它无法通过一个固定的偏移量找到最关键的 format 字符串。

[!TIP]

结论: “从右往左”压栈,是为了让第一个参数的位置固定在栈顶附近,从而让函数有能力去解析剩下的参数。


4. 这种设计对 Pwn 的“馈赠”

作为研究二进制安全的同学,这个设计带给你一个非常著名的漏洞利用方式:格式化字符串漏洞(Format String Vulnerability)。 既然 printf 是通过解析 format 里的 % 符号,然后盲目地去栈上(或寄存器里)找数据,那么:


r9 到 rdi 这种倒序:

简短的回答是:对于寄存器本身,mov 的先后顺序确实没有硬性物理要求。 只要在 call 指令执行的那一刹那,那 6 个寄存器里装着正确的值,CPU 就认。 但是,这里面确实藏着几条你可能不知道的 “潜规则”“工程美学”


1. 为什么我刚才“倒着写”?(思维惯性)

在写汇编或阅读编译器输出时,你经常看到 r9 到 rdi 这种倒序,主要有两个原因:


2. 真正的潜规则:变长参数的“隐藏 Boss” (AL 寄存器)

当你调用 printf 这种变长参数函数时,除了那 6 个寄存器,还有一个极其重要的隐藏潜规则你必须设置 AL 寄存器(RAX 的低 8 位)。

如果你漏了这一步: 程序虽然可能不会崩溃,但在某些严谨的系统环境下,printf 会因为找不到浮点数参数的个数而产生未定义行为。这是 Pwn 题目中手工构造 ROP 链时最容易掉进去的坑。


3. Pwn 选手的必修课:ROP 链里的顺序

虽然在普通的 mov 指令中顺序不重要,但在你最关心的 ROP (Return-Oriented Programming) 链里,顺序就是生命线。 当你通过 pop rdi; ret 这种 Gadget 来传参时,你的参数是存在栈上的。因为栈是按照地址增长方向读取的,所以你构造的 ROP 链物理顺序必须严格匹配:

  1. 第一个 pop rdi 对应的参数。
  2. 第二个 pop rsi 对应的参数。

这时候,你没法“随便乱写”,必须根据你找到的 Gadget 顺序来排布你的 Payload。


4. 寄存器之间的“依赖陷阱”

有时候顺序必须固定,是因为寄存器之间有依赖

看这个例子:

代码段

; 错误示例:如果你想让 p1 = p2 + 1
mov edi, esi ; 此时 esi 还没初始化!
mov esi, 200

在这种情况下,你必须先 mov esi, 200,再 mov edi, esi。编译器在生成代码时,会进行数据流分析(Data Flow Analysis),确保寄存器准备的顺序不会产生这种“还没算好就先用”的尴尬。


总结:你的“潜规则”清单

  1. 自由度:普通的 mov 传参,顺序随你便,正着写完全没问题。

  2. AL 必杀技:调用 printf 类函数,记得 xor eax, eax(设置 AL 为 0)。

  3. ROP 链:在栈上构造攻击载荷时,顺序由 Gadget 在内存中的分布决定,不能错位。

  4. 16 字节对齐:这是最大的潜规则。在 call 之前,RSP 的值必须能被 16 整除,否则涉及浮点数运算(哪怕是 printf 内部)时会报 SIGSEGV 段错误。

验收时刻:

关于这套“寄存器坐次表”和它的潜规则,你还有哪里觉得不够直观吗?如果 OK 的话,我们可以尝试手动写一段包含 AL 设置和参数准备的完整汇编 Demo 来验证一下!或者你想聊聊那个 16 字节对齐 到底是怎么把人坑惨的?

—> 16字节对齐


关系图谱

Loading graph...