ROP攻击:技术起源与编写实践

一、从 ret2libc 到 ROP:一段被 NX 逼出来的历史

上世纪 90 年代的漏洞利用很简单:栈溢出后把 shellcode 塞进栈里,改返回地址跳过去执行。2001 年 PaX 项目引入 NX(不可执行栈),栈上的 shellcode 再也跑不起来了。攻击者的第一反应是 ret2libc——不执行栈上的代码,而是跳转到 libc 里现成的函数,比如 system("/bin/sh")。

ret2libc 的思路是:溢出点覆盖返回地址为 system 的地址,返回地址后面跟返回参数。32 位下调用约定是参数全压栈,所以布局是 system_addr | 返回地址 | "/bin/sh" 地址,一次跳转就能弹 shell。

但 ret2libc 有个致命弱点:攻击者的目的往往不是一次函数调用,而是一连串操作——先 mprotect 把栈改成可执行,再跳回 shellcode,或者连续调用多个函数完成复杂逻辑。单个函数跳转做不到。2007 年 Hovav Shacham 的论文《The Geometry of Innocent Flesh on the Bone》给出了答案:既然不能执行新代码,那就执行已有的代码碎片。

这就是 ROP:把程序自身(含动态库)里的指令序列——以 ret 结尾的短片段,叫 gadget——串联起来。每个 gadget 做一点点事,执行完 ret 弹出下一个地址,控制流像多米诺骨牌一样沿着攻击者布置在栈上的地址链走下去。栈就是"程序计数器":ret 指令相当于 pop rip,每弹一个地址就跳到下一段。

二、gadget:ROP 的基本积木

一个 gadget 是程序中以 ret(0xC3)结尾的任意指令序列。pop rdi; ret、pop rsi; ret、mov rax, [rdi]; ret、syscall; ret 都是典型的 gadget。

为什么 ret 结尾是关键?因为 ret 的本质是 pop rip——从栈顶取 8 字节(x64)作为下一条指令地址,同时 rsp 加 8。攻击者在栈上预埋一串地址,每执行一个 gadget 的 ret,就自动"消费"一个地址并跳转。整个过程不需要栈可执行,只依赖控制流劫持(第一步拿到 RIP 控制权)。

查找 gadget 的经典工具是 ROPgadget 和 ropper:

ROPgadget --binary ./vuln --only "pop|ret"
ropgadget --binary libc.so.6 | grep "pop rdi"

x64 下前六个参数走寄存器(rdi, rsi, rdx, rcx, r8, r9),所以最常用的 gadget 是 pop rdi; ret、pop rsi; pop r15; ret 这类"参数搬运工"。栈布局形如:

[padding 到返回地址]
[pop rdi; ret] [参数1]
[pop rsi; pop r15; ret] [参数2] [垃圾]
[目标函数地址]

执行流:溢出覆盖返回地址为 pop rdi; ret → ret 跳到 gadget → gadget 把栈上参数1 弹进 rdi → 自身 ret 弹出下一地址(pop rsi; pop r15; ret)→ 继续搬运参数2 → ret 跳到目标函数。函数返回时 ret 又弹出攻击者布置的下一个 gadget 地址,链式推进。

三、编写一条完整 ROP 链

以经典的 x64 栈溢出为例,目标是用 ROP 调 execve("/bin/sh", 0, 0) 或 system("/bin/sh")。

第一步:确定偏移。 用 cyclic pattern(pwntools 的 cyclic(200))定位覆盖返回地址所需的字节数。

第二步:泄漏地址。 现代系统开 ASLR,libc 基址每次启动随机。需要先泄漏:用 puts@plt 打印 puts@got 里存的真实地址,再返回 main 重新进入漏洞函数。pwntools 写法:

payload = b"A"*offset
payload += p64(pop_rdi_ret) + p64(puts_got) + p64(puts_plt) + p64(main)

收到的输出里提取 6 字节地址,减去 libc 中 puts 的偏移,得到 libc 基址,进而算出 system、/bin/sh 的真实地址。

第三步:构造最终链。 常见两种:

  • 调 system("/bin/sh"):pop rdi; ret + binsh_addr + system_addr
  • 调 execve 更优雅:pop rdi; ret + binsh + pop rsi; pop r15; ret + 0 + 0 + pop rdx; ret + 0 + syscall; ret——需要 libc 里有 pop rdx; ret(新版 glibc 里常见)

第四步:处理栈对齐。 x64 的 movaps 指令要求 16 字节对齐,直接跳 system 常因 rsp 不对齐而 SIGSEGV。解决办法是在链里多塞一个 ret gadget 或调整 padding 让 rsp 在函数入口处 16 字节对齐。这是新手最容易卡壳的地方,症状是"明明地址都对却崩在 movaps"。

四、x86 与 x64 的差异

32 位下参数全走栈,ROP 链更简单但也更笨重:每个函数调用要在栈上铺参数,链子长得快。x64 参数走寄存器,需要 pop rdi; ret 之类的 gadget,但链子短、更精确。

另一个差异是 gadget 的可用性。32 位二进制里 ret 附近常有 pop esi; pop edi; pop ebp; ret 这种"全家桶"gadget,一次弹三个寄存器,参数布局要跟着调整。x64 则常拆成单寄存器 gadget,灵活但要求 gadget 齐全。所以写 x64 ROP 链前先用 ROPgadget 确认目标 gadget 存在,缺了就换函数或换利用思路(比如改用 one_gadget——glibc 里现成的"一条地址直接 execve"的 magic gadget)。

五、缓解措施与绕过

现代防御体系给 ROP 上了四道锁:

  1. ASLR:库基址随机化,需要泄漏步骤(如上)。
  2. NX:栈不可执行,这正是 ROP 存在的前提——ROP 不执行栈上代码,天然免疫 NX。
  3. CFI / CET:Intel CET 的 shadow stack 和 indirect branch tracking 专门针对 ROP——shadow stack 用独立栈记录合法返回地址,ret 时比对,不匹配直接异常。这是 ROP 的终结者级别防御,目前绕过研究集中在:syscall 侧信道、信号处理(sigreturn)、或者干脆不用 ret 改用 jmp 链(JOP)。
  4. stack canary:栈上放随机值,溢出覆盖到 canary 就触发检查。绕过方式是泄漏 canary(格式化字符串、堆溢出读)或对 fork 型服务逐字节爆破。

个人实践:写利用时永远先 checksec 看开了哪些保护,再决定是 ret2libc、ROP 还是混合。真实场景里最实用的组合是"canary 泄漏 + ROP 链 + ret2csu"——ret2csu 是 glibc 里 __libc_csu_init 的一段万能 gadget,能控制 rdi/rsi/rdx 三个参数寄存器,几乎每个 x64 程序里都有,是 ROP 链的兜底方案。

⚠️ 网络安全免责声明

本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。

请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。

如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。