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的回答贴出来
为什么加一个 ret gadget? x86-64 System V ABI 要求执行 call 前 RSP 16 字节对齐。
secret_contract 正常返回后 RSP%16==0,若直接跳abyss_gateway 开头(有 push rbp),
进入 call system 前 RSP%16 会变成 8,不对齐,system 内部的 movaps 会段错误。
垫一个 ret(pop 8 字节)后 RSP%16变 8,再进 abyss_gateway,push rbp 后变 0,
call system 时正好对齐。这是 ret2text 的常见坑。
- 做题的时候都试试吧,刚刚给我整破防了
评论已关闭