# CVE-2026-93674 — Root Cause Analysis

## Summary

Langflow OSS through 1.12.2 exposes `POST /api/v1/validate/code`, a "validation-only"
endpoint whose backend (`lfx.custom.validate.validate_code`) calls
`importlib.import_module()` on **every import statement** found in attacker-supplied
code. Importing a module executes its top-level code, so the endpoint runs arbitrary
Python inside the Langflow server process during what is documented as a non-executing
validation step. Combined with (a) the default single-user configuration
(`LANGFLOW_AUTO_LOGIN=true`, which lets any network client mint a superuser token from
`GET /api/v1/auto_login` with no credentials) and (b) a second remote code-evaluation
sink (`POST /api/v1/custom_component`, which `exec()`s the attacker-supplied component
class body and can plant a malicious module into the server-writable
`site-packages`), a remote unauthenticated attacker obtains arbitrary OS command
execution as the Langflow service user. The vulnerability is fixed in Langflow 1.12.3
by commit `461506ac2f38f70a994b5140572b876448c11e4c` ("fix(security): close
pathlib/io/codecs scanner bypass and stop validate_code from executing imports",
H1-3992099 / LE-2683), which replaces `import_module()` with
`importlib.util.find_spec()` — locate-only, never executing module code.

## Impact

- **Package/component:** `langflow` / `langflow-base` / `lfx` (PyPI), specifically
  `lfx.custom.validate.validate_code` behind the FastAPI route
  `POST /api/v1/validate/code`.
- **Affected versions:** 1.0.0 through 1.12.2 (IBM bulletin / NVD CPE range; the
  vulnerable `importlib.import_module()` loop is present in v1.12.2 and removed in
  v1.12.3).
- **Risk level:** Critical (CVSS 9.8, AV:N/AC:L/PR:N/UI:N). Full remote,
  unauthenticated OS command execution with the privileges of the Langflow service
  account (in the official container: `uid=1000(user) gid=0(root)`), i.e. complete
  compromise of flows, stored credentials/global variables, and any data readable by
  the service.

## Impact Parity

- **Disclosed/claimed maximum impact:** remote (unauthenticated) arbitrary code /
  OS command execution (`code_execution`).
- **Reproduced impact from this run:** remote unauthenticated OS command execution
  through the real HTTP API of the digest-pinned official `langflowai/langflow:1.12.2`
  image: the planted module ran `id` via `subprocess.check_output(..., shell=True)`
  inside the server process; the output (`uid=1000(user) gid=0(root) groups=0(root)`)
  was exfiltrated in-band in the HTTP 500 `detail` field of the very
  `POST /api/v1/validate/code` response, and a unique per-attempt marker file was
  written inside the container filesystem.
- **Parity:** `full`.
- **Not demonstrated:** nothing material — the claimed impact class was reproduced
  end-to-end. (A persistent shell/pivot was not attempted; it is not required for
  parity.)

## Root Cause

`src/lfx/src/lfx/custom/validate.py` (v1.12.2), function `validate_code(code)`:

```python
# Evaluate the import statements
for node in tree.body:
    if isinstance(node, ast.Import):
        for alias in node.names:
            try:
                importlib.import_module(alias.name)   # <-- EXECUTES module top-level code
            except ModuleNotFoundError as e:
                errors["imports"]["errors"].append(str(e))
```

`importlib.import_module()` is not a lookup — it loads and **executes** the module.
Because the endpoint is reachable by any network client under the default
`LANGFLOW_AUTO_LOGIN=true` configuration (the auto-login route issues a superuser
JWT without credentials), an attacker who can place a Python file on any
`sys.path` entry writable by the service account gets it executed by simply sending
`{"code": "import <module>"}`. The official container runs as `uid=1000` and owns
`/app/.venv/lib/python3.14/site-packages`, which is on `sys.path`, so the built-in
custom-component code-evaluation feature (`POST /api/v1/custom_component` →
`build_custom_component_template()` → `exec()` of the class body) provides the
file-write primitive fully remotely:

```python
class Planter(CustomComponent):
    _w = pathlib.Path("/app/.venv/lib/python3.14/site-packages/<mod>.py").write_text(payload)
```

The planted module both writes a unique marker file and raises
`RuntimeError("PLANTED_EXEC:<token>:" + subprocess.check_output("id", shell=True))`.
`validate_code` only catches `ModuleNotFoundError`, so the `RuntimeError` propagates
to the route handler, which returns HTTP 500 with `detail=str(e)` — exfiltrating the
command output directly in the HTTP response.

In 1.12.3 the same request path performs
`importlib.util.find_spec(alias.name.split(".")[0])` and never executes module code;
the identical attacker procedure therefore produces HTTP 200, empty errors, and no
marker file.

