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:原函数执行
leave,ESP瞬移并弹出老EBP。此时ESP恰好指着system的地址。 - 3:原函数执行
ret(即pop eip)。system被弹入指令寄存器,CPU 开始执行system。 - 4:
system以为自己是被正常调用的,它自动越过ESP+4(即假的返回地址),去ESP+8(即 ebp + 0x0C)的位置拿到了 "/bin/sh"
- 1:你的输入填满
x64(64位)传参原则:优先“寄存器”
- 为了速度,x64 规定前 6 个参数必须放在指定的寄存器里。
寄存器传参顺序:
RDI, RSI, RDX, RCX, R8, R9- 参数 1:
rdi(比如存放"/bin/sh"的地址) - 参数 2:
rsi - 参数 3:
rdx - ...依此类推。如果超过 6 个参数,第 7 个才开始往栈上放。
- 参数 1:
- 结构:
==== 低地址 (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 开始跑
system,system去看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函数翻转了!
- 1.
- 那么在回到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,试一下立马就能解决。
- 做题的时候都试试吧,刚刚给我整破防了
评论已关闭