HTB: BackendTwo Writeup
BackendTwo - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | BackendTwo |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | N/A |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
BackendTwo exposes a bare FastAPI/uvicorn backend as its only web surface. Fuzzing the API tree reveals a mass-assignment flaw in the user-edit endpoint that lets a freshly registered account flip its own is_superuser flag. That superuser JWT unlocks an admin file-read endpoint, which — beyond the intended path of leaking /proc/self/environ for the JWT signing secret — is powerful enough on its own to read arbitrary files as root-owned processes, including the box’s SSH auth.log. A password fat-fingered into the username field of a failed login sitting in that log turns into a direct SSH credential via password reuse. Root is gated behind pam_wordle, a custom PAM module that forces a Wordle game before sudo will run, solved here with a scripted PTY-driving Wordle solver.
TL;DR: API fuzzing (/api → /api/v1 → /user, /admin) → integer IDs leak admin profile → signup/login → mass assignment (is_superuser: true) → admin file-read endpoint → /proc/self/environ leaks API_KEY (JWT secret) → same file-read endpoint reads /home/htb/auth.log → password reuse via SSH → user.txt → sudo -l gated by pam_wordle → scripted Wordle solver drives sudo interactively → root.txt.
Reconnaissance
Service Enumeration
The web service on the target responds only in JSON, with a uvicorn server header — a strong signal this is a Python FastAPI backend rather than a conventional site.
Enumerating the root of the API surface by hand (rather than assuming a fixed structure) walked down the tree one level at a time:
# root endpoint just advertises the versioned API pathcurl -s http://TARGET/api# → {"endpoints": "/v1"}
curl -s http://TARGET/api/v1# → {"endpoints": ["/user", "/admin"]}Both /user and /admin exist. Probing /user with a non-numeric value confirms the endpoint expects an integer path parameter (a FastAPI type-validation error leaks this for free):
curl -s http://TARGET/api/v1/user/melo# → {"detail":[{"loc":["path","user_id"],"msg":"value is not a valid integer","type":"type_error.integer"}]}Walking small integers up from 1:
curl -s http://TARGET/api/v1/user/1/api/v1/user/1 returns the admin account’s profile — is_superuser: true sitting in plaintext in the response. This confirms the user-enumeration surface is unauthenticated and that superuser status is a first-class, client-visible field on the user object — the first hint that it might also be a client-writable one.
Vulnerability Assessment
- Unauthenticated user enumeration by integer ID on
/api/v1/user/{id}. - A
signup/loginflow exists under/api/v1/user/, so an attacker can mint their own low-privilege account rather than needing to compromise an existing one. is_superuseris exposed on the user JSON object returned to clients — a classic setup for a mass assignment vulnerability if the edit endpoint doesn’t allow-list the fields it accepts.- An admin-only file-read endpoint exists once superuser is achieved, and (as exploited below) its read authorization is broader than the application designer probably intended.
Initial Foothold
1. Register and authenticate
# create a normal, unprivileged accountcurl -s -X POST http://TARGET/api/v1/user/signup \ -H 'Content-Type: application/json' \ -d '{"email": "melo@backendtwo.htb", "password": "..."}'
# authenticate to get a JWT (this account landed as user id 12)curl -s http://TARGET/api/v1/user/login \ -d 'username=melo@backendtwo.htb&password=...'# → {"access_token": "<JWT>", "token_type": "bearer"}The returned JWT is HS256-signed and carries the low-privilege claims (sub: 12, is_superuser: false).
2. Mass assignment → superuser
The user-edit endpoint accepts a JSON body intended only for updating a display profile field. Because the backend model-binds the request body directly onto the ORM user object instead of filtering to an explicit allow-list of editable fields, any field present on the model — including is_superuser — is accepted and persisted:
# PUT to the edit endpoint with an out-of-scope field in the bodycurl -X PUT http://TARGET/api/v1/user/12/edit \ -H "Authorization: Bearer <JWT>" \ -H 'Content-Type: application/json' \ -d '{"is_superuser": true}'This is why it works: the API framework’s request model for this route almost certainly mirrors the database schema 1:1 rather than defining a narrower “editable fields” schema — so anything valid on the ORM model is valid in the request. Re-authenticating (/user/login again) mints a fresh JWT with is_superuser: true baked into its claims, since the token is signed at login time from the current DB state.
3. Admin file-read → JWT secret
With the elevated JWT, the admin file-read endpoint accepts a base64url-encoded path and returns file contents:
# GET /api/v1/admin/file/<base64url(path)>curl -s http://TARGET/api/v1/admin/file/$(python3 -c "import base64print(base64.urlsafe_b64encode(b'/proc/self/environ').decode().rstrip('='))") -H "Authorization: Bearer <superuser JWT>"Reading /proc/self/environ (the running app process’s environment) leaked API_KEY=<redacted>. Cross-referencing what this key is used for: FastAPI apps commonly load their JWT signing secret straight from an environment variable at import time (JWT_SECRET = os.environ['API_KEY'], HS256) — this key is the token-signing secret itself, meaning any JWT for this app can now be forged offline with pyjwt.
4. Direct-to-root-owned-file read → password reuse
Rather than using the JWT secret to forge a debug claim for the write-capable variant of this endpoint and plant a backdoor, the read side of the admin file endpoint required nothing beyond is_superuser: true — which the forged/mass-assigned JWT already carried. That’s enough to read any file the backend process has permission to read, including logs outside the app’s own directory:
curl -s http://TARGET/api/v1/admin/file/$(python3 -c "import base64print(base64.urlsafe_b64encode(b'/home/htb/auth.log').decode().rstrip('='))") -H "Authorization: Bearer <superuser JWT>" | jq -r '.file'/home/htb/auth.log contained a failed login attempt where the user had fat-fingered their password into the username field — a common real-world credential-hygiene mistake that leaks a plaintext secret into logs that would otherwise only ever record usernames: 1qaz2wsx_htb!.
5. Credential reuse over SSH
ssh htb@TARGET# password: 1qaz2wsx_htb!The leaked password authenticated directly as htb over SSH (groups: sudo among others).
cat user.txt# <redacted>Privilege Escalation
PAM-Wordle
sudo -lsudo on this box is gated by a custom PAM module, pam_wordle, wired into /etc/pam.d/sudo ahead of the normal pam_unix.so auth stage. Before sudo will even evaluate -l or run a command, it drives an interactive session that:
- Picks a secret five-letter word from a pool (agent-observed pool path:
/opt/.words). - Gives the user 6 guesses.
- After each guess, prints a
Hint ->line coloring each letter: correct-position, present-but-wrong-position, and absent — i.e. real Wordle rules enforced over a PTY, not a simple string match.
Because this is a live, stateful, PTY-driven prompt (not a single scriptable input), automating it requires actually playing the game programmatically:
# wordle_solve.py (abridged)# Drives `sudo` over a pty, sends the SSH password when prompted,# then plays Wordle by parsing each "Hint ->" line and narrowing# the candidate word list using standard Wordle constraint logic:# - 'x' at position i -> letter confirmed at that index# - '*' at position i -> letter in word, but not at that index# - '?' at position i -> letter not in word at allimport pty, os, re, select
def filter_candidates(candidates, guess, hint): survivors = [] for word in candidates: ok = True for i, (g, h) in enumerate(zip(guess, hint)): if h == g: # correct letter/position if word[i] != g: ok = False elif h == '*': # right letter, wrong slot if g not in word or word[i] == g: ok = False elif h == '?': # letter absent if g in word: ok = False if ok: survivors.append(word) return survivors
# main loop: spawn `sudo <cmd>` under a pty, answer the password prompt,# then for each of the 6 attempts pick the best remaining candidate,# send it, read back the "Hint ->" line, and re-filter the pool until# a single word (or a win) remains.Why a PTY is required here (and not just piping stdin/stdout): sudo’s password prompt and the PAM conversation both check that they’re talking to a real terminal, so a plain subprocess pipe gets rejected or hangs — a pseudo-terminal is needed to satisfy that check while still letting a script drive both sides of the conversation.
Once the word is solved, PAM authentication succeeds and sudo falls through to the normal sudoers evaluation — where htb holds (ALL:ALL) ALL, i.e. unrestricted root:
sudo /bin/bash# (after the Wordle solver completes the game)cat /root/root.txt# <redacted>Attack Chain Summary
API fuzzing (/api → /api/v1 → /user, /admin) → unauthenticated integer-ID enumeration leaks admin profile (is_superuser) → signup + login (own low-priv JWT, id 12) → mass assignment: PUT /user/12/edit {"is_superuser": true} → re-login for elevated JWT → admin file-read endpoint: GET /proc/self/environ → API_KEY (JWT secret, HS256) → same file-read endpoint (superuser-only, no debug flag needed): GET /home/htb/auth.log → password reuse found in log (fat-fingered into username field) → SSH as htb → user.txt → sudo -l gated by pam_wordle → scripted PTY Wordle solver defeats PAM gate → sudo (htb = ALL:ALL ALL) → root.txtTools Used
| Tool | Purpose |
|---|---|
curl | API enumeration, auth, mass-assignment PUT, admin file-read requests |
jq | Parsing JSON API responses |
python3 | Base64url path encoding for the file-read endpoint; custom PTY-driving Wordle solver (wordle_solve.py) |
ssh | Credential-reuse login as htb |
sudo / pam_wordle | Target privilege boundary defeated by the Wordle solver |
Key Learnings
Techniques Practiced
- API surface discovery by walking undocumented FastAPI routes and reading its structured type-validation errors as a fuzzing oracle.
- Exploiting mass assignment where a PUT/edit endpoint model-binds the full ORM object instead of an explicit allow-list of editable fields.
- Abusing an admin file-read primitive beyond its intended “leak the JWT secret” use case to directly read arbitrary log files.
- Recognizing password-in-username-field as a realistic, non-synthetic credential leak inside auth logs.
- Automating a stateful, PTY-gated interactive challenge (PAM-Wordle) by parsing structured hint output and applying constraint-satisfaction filtering — not just replaying a fixed script.
Lessons Learned
- Any field visible on a returned API object should be assumed writable until proven otherwise — request/response schemas need to be strictly separated (allow-listed) from internal ORM models, especially for privilege-sensitive fields like
is_superuser. - A “read-only” admin primitive can be just as dangerous as a write primitive if its authorization check only gates on a coarse role flag rather than a specific, minimal capability (e.g. “read app config” vs. “read any file on disk”).
- Secrets that leak into logs aren’t limited to secrets fields — a user’s own typo can put a plaintext password into a username field, and that field is rarely treated as sensitive by log-retention policy.
- Novel PAM gates in front of
sudodon’t have to be unautomatable — anything that produces structured, parseable feedback (a hint string) can be solved programmatically with the right constraint logic and a PTY to satisfy the terminal check.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- BackendTwo — Official HackTheBox Writeup (HTB Document No. D24.100.267, prepared by C4rm3l0, machine author ippsec) — used only to corroborate the underlying mechanics of the mass-assignment flaw, the FastAPI JWT-secret-from-environment pattern, and the PAM-Wordle gate; all IPs, credentials, and commands in this writeup are from the author’s own run.