CrossFitTwo
Summary
The CrossFitTwo server had its Unbound DNS management port (8953) exposed to the internet without client-certificate enforcement, giving any reachable attacker an unauthenticated file-read primitive against the underlying operating system.
Reading the reverse-proxy configuration (relayd.conf) revealed a hidden member web application; that application's password-reset endpoint trusted an attacker-controlled Host header, allowing account registration via a poisoned confirmation link.
Once inside the member portal, a cross-site WebSocket hijacking payload placed in the live chat relayed user david's private messages — including his SSH password — to an external listener, establishing a shell on the box and capturing the user flag.
From david's account, a scheduled Node.js statistics service (statbot) ran as the more-privileged user john and loaded modules from a directory david could write to; overwriting the log-to-file module caused the next cron-triggered run to drop a setuid shell owned by john, moving the attacker laterally into the staff group.
As john (and therefore staff), a setuid-root/setgid-staff file-reader binary was the only authorized path to privileged files, but its YubiKey-style OTP gate was defeated because the OTP seed material had been written to a world-readable file in /tmp.
That binary was used to read root's SSH private key from a predictable backup path; the key's passphrase was obtained the same way.
Authenticating as root over SSH with that key and passphrase completed the full compromise.
Attack path — how the box was taken
Mapped the attack surface and identified a management port exposed to the internet, then Read the reverse-proxy configuration through the unauthenticated Unbound control port, then Registered a member-portal account by injecting an attacker-controlled domain into the password-reset Host header, then Exfiltrated david's SSH credentials by hijacking the member-chat WebSocket from a malicious page, then Authenticated as david over SSH and captured the user flag, then Replaced a Node.js module writable by david to execute code as john and gain the staff group, then Generated a valid YubiKey OTP from world-readable /tmp seed material to unlock the root file-reader, then Authenticated as root over SSH with the stolen private key and its recovered passphrase.
Exact commands 2
nmap -sV -sC -p- --min-rate 5000 -oN crossfittwo-nmap.txt $TARGETopenssl s_client -connect $TARGET:8953 -servername unbound -showcerts 2>/dev/null | head -40Exact commands 2
unbound-control -s $TARGET@8953 statusunbound-control -s $TARGET@8953 auth_zone_reload /etc/relayd.conf 2>&1 | tee /tmp/relayd-leak.txtExact commands 1
nc -lvnp 8080Exact commands 1
python3 -m http.server 8080Exact commands 2
ssh david@$TARGET 'ls -la /opt/sysadmin/node_modules/log-to-file/; stat /opt/sysadmin/node_modules/log-to-file/app.js; cat /opt/sysadmin/server/statbot/statbot.js'scp -o StrictHostKeyChecking=no /tmp/cf2_app.js david@$TARGET:/opt/sysadmin/node_modules/log-to-file/app.jsExact commands 2
ssh david@$TARGET '/tmp/johnsh -p -c "cat /tmp/cf2-yubi"'git clone https://github.com/Yubico/yubico-c /tmp/yubico-c && cd /tmp/yubico-c && autoreconf --install && ./configure && makeAttack patterns used
The transferable techniques behind the compromise.
Unauthenticated Unbound control-port arbitrary file readEnumerationT1083
What it is
The Unbound control port accepted commands from any caller without requiring a client TLS certificate. Using unbound-control, the attacker issued auth-zone commands referencing arbitrary local file paths; the daemon parsed and surfaced their contents in zone-load output. Reading /etc/relayd.conf exposed the full reverse-proxy configuration, which mapped several virtual-host names — not advertised anywhere on the public site — to internal back-end services, including a member web application. This single unauthenticated primitive handed the attacker both a local file-system reader and a complete map of the web infrastructure.
Why it works
In /etc/unbound/unbound.conf, set 'interface: 127.0.0.1' under the remote-control block so the port only binds on loopback. If remote management is operationally required, generate a client-certificate pair with unbound-control-setup, distribute the client cert only to named management hosts, and enforce 'control-use-cert: yes'. Independently, block port 8953 at the network perimeter firewall for all external sources.
HTTP Host-header injection in password-reset flowExploitationT1190
What it is
The member web application constructed its password-reset confirmation URL by reading the HTTP Host header of the incoming request rather than using a hard-coded server-side base URL. By submitting a reset request for a valid email address while spoofing the Host header to point to an attacker-controlled listener, the application emailed (or logged) a reset link carrying a valid one-time token that arrived at the attacker's machine instead of the intended server. The attacker consumed that token to set a known password, effectively creating or taking over a member account.
Why it works
Hard-code the application's canonical base URL in server-side configuration (e.g. APP_URL=https://$TARGET/) and use only that value when generating any email link, redirect, or confirmation URL. Never use the Host, X-Forwarded-Host, or Forwarded headers for security-sensitive URL construction. As defense-in-depth, add a strict Content-Security-Policy header and set session cookies to Secure; SameSite=Strict.
Cross-site WebSocket hijacking / CSWSHExploitationT1185
What it is
The member portal's live chat used a WebSocket endpoint that did not validate the Origin header. This meant any web page — hosted anywhere on the internet — could silently open a WebSocket connection using a logged-in user's session cookie and read messages on their behalf. The attacker hosted a malicious HTML page containing a JavaScript WebSocket hijack; when david visited it (via a link delivered through the portal or social engineering), his browser opened a chat socket authenticated with his own session and forwarded all incoming messages to the attacker's listener in real time. One of those messages contained david's SSH password.
Why it works
In the WebSocket upgrade handler, compare the request's Origin against a strict server-side allowlist (e.g. ['https://$TARGET/']) and reject any connection from an unlisted origin with HTTP 403. Set all session cookies to SameSite=Strict so they are not sent in cross-origin requests at all, providing a second independent control even if the Origin check is ever misconfigured.
Node.js module search-path / path-preference hijackingLateral MovementT1574.007
What it is
The statbot service under /opt/sysadmin/server/statbot ran on a cron schedule as john (a more privileged user, also in the staff group) and loaded the npm module log-to-file from a node_modules directory that david had write access to. Node.js resolves require() calls by searching these directories at runtime, so overwriting app.js with a malicious payload caused the next scheduled invocation — running as john — to copy /bin/sh to /tmp/johnsh and set the setuid and setgid bits on it. Executing that shell gave the attacker a john+staff session without needing john's password.
Why it works
Own /opt/sysadmin/node_modules and every subdirectory as root:root (or as john:john) with mode 755, so no other user can write there. If the service account must install packages, perform npm install as the service account in a staging area and copy the result into the production path with restricted permissions afterwards. Consider a read-only bind-mount over the node_modules directory in the systemd unit or cron context to enforce immutability at runtime.
Privileged file-reader bypass via leaked OTP seedPrivilege EscalationT1552.001
What it is
The setuid-root/setgid-staff binary /usr/local/bin/log ('LogReader', permissions rwsr-s---) required a YubiKey-style one-time password before it would serve any file. However, the OTP seed material — KEY, UID, and counter (CTR) values — had been written in cleartext to /tmp/cf2-yubi, a world-readable file. Any local user could read those three values and compute a valid OTP using the published Yubico OTP algorithm. The attacker built the yubico-c reference library, generated a token offline, and passed it to /usr/local/bin/log along with the target path /var/backups/root_.ssh_id_rsa.current, retrieving root's encrypted SSH private key. The key's passphrase was obtained through the same unlocked read path.
Why it works
OTP seed material must never be written to /tmp or any world-readable path. If an inter-process handoff of key material is genuinely required, use a named pipe or Unix-domain socket owned by the service account with mode 600, or use an in-memory key derivation step that does not touch the filesystem. Apply the principle of least privilege to every file that touches cryptographic material: owner = service account, mode = 600, no world or group read.
SSH private key authentication with recovered credentialFull CompromiseT1021.004
What it is
The private key retrieved via the LogReader binary was encrypted; its passphrase was obtained through the same OTP-unlocked read path. Presenting the key and passphrase to the SSH daemon as root returned uid=0 and the root flag, completing full system compromise. The entire privilege-escalation chain from foothold to root required no vulnerability in the OS or SSH itself — only a series of misconfigurations in locally running services and file permissions.
Why it works
Do not store root's (or any privileged account's) SSH private key in shared storage or paths reachable by non-root processes. If backup copies are required for disaster recovery, store them in an offline, encrypted vault (e.g. HashiCorp Vault, or an encrypted USB device held off-host) with strict access logging. Longer term, replace long-lived private key files with short-lived SSH certificates issued by an internal CA; rotate both the CA and issued certs on a defined schedule so a stolen key has a bounded useful lifetime.
Findings
Exposed services
| External surface | A full TCP scan of <retired-instance-ip> returned three open ports: 22 (OpenSSH 9.5), 80 (HTTP / PHP 7.4.12 hosting CrossFit virtual hosts), and 8953 — a TLS-wrapped service whose self-signed certificate carried the Common Name 'unbound', identifying it as the Unbound DNS daemon's remote-control socket. This port is designed for trusted internal management tooling only; its exposure on a public interface was the root cause of the initial file-read vector. |