Agile is a medium linux box by 0xdf featuring a simple web-based LFI that could be used to bypass PIN validation in the Werkzeug debug console. Once on the box, you’ll recover some creds from a MySQL database and gain access to a local user account. You’ll then be required to exploit a previously discovered vulnerability but this time using a local symlink to get through a filter, and then exploit sudoedit
and a cron job that uses python’s virtualenv to gain code execution as root
.
About
Recon
NMAP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Nmap 7.70 scan initiated Wed Mar 15 06:47:26 2023 as: nmap -sC -sV -oN nmap.txt -v 10.10.11.203
Nmap scan report for agile (10.10.11.203)
Host is up (0.44s latency).
Not shown: 998 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://superpass.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Mar 15 06:48:32 2023 -- 1 IP address (1 host up) scanned in 66.77 seconds
Web
Going to the web page redirected to superpass.htb
, which I then added to my /etc/hosts
file;
There is a getting started link at the bottom of the page, which redirect to a login form;
Clicking the register button, I was able to create an account, and login;
The application is a custom web-based password manager. Checking burp, the application issued us with a token following login;
1
session=.eJwtjkuKwzAQRK8ieh0GtSRLap8i-yGEltQdG5wPlrMKuftoMauiqOLxPnDVjfsiHebfD5hjBNyld74JnOC8CXcx2_Nm1oc5noZrHaM5lrWb1_j8wOV7OQ3ILn2B-djfMtraYAas2VEkLllTLmhLyMlHL7lZVGIqNgVN0-QImwRfGjYUVinqY_UanNQYAmkNwgOktobJZVsc-ZgjVXLosXkXlDG1zIVIHDvbkFOzqQz967vL_m-T4PsHJgRGWg.ZB1wLQ.O5sxCFDdi0TGl0bQOQvJrPSjISE
That looked a bit like a JWT, but the algorithm part is missing. Attempting to base64-decode the payload part of the token failed, likely due to the the -
and _
symbols in the payload. Some encoders replace +
and /
with -
and _
, respectively. After replacing them, the payload decodes to a zlib-compressed data, which decompresses to a JSON object;
1
{"_flashes":[{" t":["message","Please log in to access this page."]}],"_fresh":true,"_id":"1c82969ab8f78b10b487363e8d01f9a9b074f755291de43bd1d1eafebf36c3f42ec6449fc4ea296f0c45280b2936869c92131d324fa17d8ab99e2a20d1a7d07b","_user_id":"17"}
So I wrote a simple script that, when given a token payload as an argument, decodes it, and when given a file, reads it as JSON and encode it;
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
#!/usr/bin/python3
import sys, os, zlib, base64
def token_decode(token):
token = token.replace("-", "+")
token = token.replace("_", "/")
while (len(token) % 4) != 0:
token += "="
print(f"[+] Base64: {token}")
data = base64.b64decode(token)
data = zlib.decompress(data)
wfo = open("token_data.json", "wb")
wfo.write(data)
wfo.close()
print(f"[+] JSON: {data.decode()}")
return data
def token_encode(data):
data = base64.b64encode(zlib.compress(data.encode())).decode()
while data.endswith("="): data = data[:-1]
data = data.replace("+", "-")
data = data.replace("/", "_")
print(f"[+] Token: {data}")
return data
if __name__ == '__main__':
token = sys.argv[1]
if os.path.isfile(token):
data = open(token).read()
token_encode(data)
else:
token_decode(token)
Multiple attempts to manipulate the token using this script failed, so I moved on.
The password manager has an option to export passwords in the vault, which generated a CSV file of passwords for download;
The fn
parameter looks interesting. Testing it, it was found to be vulnerable to Local File Inclusion (LFI);
Giving the parameter an invalid file generated a detailed debug message that exposes the web app as a Flask/Werkzeug application. This indicate the server is running in debug mode. Going to the /console
route (the default debug console route for Flask) however gave a 404 message;
This is odd since the 404 page returned above differs with the ones returned by the server when requesting an invalid route;
Hovering over a line in the debug error output showed a small terminal icon to the right;
Clicking it prompted us for a debug pin, which we don’t have;
Foothold
Enumeration using LFI
Taking advantage of the LFI bug, and the debug error output, I was able to extract a lot from the system;
- 4 local users (
/etc/passwd
) - corum, runner, edwards, dev_admin - Hidden subdomain
test.superpass.htb
(/etc/hosts
). Can’t access this domain from my box as it keeps redirecting tosuperpass.htb
. - Process command (
/proc/self/cmdline
) -/app/venv/bin/python3/app/venv/bin/gunicorn--bind127.0.0.1:5000--threads=10--timeout600wsgi:app
- Environment vars (
/proc/self/environ
) -LANG=C.UTF-8PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/binHOME=/var/wwwLOGNAME=www-dataUSER=www-dataINVOCATION_ID=9c2427940e444f78a89d7d86fde2dc58JOURNAL_STREAM=8:32855SYSTEMD_EXEC_PID=1090CONFIG_PATH=/app/config_prod.json
- MySQL creds in
/app/config_prod.json
-superpassuser:dSA6l7q*yIVs$39Ml6ywvgK
- MySQL creds in
- Application entrypoint:
/app/app/superpass/app.py
- From there traced all different parts of the app, and downloaded them for analysis.
The code responsible for the export and the LFI was found in views/vault_views.py
;
The generate_csv()
function is defined in services/password_service.py
;
I thought of finding a way to access other users password exports if I could figure out how the random string appended to the exported filename is generated. The get_random()
function used is defined in services/utility_service.py
;
1
2
3
4
5
import datetime
import hashlib
def get_random(chars=20):
return hashlib.md5(str(datetime.datetime.now()).encode() + b"SeCReT?!").hexdigest()[:chars]
It looks pretty solid, so I moved on.
Werkzeug Debug Console PIN
Looking for possible ways to guess the console PIN, I found this guide. The README explains in great detail how to effectively guess the console PIN of a Werkzeug server provided you have an LFI on the host. I was able to get it to work after some tweaks.
The file werkzeug-pin-bypass.py
has two arrays that need to be configured according to the target;
probably_public_bits
;
- The username is
www-data
in our case. - The third item set to
Flask
iswsgi_app
in our case. I found this by running the given commandgetattr(app, '__name__', getattr(app.__class__, '__name__'))
in a local instance of the web app. This seems to be the combination of the entrypoint file used bygunicorn
(wsgi.py
) and the web app entrypoint (app.py
). - Full path of Flask library -
/app/venv/lib/python3.10/site-packages/flask/app.py
private_bits
;
- Can’t run
uuid.getnode()
on the box obviously, and/sys/class/net/ens33/address
does not exist. I read/proc/self/net/addr
to get the interface the service is using, which was found to beeth0
. Then I read/sys/class/net/eth0/address
to get the address, which I converted to int. This value seems to have changed from when I first did the box, so you may need to regenerate. - Looking at the other code given for generating machine ID, for this box it will be contents of
/etc/machine-id
+superpass.service
(from/proc/self/cgroup
). This turn out to beed5b159560f54721827644bc9b220d00superpass.service
Plugging all these parameters in, we got a PIN;
Running the script gave me a PIN, and it was accepted by the server. I then used the python console to spawn a BASH reverse shell;
User
With a shell on the box as www-data
, I can now connect to the local MySQL server whose creds I got earlier;
The password in the users
table are all hashed, but the ones in passwords
table are plain;
The creds of corum
are of interest as that’s a valid local user. Testing them, I got access to the local user account of corum
over SSH using the password 5db7caa1d13cc37c9fc2
;
PrivEsc
User has no special groups, nor sudo perms. Checking the nginx config files for the server at /etc/nginx/sites-enabled/
, I understood why we couldn’t access /console
directly;
The config for the vhost test.superpass.nginx
is defined in superpass-test.nginx
. The service is listening on port 5555
locally;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 127.0.0.1:80;
server_name test.superpass.htb;
location /static {
alias /app/app-testing/superpass/static;
expires 365d;
}
location / {
include uwsgi_params;
proxy_pass http://127.0.0.1:5555;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Protocol $scheme;
}
}
Running ps aux
showed the process running as the user runner
;
So I setup an SSH tunnel using -L 5555:test.superpass.htb:5555
and edited my /etc/hosts
file to map test.superpass.htb
to debian.local
(my localhost). It worked, and I can now access the server;
This looks very similar to the public facing website. The sources are in /app/app-testing
, and are owned by runner
. The file /app/config_test.json
is interesting, and is readable by runner
, which the server is running as. Testing the LFI bug discovered earlier on the public instance, the local instance seems to be not vulnerable as it simply logs out the user;
Checking the source at /app/app-testing/superpass/views/vault_views.py
, I realised why;
The application is checking to make sure the requested file’s extension ends with .csv
. This would have complicated things, but since we already have a shell on the box, we could exploit this by creating a symbolic link whose name ends with .csv
and point it to any file we wish.
My first attempt at doing this was to create the symlink in /tmp
(/var/tmp
). It didn’t work. I realized that all symlinks created in this directory by a user cannot be used by another user even if the other user has the required permissions on the destination file of the symlink. More on this here
Since I am working as the user corum
, I made my home directory readble and executable to others on the box. This is to allow runner
to access files in corum’s home directory. I then created a symlink to the file /app/config_test.json
. It worked, and I got a MySQL cred;
A credential was found in the database for the user edwards
, and the password d07867c6267dcb5df0af
gave me access to his local account over SSH;
The user edwards
has permissions to use sudoedit
to edit a file with privileges of user and group dev_admin
. You can trigger this with the command;
1
sudoedit -u dev_admin /app/config_test.json
sudoedit
uses the $EDITOR
environment variable to decide which editor to use when viewing a file. By setting it to vim <file>
, we could use this to read/write to any file that has permissions for user or group dev_admin
.
Further enumeration showed that /app/test_and_update.sh
is run as cron task by root user;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# update prod with latest from testing constantly assuming tests are passing
echo "Starting test_and_update"
date
# if already running, exit
ps auxww | grep -v "grep" | grep -q "pytest" && exit
echo "Not already running. Starting..."
# start in dev folder
cd /app/app-testing
# system-wide source doesn't seem to happen in cron jobs
source /app/venv/bin/activate
# run tests, exit if failure
pytest -x 2>&1 >/dev/null || exit
# tests good, update prod (flask debug mode will load it instantly)
cp -r superpass /app/app/
echo "Complete!"
The script simply activates a python virtual environment by sourcing /app/venv/bin/activate
, run some tests using pytest
, and copy all the contents of /app/app-testing/superpass/
to /app/app/
. Checking the permissions of the files involved, /app/venv/bin/activate
was found to be writable to the group dev_admin
;
This is very interesting since the file is executed as a script. Setting my $EDITOR
variable to vim /app/venv/bin/activate
;
Strangely, it opened the file in read-only mode. I had to add --
between vim
and the target file before it worked;
1
export EDITOR='vim -- /app/venv/bin/activate'
Few seconds later, I got a shell;
Summary
- Web app has open registration.
- It is a web-based password manager.
- Export feature found to be vulnerable to LFI.
- Exploited it to generate Werkzeug console PIN, and gained RCE.
www-data
;- Recovered creds of user
corum
from local MySQL using creds in/app/config_prod.json
.
- Recovered creds of user
corum
;- Got
user.txt
- Setup an SSH tunnel to access
test.superpass.htb:5555
. - Bypassed LFI filter using local symlinks to dump
/app/config_test.json
. - Used it access local MySQL and found creds for
edwards
.
- Got
edwards
;sudoedit
perms to/app/config_test.json
as user/groupdev_admin
.- Identified a cron task running
/app/test_and_update.sh
which uses a python virtual env. - Virtual env setup file is writable to group
dev_admin
. - Exploited it to inject a BASH reverse shell, and got a shell as
root
.