Description
This program is not impressed by cheap parlor tricks like reading arbitrary data off the stack. To impress this program you must change data on the stack!
Setup
Download vuln/vuln.c for local analysis and install pwntools.
Interact with the remote instance at rhea.picoctf.net <PORT_FROM_INSTANCE>.
wget https://artifacts.picoctf.net/c_rhea/15/vuln && \
wget https://artifacts.picoctf.net/c_rhea/15/vuln.c && \
pip install pwntools && \
nc rhea.picoctf.net <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the offset
ObservationA %n write needs the target address at a known stack position, referenced by index in the specifier. So the first job is finding which argument number corresponds to the format string buffer.Use pwntools' FmtStr + exec_fmt helper to spray %p until the library auto-detects the correct stack offset.bashautofmt = FmtStr(exec_fmt); offset = autofmt.offsetWhat didn't work first
Tried: Hardcode the offset at 8 (a common x86-64 format string offset) instead of using FmtStr auto-detection.
The offset depends on how much stack the calling function allocates before printf, which shifts with the binary, the compiler, and the optimization level. Hardcode 8 and it may work locally and fail on the server, writing to the wrong address or segfaulting. FmtStr probes the live process and reports the real number.
Tried: Use %p payloads manually in the nc session to count stack slots until you see the marker, then hardcode that number.
Counting by hand works in principle, but off-by-one slips are easy, and the offset in a plain nc session can differ from what the exploit sees because environment variables change the stack. FmtStr with exec_fmt measures it over the same connection the exploit uses.
Learn more
pwntools is a Python CTF framework and exploit development library. Its
FmtStrclass automates format string exploitation by automatically determining the stack offset where the format string itself appears. This is crucial because the%nwrite technique requires knowing the exact position of a controlled address on the stack.The
exec_fmtcallback connectsFmtStrto the remote service: it sends a format string payload, receives the output, and returns it soFmtStrcan parse the leaked values. The library sends a series of test payloads with a unique marker, then searches the leaked stack values for that marker to determine the offset automatically.Knowing the offset enables direct parameter access: the format specifier
%15$preads the 15th argument directly. This is essential for the write technique, where you embed a target address at a known offset in the format string and then write to it using%N$n(which writes the number of characters printed so far to the address at argument N).The offset varies by binary because it depends on how much stack space the calling function uses before calling
printf. Different compilers, optimization levels, and function prologues all affect the offset. This is why dynamic detection (rather than hardcoding) is the robust approach.Step 2Craft the overwrite
ObservationThe source checks whether the global sus holds the ASCII bytes for 'flag', which is 0x67616c66 little-endian. Write exactly that to its fixed address at 0x404060 and let fmtstr_payload work out the byte chunking.Generate a payload that writes 0x67616c66 ("flag") into address 0x404060 (the sus global). fmtstr_payload handles the padding for you.bashpayload = fmtstr_payload(offset, {0x404060: 0x67616c66})What didn't work first
Tried: Craft a single %n write instead of letting fmtstr_payload split it into %hhn byte chunks.
Writing 0x67616c66 with one %n means printing over 1.7 billion characters first. That takes minutes over a socket and the server closes the connection long before. fmtstr_payload splits the value into single bytes and uses %hhn, keeping the padding near a thousand characters.
Tried: Use the address of a local variable or a stack address instead of the global sus at 0x404060.
With ASLR on, stack addresses move every run, so a hardcoded one is wrong outside the environment where it was measured. sus is a global in .bss and the binary is not PIE, so 0x404060 holds every time. Targeting a stack address instead would require leaking one first, a harder chain.
Learn more
The
%nformat specifier is uniquely dangerous: instead of printing something, it writes the number of characters printed so far into the integer pointed to by the corresponding stack argument. By controlling how many characters are printed (via width specifiers like%100d) and controlling what address is on the stack at the right offset, an attacker can write arbitrary values to arbitrary memory.fmtstr_payload()constructs the entire format string automatically: it places the target address(es) in the string at the correct stack offset, uses%hhn(1 byte) /%hn(2 bytes) /%n(4 bytes) with carefully calculated width values to write the desired integer.Why byte-chunking matters on a network: a single
%nfor a 4-byte write must first print the literal value as characters, so writing0x67616c66= ~1,734,437,990 chars across a socket is a non-starter. Splitting the write into four%hhnbytes caps the total padding around 1,000 chars (each byte rolls modulo 256), turning a multi-hour transfer into milliseconds. See the format string guide for the full%hhnbyte-write derivation.Goal: write 0x67616c66 ("flag") to 0x404060. Naive 4-byte %n approach is too slow on the network because %n writes ALL FOUR BYTES at once - meaning we'd need to print 0x67616c66 = 1,734,437,990 characters before the %n. Bad. fmtstr_payload's chunked-byte strategy: byte position target byte cumulative chars to print --------------------------------------------------------- 0x404060 (lo) 0x66 0x66 = 102 0x404061 0x6c 0x16c = 364 0x404062 0x61 0x261 = 609 0x404063 (hi) 0x67 0x367 = 871 We write each byte with %hhn after padding the running count up to the next target byte. The "rolling count" wraps at 256 (byte width) so we just have to add enough %Nc each step. Resulting payload (simplified): [%102c%17$hhn] + [%262c%18$hhn] + [%245c%19$hhn] + [%262c%20$hhn] + p64(0x404060) + p64(0x404061) + p64(0x404062) + p64(0x404063)Writing
0x67616c66to address0x404060is a concrete example of arbitrary write - the most powerful primitive in binary exploitation. With arbitrary write, an attacker can overwrite: function pointers (to redirect code execution), return addresses (classic stack smashing), the Global Offset Table (to redirect library calls), or security-sensitive variables like thesusguard variable in this challenge.The value
0x67616c66is the little-endian encoding of the ASCII string"flag":f=0x66,l=0x6c,a=0x61,g=0x67. Choosing a memorable ASCII value as the target makes it easy to verify the write succeeded by examining the variable in a debugger.Step 3Send and read
ObservationWith the offset and target address settled, the payload is complete. Send it over the socket and read the flag the binary prints once sus matches.Send the payload to the remote service. Once sus == 'flag', the program prints picoCTF{f0rm47_57r?_f0rm47_m3m_99...}.Learn more
Sending the pwntools-crafted payload to the remote service completes the exploit chain. The binary receives the format string, passes it to
printf, which processes the%nspecifiers and writes"flag"intosus. Then the program checks ifsus == "flag"and, finding it true, prints the flag.This demonstrates the full power of format string exploitation: starting from a single vulnerable
printf(input)call, an attacker can read arbitrary memory (information disclosure) and write arbitrary memory (arbitrary code execution). The printf "write-what-where" primitive was one of the most exploited vulnerability classes in the 2000s.Modern mitigations that make format string exploits harder include: FORTIFY_SOURCE (catches some misuses at compile time), RELRO (makes the GOT read-only, preventing GOT overwrites), and PIE (randomizes binary base address, making hardcoded addresses invalid). However, format string bugs that leak stack data can bypass ASLR by revealing the randomized base address, then a second write payload can use the leaked address to target specific locations.
pwntools makes exploit development faster and more reliable by handling the low-level details. Professional exploit developers use pwntools for CTF challenges and security research, but the underlying concepts - format string semantics, stack layout, address arithmetic - must be understood deeply to debug failures and adapt techniques to novel situations.
Interactive tools
- 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{f0rm47_57r?_f0rm47_m3m_99...}
Once sus reads "flag", the binary happily prints the real flag.
Key takeaway
How to prevent this
How to prevent this
%n turns format string from a leak primitive into a write-what-where. The fix is identical; the consequences are worse.
- Strip
%nsupport entirely withFORTIFY_SOURCE=2or by linking against a libc that omits it. Most production code never legitimately uses%n; turning it off costs nothing. - Enable full RELRO (
-Wl,-z,relro,-z,now) so the GOT is read-only after startup. Even with arbitrary write, GOT-overwrite hijacks fail. - And the prerequisite: don't pass user input as the format string. Catch with
-Werror=format-security; ban dynamic format strings in code review.