x86 (32位) 传参原则:全靠“栈”

  • 参数直接压入栈中。
  • 找参数的顺序:返回地址 -> 参数1 -> 参数2 -> 参数3
  • 结构:
==== 低地址 (Low Address) | 栈顶方向 ==== 
                      |
                      |  <-- 你的输入 (buf) 从这里开始写入,像倒水一样往下流
                      V
+---------------------+  <-- ebp - 0x70
|                     |
|      buf[100]       |  (这里足足有 100 个字节的空间)
|                     |  (Payload 第一部分:b'A' * 100,刚好填满这个大坑)
|                     |
+---------------------+  <-- ebp - 0x0C
|     Canary (v3)     |  (金丝雀,占 4 个字节)
|                     |  (Payload 第二部分:p32(canary),悄悄把真密码放回去)
+---------------------+  <-- ebp - 0x08
|                     |
|  对齐缝隙 / 杂物区  |  (编译器为了对齐留下的空白,占 8 个字节)
|                     |  
+---------------------+  <-- ebp + 0x00
|  旧 EBP (Saved EBP) |  (保存着上一个函数的抽屉底,占 4 个字节)
|                     |  (Payload 第三部分:b'B' * 12,无情碾压过缝隙和旧EBP)
+---------------------+  <-- ebp + 0x04
|       返回地址      |  (也就是 Return Address,占 4 个字节)
|                     |  (Payload 第四部分:p32(system_addr))
+---------------------+  <-- ebp + 0x08
|     假的返回地址    |  (system 执行完去哪?我们随便填 b'AAAA')
+---------------------+  <-- ebp + 0x0C
|  传给 system 的参数 |  (也就是 p32(binsh_addr),指引它去执行 "/bin/sh")
+---------------------+
                      |
==== 高地址 (High Address) | 栈底方向 ====
  • 执行流程:

    • 1:你的输入填满 buf,跨过 ebp,精准覆盖“返回地址”为 system
    • 2:原函数执行 leaveESP 瞬移并弹出老 EBP。此时 ESP 恰好指着 system 的地址。
    • 3:原函数执行 ret (即 pop eip)。system 被弹入指令寄存器,CPU 开始执行 system
    • 4:system 以为自己是被正常调用的,它自动越过 ESP+4(即假的返回地址),去 ESP+8(即 ebp + 0x0C)的位置拿到了 "/bin/sh"

x64(64位)传参原则:优先“寄存器”

  • 为了速度,x64 规定前 6 个参数必须放在指定的寄存器里。
  • 寄存器传参顺序RDI, RSI, RDX, RCX, R8, R9

    • 参数 1:rdi (比如存放 "/bin/sh" 的地址)
    • 参数 2:rsi
    • 参数 3:rdx
    • ...依此类推。如果超过 6 个参数,第 7 个才开始往栈上放。
  • 结构:
==== 低地址 (Low Address) | 栈顶方向 ==== 
                      |
                      |  <-- 你的输入 (buf) 从低向高写入
                      V
+---------------------+  <-- rbp - 0x20
|                     |
|      buf[32]        |  (局部变量空间,Payload 填满它)
|                     |  
+---------------------+  <-- rbp + 0x00
|  旧 RBP (Saved RBP) |  (老上司的地基,占 8 个字节)
|                     |  (Payload:b'A' * 8 碾压它)
+---------------------+  <-- rbp + 0x08
|       返回地址      |  (我们要覆盖的第一个目标,占 8 个字节)
|                     |  (填写:p64(pop_rdi_ret_addr) —— 叫搬运工)
+---------------------+  <-- rbp + 0x10
|   传递给 RDI 的值   |  (紧跟在 pop rdi 后面)
|                     |  (填写:p64(binsh_addr) —— 要搬运的货物)
+---------------------+  <-- rbp + 0x18
|      目标函数       |  (搬运完后去哪执行?)
|                     |  (填写:p64(system_addr) —— 干活的函数)
+---------------------+
                      |
