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的回答贴出来
为什么加一个 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 的常见坑。

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