- **Fix commit:** `461506ac2f38f70a994b5140572b876448c11e4c`
  (`fix(security): close pathlib/io/codecs scanner bypass and stop validate_code from
  executing imports`, PR #15201, H1-3992099 / LE-2683).
- **Fixed release:** Langflow OSS 1.12.3 (git tag `v1.12.3` =
  `fec71dca901949c09ed4d63315804337cd2eb13d`).

Note on CVE mapping: the IBM bulletin for 1.12.3 lists 25 CVEs without per-CVE commit
mapping. CVE-2026-93674 is the 9.8 PR:N CWE-94 ("code injection / OS command") entry;
the `validate_code` import-execution sink fixed by 461506ac2f is the matching
unauthenticated remote code-execution fix in the 1.12.3 security train. The public
third-party PoC (rmhowe425/POC-CVE-2026-93674) targets the MCP stdio endpoint
(`/api/v2/mcp/servers`), which corresponds to the earlier GHSA-w794-rj3p-xv45 /
CVE-2026-105697 fix (1.10.3) — that allowlist is already present in 1.12.2, so the
MCP path is not the 1.12.2→1.12.3 divergence; the validate/code path is.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; requires docker, curl, jq).
2. The script:
   - Pulls/pins the official images by digest: vulnerable
     `langflowai/langflow@sha256:79c02794adebe82d756b7152ce4feebe4a5426e1faf3fe5b5d0dd08f304510c4`
     (v1.12.2) and fixed
     `langflowai/langflow@sha256:34055a07d446de51760e28dab6332e22624e5f48dca611567779992fc32c5ec0`
     (v1.12.3).
   - Starts **two fresh vulnerable containers** and **two fresh fixed containers**
     (`LANGFLOW_AUTO_LOGIN=true`, the OSS package default), waiting for `/health`.
   - Per attempt: (1) `GET /api/v1/auto_login` with no credentials → superuser JWT;
     (2) `POST /api/v1/custom_component` plants `pruva_planted_<run>_<n>.py` into
     site-packages via class-body `exec()`; (3) `POST /api/v1/validate/code`
     `{"code":"import <module>"}` triggers the vulnerable import execution; the
     marker file is read back out of the container.
   - Vulnerable pass criteria: HTTP 500 + `PLANTED_EXEC:<token>:uid=1000(user)...`
     in the response body + marker file containing the unique token inside the
     container. Fixed pass criteria: HTTP 200, empty errors, no marker.
3. Expected evidence: 2/2 vulnerable attempts execute attacker code; 2/2 fixed
   attempts do not (identical procedure, plant still succeeds on fixed — proving the
   divergence is exactly the validate/code import execution).

## Evidence

- Driver log: `bundle/logs/reproduction_steps.log`
- Image identity: `bundle/logs/repro/image_identity.txt`
- Per-attempt artifacts (`{vuln,fixed}_attempt_{1,2}_*` under
  `bundle/logs/repro/attempts/`): auto-login token responses, plant
  requests/responses, trigger requests/responses, planted module content, marker
  files, container logs. All SHA-256-bound in
  `bundle/repro/runtime_manifest.json`.
- Key excerpt (vulnerable, both attempts):
  `POST /api/v1/validate/code` → `HTTP=500`,
  body `{"detail":"PLANTED_EXEC:PRUVA-CVE-2026-93674-<run>-VULN-<n>:uid=1000(user) gid=0(root) groups=0(root)"}`,
  and `marker.txt` = `PRUVA-CVE-2026-93674-<run>-VULN-<n> uid=1000(user) gid=0(root) groups=0(root)`.
- Key excerpt (fixed, both attempts): identical requests → `HTTP=200`,
  body `{"imports":{"errors":[]},"function":{"errors":[]}}`, empty marker file.
- Environment: Docker on Linux x86_64; images digest-pinned as above; Python 3.14
  inside the container; `LANGFLOW_AUTO_LOGIN=true` (package-level default; the image
  sets it to `false`, which only changes the bootstrap to credential-based login —
  the validate/code sink itself is identical).

## Recommendations / Next Steps

- Upgrade to Langflow OSS **1.12.3** or later (IBM/vendor guidance; the fix replaces
  `import_module()` with `find_spec()` in `validate_code`).
- Until upgraded: do not expose Langflow to untrusted networks; set
  `LANGFLOW_AUTO_LOGIN=false` and strong superuser credentials (raises the bar to
  authenticated, but the sink still executes for any authenticated user on ≤1.12.2);
  restrict file-system write access of the service account to site-packages.
- Defense-in-depth: treat every "validation" endpoint as non-executing (audit for
  other `import_module`/`exec` uses on request paths), and consider read-only root
  filesystems / non-root containers.
- Testing: regression test that `POST /api/v1/validate/code` with an import of a
  planted module never executes it (covered upstream by the lfx-side tests added in
  the fix commit).

## Additional Notes

- **Idempotency:** the script is fully idempotent — each attempt uses a fresh,
  uniquely-named container and a unique module/marker name, and a `trap` removes all
  containers on exit. Re-running produces fresh unique tokens/markers.
- **Repeatability:** the exploit ran twice per side (two fresh vulnerable processes,
  two fresh fixed processes) with identical outcomes.
- **Why two stages:** the CVE sink (`validate_code` import execution) requires an
  importable attacker module. The plant uses Langflow's built-in custom-component
  code-evaluation feature, which behaves identically on 1.12.2 and 1.12.3 — the
  vulnerable/fixed divergence is isolated entirely to the `validate_code` step,
  which is what the 1.12.3 fix changed.
- **Limitations:** none affecting the verdict. The reproduction uses the official
  vendor images at the exact vulnerable/fixed digests; no sanitizers, mocks, or
  instrumentation were used.