==== 高地址 (High Address) | 栈底方向 ====
  • 在 64 位下,system 函数不会去栈上找参数,它只会去 RDI 寄存器里看
  • x64 ROP 链执行全流程:

    • 第一次弹栈 (ret):原函数结束,执行 ret。把 pop rdi; ret 的地址弹给 RIP。同时,RSP 向下走 8 字节,此时栈顶正对着 "/bin/sh"。
    • 搬运参数 (pop rdi):CPU 执行 pop rdi,把当前栈顶的 "/bin/sh" 拿走,塞进 RDI 寄存器。RSP 再次下移,此时栈顶正对着 system
    • 第二次弹栈 (ret):Gadget 里的 ret 执行。把栈顶的 system 弹给 RIP
    • 最终执行:CPU 开始跑 systemsystem 去看 RDI,发现参数,拿下 Shell

什么是 Gadget?

  • Gadget 是指以 ret 结尾的极短指令序列。
  • 例如:pop rdi; ret。它的存在就是为了操作一下寄存器,然后立刻把控制权(ret)交还给栈上的下一个地址。

x64的16位对齐(Ubuntu 18.04 以上的版本(也就是 Libc 版本 >= 2.27))

在 64 位 Linux 系统中,有一套严格的底层规矩叫 System V AMD64 ABI。它硬性规定了:在调用任何函数(执行 call 指令)之前,栈指针寄存器(RSP)必须是 16 字节对齐 的(也就是地址末尾必须是 0)。

为什么定这个规矩? 因为 64 位 CPU 支持非常高级的 SSE/AVX 向量扩展指令集(比如我们前面提到的 movaps)。这些指令可以一次性处理 128 位甚至 256 位的数据(常用于快速清空内存、字符串处理)。

代价是什么? 硬件层面要求,使用这些高级指令时,内存地址必须绝对对齐。如果在未对齐的栈上强行使用 movaps,CPU 硬件会直接抛出异常(General Protection Fault),表现出来就是毫不留情的 Segmentation fault。Ubuntu 18.04 之后的 GLIBC 在 system 和 printf 内部大量使用了这种优化。

  • 64 位 Ubuntu 中的 glibc 库(如 system)在执行特定指令(movaps)时,要求栈顶指针 RSP 必须是 16 的倍数(十六进制以 0 结尾)。如果不对齐,触发 Segmentation Fault (Core Dump)
  • 解决方案:ret 垫片

    • 使用 ROPgadget --binary ./pwn --only "ret" 找一个只有 ret 的地址。
    • 在进入后门函数或 system 之前,在 Payload 里加塞一个 p64(ret_addr)
  • 原理:

    • 原函数执行 leave 后:
    • 此时 RSP 刚好指向 原本的 rbp + 0x08
    • 这时候准备弹出你写的第一个目标(ret 垫片地址)
    • 执行原函数的 ret:弹出 ret_addr(0x40044e)
    • RSP 增加 8。
    • 执行那个 ret 指令:程序跳到了 0x40044e。这个指令本身又是一个 ret,它会再次把当前的栈顶弹出。

      • 动作:弹出 backdoor 地址(0x400577)。
      • RSP 变化:RSP 又增加了 8。
      • 数学计算:8+8=16
    • 进入 system

      • 因为 RSP 连续移动了两次(共 16 字节),它的末尾又变回了 0。
      • system 检查: “RSP 末尾是 0,符合。”
      • 成功拿到 Shell
    • ret 垫片刚刚执行完毕,backdoor 地址刚刚被弹出塞进 RIP时,CPU 准备执行 backdoor 第一条指令” 的那一刻的栈结构
==== 低地址 (Low Address) | 栈顶方向 ==== 
                      |
                      |  (这上面的空间已经被 pop 弹空了,变成了“废弃沙滩”)
                      V
