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 上了四道锁:
- ASLR:库基址随机化,需要泄漏步骤(如上)。
- NX:栈不可执行,这正是 ROP 存在的前提——ROP 不执行栈上代码,天然免疫 NX。
- CFI / CET:Intel CET 的 shadow stack 和 indirect branch tracking 专门针对 ROP——shadow stack 用独立栈记录合法返回地址,
ret时比对,不匹配直接异常。这是 ROP 的终结者级别防御,目前绕过研究集中在:syscall侧信道、信号处理(sigreturn)、或者干脆不用ret改用jmp链(JOP)。 - stack canary:栈上放随机值,溢出覆盖到 canary 就触发检查。绕过方式是泄漏 canary(格式化字符串、堆溢出读)或对 fork 型服务逐字节爆破。
个人实践:写利用时永远先 checksec 看开了哪些保护,再决定是 ret2libc、ROP 还是混合。真实场景里最实用的组合是"canary 泄漏 + ROP 链 + ret2csu"——ret2csu 是 glibc 里 __libc_csu_init 的一段万能 gadget,能控制 rdi/rsi/rdx 三个参数寄存器,几乎每个 x64 程序里都有,是 ROP 链的兜底方案。
⚠️ 网络安全免责声明
本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。
请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。
如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。
浙公网安备 33010602011771号