The challenge comes with a C program, its compiled binary, and a Python wrapper. The bug is the call printf(buf): instead of printing the input as plain text, printf treats parts of it as instructions. The wrapper sends the same input to 200 fresh processes and only gives the flag if at least 196 succeed.

The operating system can place the program at a different address each time (address randomization, or ASLR). A protection called Full RELRO makes the table of imported function addresses read-only. The wrapper accepts one ASCII input and reuses it for all 200 runs, so we cannot add raw binary addresses to the input or use output from one run to adjust it. The exploit works around this by building a pointer (a value that holds an address) from values already available to printf.

Idea: use format-string writes to point at printf's saved return address, then change its last byte from 0xf9 to 0x09. That sends execution to win without needing to know the program's randomized base address.

How the input reaches printf

The vulnerable part of the program is:

read(0, buf, 4);
sz = atoi(buf);
if (sz < 0x300) {
    read(0, buf, sz);
    buf[sz - 1] = '\0';
    printf(buf);
    _exit(0);
}

printf receives the input directly, so a sequence such as %n is treated as a formatting command rather than printed literally. Here, base means the program's starting address in that process. win is at base + 0x1209, while the instruction after the call to printf is at base + 0x12f9. Since base is page-aligned, the two addresses have the same upper bytes. Changing only the last byte of the saved return address is enough to send execution from printf back into win.

The saved return address to change belongs to printf, not main. After printf returns, the program calls _exit(0), so it does not return through main's saved address. The win function copies a fresh token from secret.txt to win.txt; the wrapper checks whether those files match and counts that process as a success.

The payload itself is 292 ASCII bytes, but the solver declares a length of 452. It sends the payload as a line. In the child process, the C program asks read for up to 452 bytes, ignores how many bytes actually arrived, and writes a terminating zero at buf[451]. In this setup, the declared length is part of the exploit: changing it can change how far printf reads beyond the visible directives. It is not the payload's actual length.

Building the return address a byte at a time

printf keeps track of how many characters it has printed. %n writes that count through a pointer; %hn writes its low two bytes, and %hhn writes its low byte. The program does not pass extra arguments to printf, but the format string makes it read values from the places where it expects arguments. This is undefined behavior in C, so the exploit depends on this binary and its runtime.

The first %c directives move through those argument slots until the payload reaches a useful pointer. %64914c then adds 64,914 characters to printf's count. %*c gets its field width from another argument, allowing a value already on the stack to affect the count. The following %hn uses that count to change the low two bytes of a pointer while leaving its upper bytes alone. This lets the exploit work with a randomized stack address.

The last part prepares the byte to write. In the stack arrangement expected by this binary, the value used for the repeated field width has a low byte ending in hexadecimal 8. Multiplying that byte by 16 removes its unknown upper half. The payload uses one copy of the value and adds it 15 more times, then adds 0x89:

(0x?8 × 16) mod 256 = 0x80
(0x80 + 0x89) mod 256 = 0x09

Here ? stands for the unknown upper half of the byte, and mod 256 means only the last byte is kept. The final %106$hhn writes 0x09 through argument 106. By then, the earlier writes have arranged for that argument to point at printf's saved return address. Changing its last byte sends execution to win.

The solver builds the payload in named pieces rather than hiding it in one long string:

walk_to_pointer = b"%c" * (5 + 0x37)
forge_return_slot = b"%64914c%*c" + b"%c" * 10 + b"%hn"
normalize_low_byte = b"%c" * 16 + b"%hhn"
shift_nibble = b"%*97$c" * 15
overwrite_return_lsb = b"%137c%106$hhn"

%*97$c uses argument 97 as the field width, and repeating it 15 times adds that value 15 more times. %137c adds 137 characters (0x89) before %106$hhn writes the final byte. solve.py joins these pieces and sends them with the declared length of 452.

The solver explains the pointer relationships that its constants rely on, but the repository does not include a debugger trace for every argument slot and intermediate write. The code offsets and byte arithmetic can be checked directly; the exact stack setup depends on the original binary and runtime. In particular, mixing positional and sequential format arguments is not portable behavior.

Why the success count matters

The wrapper runs the payload 200 times with fresh process state. It accepts the exploit only when more than 195 runs succeed, which means at least 196 must reach win and copy the token correctly. The original write-up reports local runs with 196, 198, and 199 successes, so the payload is probabilistic rather than guaranteed.

The screenshot below shows a local run with 194/200, which does not pass the wrapper's threshold. It demonstrates why one successful process is not enough. The original account mentions separate runs with 196, 198, and 199 successes, but their transcripts are not included here. The screenshot therefore shows partial success, not an accepted solve.

local run

Reproducing it locally

build the challenge image and start its wrapper. The challenge's nsjail configuration requires privileged Docker for this local setup.

docker build -t ictf2025-stillerer-local artifacts
docker run --rm --privileged -p 31337:1337 ictf2025-stillerer-local

In another terminal, run:

./solve.py 127.0.0.1 31337

Check the final Total: line, the acceptance message, and the flag. A run with fewer than 196 successes has not met the challenge's success condition, even if many individual processes reached win.