+---------------------+  <-- 原本的 rbp - 0x0C
|                     |
|    buf 等局部变量   |  (填满了 b'A' * 12)
|                     |
+---------------------+  <-- 原本的 rbp + 0x00
|  旧 RBP (Saved RBP) |  (填满了 b'A' * 8。到这里共 20 字节)
+---------------------+  <-- 原本的 rbp + 0x08 (原返回地址)
|    ret 垫片地址     |  (第一步:原函数 ret 弹出了它,RSP 往下走一格)
+---------------------+  <-- 原本的 rbp + 0x10 
|   backdoor 地址     |  (第二步:垫片 ret 弹出了它,RSP 再往下走一格)
+---------------------+  <-- 🚨 现在的 RSP 稳稳地停在这里! 🚨
|                     |
| 远古垃圾数据 / 父栈 |  (这里是原程序栈里更早的数据,此时变成了新的栈顶)
|                     |  
+---------------------+
                      |
==== 高地址 (High Address) | 栈底方向 ====

x64 关于ret垫片的坑

  • ret2libc时,当一次payload传入后泄漏了函数地址,此时要重新回到main函数,让程序重新执行了一次 main 函数,进行栈溢出来获取bin/sh/权限,但是这里有一个汇编层面的细节:

    • 1.main 函数的第一条指令通常是 push rbp(把寄存器压入栈中)。
    • 2.这个 push 动作会消耗 8 个字节的栈空间。
    • 3.这意味着,当你第二次溢出发送 payload2 时,你的栈底指针(RSP)的奇偶性已经被 main 函数翻转了!
  • 那么在回到x64的16位对齐特征,此时8-8=0%6->0,刚刚好抵消,满足16位对齐了,所以在第二次溢出的时候不用加ret垫片
  • 这玩意很玄学,有时候payload1不加,payload2加,有时候反过来,原理有点复杂,我把AI的回答贴出来
这是一个在 64位 Linux Pwn 中非常经典、几乎每个初学者都会被折磨的终极疑问。

你遇到的这个现象,根本原因是 **Ubuntu 18.04 及以上版本(GLIBC 2.27+)的 64位系统引入了严格的栈对齐(Stack Alignment)机制**。

我来为你把背后的底层逻辑彻底扒开。别担心,只要搞懂了 `rsp` 指针的奇偶变化,以后不管是 pwn07 还是 pwn10,你都能闭着眼睛算出来要不要加垫片。

---

### 1. 核心原理:为什么需要加 `ret` 垫片?

在 64 位的 GLIBC 中,某些函数(尤其是 `system`、`printf`,有时候也包括 `puts`)内部使用了高级的 SIMD 指令(比如 `movaps`)。**`movaps` 指令在硬件层面上强制要求栈指针 `rsp` 必须是 16 字节对齐的**(即 `rsp` 的地址必须以 `0` 结尾,比如 `0x...00`)。

* **正常函数调用时:** 当你执行 `call function` 时,会将 8 字节的返回地址压入栈中。这会导致 `rsp` 减 8。因此,当进入函数的第一条指令时,`rsp` 的地址实际上是以 `8` 结尾的(比如 `0x...08`)。**这就是函数内部 `movaps` 所期望的正确初始状态。**
* **ROP 链调用时:** 我们的 Payload 是通过多个 `ret` 指令拼凑出来的,**没有经过 `call` 指令**。当我们劫持程序执行流进入 `puts` 或 `system` 时,`rsp` 的结尾往往是 `0`(差了 8 个字节)。此时一旦函数内部执行到 `movaps`,CPU 就会立刻抛出 General Protection Fault,导致段错误(**Segmentation fault / core dumped**)。

**垫片的作用:** 垫片就是一个单纯的 `ret` 指令(对应的机器码往往是 gadgets 里的 `pop_ret` 或单独的 `ret`)。它除了把 `rsp` 加上 8 以外什么都不做。**这就完美地改变了 `rsp` 的奇偶性(从 0 变成 8,或者从 8 变成 0),从而满足 16 字节对齐的要求。**

