← all walkthroughs

CrossFitTwo

OpenBSD· Insane· Privilege Escalation
owned
2026-07-14
time to own
13m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

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.

1ReconnaissanceNetwork service enumeration (T1046)
Mapped the attack surface and identified a management port exposed to the internet
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.
Exact commands 2
Full port scan with version detection and default scripts.
nmap -sV -sC -p- --min-rate 5000 -oN crossfittwo-nmap.txt $TARGET
Fingerprint the TLS service on 8953; CN=unbound in the cert confirms the Unbound control port.
openssl s_client -connect $TARGET:8953 -servername unbound -showcerts 2>/dev/null | head -40
2EnumerationUnauthenticated Unbound control-port arbitrary file read (T1083)
Read the reverse-proxy configuration through the unauthenticated Unbound control port
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.
Exact commands 2
Confirm the control port responds to commands without a client certificate.
unbound-control -s $TARGET@8953 status
Trigger an auth-zone reload against the relayd config path; daemon error/output surfaces file contents.
unbound-control -s $TARGET@8953 auth_zone_reload /etc/relayd.conf 2>&1 | tee /tmp/relayd-leak.txt
3ExploitationHTTP Host-header injection in password-reset flow (CWE-20 / T1190)
Registered a member-portal account by injecting an attacker-controlled domain into the password-reset Host header
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.
Exact commands 1
Listener to capture the inbound HTTP request containing the password-reset token.
nc -lvnp 8080
4ExploitationCross-site WebSocket hijacking / CSWSH (CWE-346 / T1185)
Exfiltrated david's SSH credentials by hijacking the member-chat WebSocket from a malicious page
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.
Exact commands 1
Serve the CSWSH payload page and receive exfiltrated chat messages.
python3 -m http.server 8080
5FootholdValid account — SSH password authentication (T1078 / T1021.004)
Authenticated as david over SSH and captured the user flag
The credential exfiltrated from the hijacked chat — david / [REDACTED: recovered credential] — worked directly against the SSH service on port 22. David is a member of the sysadmins group alongside john. His home directory contained the user flag, confirming a fully established, interactive foothold on the system.
6Lateral MovementNode.js module search-path / path-preference hijacking (T1574.007)
Replaced a Node.js module writable by david to execute code as john and gain the staff group
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.
Exact commands 2
Confirm david can write to the module directory and verify statbot requires log-to-file.
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'
Overwrite the module entry point with the payload.
scp -o StrictHostKeyChecking=no /tmp/cf2_app.js david@$TARGET:/opt/sysadmin/node_modules/log-to-file/app.js
7Privilege EscalationPrivileged file-reader bypass via leaked OTP seed (T1552.001)
Generated a valid YubiKey OTP from world-readable /tmp seed material to unlock the root file-reader
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.
Exact commands 2
Read the world-readable OTP seed file from the john+staff shell; note KEY, UID, and CTR values.
ssh david@$TARGET '/tmp/johnsh -p -c "cat /tmp/cf2-yubi"'
Build the reference Yubico OTP library to obtain the ykgenerate tool.
git clone https://github.com/Yubico/yubico-c /tmp/yubico-c && cd /tmp/yubico-c && autoreconf --install && ./configure && make
8Full CompromiseSSH private key authentication with recovered credential (T1021.004 / T1078)
Authenticated as root over SSH with the stolen private key and its recovered passphrase
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.

Attack 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

Restrict the Unbound control port to loopback and require mutual TLSCritical
The Unbound DNS daemon's remote-control socket (port 8953) was bound to a public network interface and accepted connections from any caller without requiring a client TLS certificate. This gave an unauthenticated attacker the ability to issue control commands — including loading auth-zones from arbitrary local file paths — effectively turning it into an unauthenticated file-read primitive for the entire server.
Construct password-reset URLs from a server-side base URL, never the HTTP Host headerHigh
The member application's password-reset endpoint read the incoming HTTP Host header to build the confirmation link it emailed to the user. An attacker who submitted a reset request with a spoofed Host header received a valid one-time token at a domain they controlled, allowing account takeover or unauthorized account registration without ever touching the victim's inbox.
Validate the Origin header on every WebSocket upgrade and set SameSite=Strict on session cookiesHigh
The member-portal chat WebSocket endpoint accepted connections from any web origin without checking the Origin header. Because browsers automatically include session cookies in WebSocket upgrade requests, any web page the victim visited could open an authenticated socket on their behalf and silently read or send chat messages — in this case exfiltrating the victim's SSH password.
Remove write access to service-owned Node.js module directories for unprivileged usersCritical
The node_modules directory consumed by the john-owned statbot cron service was writable by david, a lower-privileged user. Node.js resolves require() calls by searching module directories at runtime, so overwriting any module's entry point caused the privileged service to execute attacker-supplied code the next time it ran on john's schedule.
Never write OTP seed material or cryptographic keys to world-readable locationsCritical
The YubiKey OTP seed (KEY, UID, and CTR values) that gated the privileged file-reader binary was written in cleartext to /tmp/cf2-yubi, a world-readable file accessible to any local user. Reading those three values and running the public Yubico OTP algorithm completely defeated the OTP control — the binary accepted the synthesized token as if it came from a physical YubiKey.
Remove the root SSH private key from the world-discoverable backup path and adopt certificate-based SSHCritical
Root's SSH private key was stored at the predictable path /var/backups/root_.ssh_id_rsa.current — a location documented in the public environment — and was retrievable by any user who could invoke the privileged file-reader binary. Once the OTP gate was bypassed, obtaining this key gave the attacker direct, persistent SSH access to root without needing to crack a password hash or exploit any additional vulnerability.

Exposed services

External surface