---

### 2. 为什么 pwn07 刚好和 pwn10 是完全反过来的?

这涉及到你 Payload1 执行完毕后,**是如何回到 `main` 函数的**。

#### **Pwn07 的情况:加了垫片导致后续“奇偶反转”**

1. **Payload1 (泄露):** 你加了垫片 `ret`。这使得进入 `puts` 时栈是对齐的,`puts` 成功执行并泄露地址。
2. **回到 main:** `puts` 执行完 `ret` 时,弹出了你布置的 `main_addr`。重点来了:你是通过 `ret` 跳回 `main` 的,**而不是通过正常程序的 `call main`!** 3.  **蝴蝶效应:** 因为少了一个 `call` 压入的 8 字节,导致 pwn07 第二次执行 `main` 函数时,整个栈的奇偶性**跟第一次完全是反的**。
3. **Payload2 (Get shell):** 当第二次溢出触发 ROP 时,因为前面的“奇偶反转”,此时到达 `system` 时,`rsp` 恰好已经是正确对齐的状态了。**所以 payload2 绝对不能加垫片,加了反而又错位了。**

#### **Pwn10 的情况:没加垫片,一切如常**

1. **Payload1 (泄露):** pwn10 没有加垫片,`rsp` 直接进入 `puts`。
* *你可能会问:为什么没加垫片 pwn10 的 puts 没崩溃?* * 因为并不是所有版本的 `puts` 处理短字符串时都会触发 `movaps` 指令。pwn10 的环境里,`puts` 碰巧“扛住”了未对齐的栈活了下来。


2. **回到 main:** 因为 Payload1 没加垫片,这种错位的状态返回到 `main` 后,负负得正,反而使得第二次执行 `main` 时的栈环境**和程序刚启动时一模一样**。
3. **Payload2 (Get shell):** 既然环境和原来一样,那到达 `system`(`system` 极其脆弱,只要不对齐 100% 崩溃)时肯定是未对齐的。**所以 payload2 必须加垫片来对齐栈。**

---

### 3. 如果硬要把 pwn10 按 pwn07 那样写会怎样?

你说得对:“如果按你那样说 pwn10也可以按pwn7那样加垫片”。**理论上完全可以!**

但为什么你会收到 `core dumped`?
因为如果你在 pwn10 的 Payload1 强行加了垫片,那么:

1. `puts` 会完美对齐并成功执行。
2. 但是它跳回 `main` 函数时,会导致第二次执行的 `main` 函数的栈变成了“错位”状态(就像 pwn07 那样)。
3. 如果 pwn10 的 `main` 函数内部在第二次执行时,包含了一些 `printf`、`scanf` 操作,这些操作在**错位的栈**上执行时,直接就触发 `movaps` 崩溃了!你连输入 Payload2 的机会都没有,直接 `core dumped`。

这就是为什么 pwn10 的 Payload1 **不能加**的原因:为了保证第二次回到 `main` 时,程序不会在输入 Payload2 之前就崩溃。

---

### 总结与实战经验总结

不要在脑海里死算 8 字节还是 16 字节,由于不同程序的 `main` 函数编译优化不同(比如有没有 `and rsp, -0x10` 强制对齐),静态算极其容易头晕。

**Pwn 圈有一条黄金法则(薛定谔的垫片):**

> **只要是在 64位系统下打 ROP,如果地址没写错,但打本地或远程报了 EOF / Core Dumped / Segmentation Fault,不用犹豫,直接在调用 `system` / `printf` 的 `pop rdi` 前面加一个 `ret`(垫片)。如果原来有垫片就删掉,如果没有就加上。**

这是一个二极管问题,非 0 即 1,试一下立马就能解决。

  • 做题的时候都试试吧,刚刚给我整破防了