-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The forbidden-patterns list was the sandbox\n\nThe vulnerable code is at `litellm/proxy/guardrails/guardrail_hooks/custom_code/code_validator.py` in commit `72a461b`. Sixty-three lines. The substantive content is the `FORBIDDEN_PATTERNS` list:\n\n```python\nFORBIDDEN_PATTERNS: List[Tuple[str, str]] = [\n # Import statements\n (r\"\\bimport\\s+\", \"import statements are not allowed\"),\n (r\"\\bfrom\\s+\\w+\\s+import\\b\", \"from...import statements are not allowed\"),\n (r\"__import__\\s*\\(\", \"__import__() is not allowed\"),\n # Dangerous builtins\n (r\"\\bexec\\s*\\(\", \"exec() is not allowed\"),\n (r\"\\beval\\s*\\(\", \"eval() is not allowed\"),\n (r\"\\bcompile\\s*\\(\", \"compile() is not allowed\"),\n (r\"\\bopen\\s*\\(\", \"open() is not allowed\"),\n (r\"\\bgetattr\\s*\\(\", \"getattr() is not allowed\"),\n (r\"\\bsetattr\\s*\\(\", \"setattr() is not allowed\"),\n (r\"\\bdelattr\\s*\\(\", \"delattr() is not allowed\"),\n (r\"\\bglobals\\s*\\(\", \"globals() is not allowed\"),\n (r\"\\blocals\\s*\\(\", \"locals() is not allowed\"),\n (r\"\\bvars\\s*\\(\", \"vars() is not allowed\"),\n (r\"\\bdir\\s*\\(\", \"dir() is not allowed\"),\n (r\"\\bbreakpoint\\s*\\(\", \"breakpoint() is not allowed\"),\n (r\"\\binput\\s*\\(\", \"input() is not allowed\"),\n # Dangerous dunder access\n (r\"__builtins__\", \"__builtins__ access is not allowed\"),\n (r\"__globals__\", \"__globals__ access is not allowed\"),\n (r\"__code__\", \"__code__ access is not allowed\"),\n (r\"__subclasses__\", \"__subclasses__ access is not allowed\"),\n (r\"__bases__\", \"__bases__ access is not allowed\"),\n (r\"__mro__\", \"__mro__ access is not allowed\"),\n (r\"__class__\", \"__class__ access is not allowed\"),\n (r\"__dict__\", \"__dict__ access is not allowed\"),\n (r\"__getattribute__\", \"__getattribute__ access is not allowed\"),\n (r\"__reduce__\", \"__reduce__ access is not allowed\"),\n (r\"__reduce_ex__\", \"__reduce_ex__ access is not allowed\"),\n # OS/system access\n (r\"\\bos\\.\", \"os module access is not allowed\"),\n (r\"\\bsys\\.\", \"sys module access is not allowed\"),\n (r\"\\bsubprocess\\.\", \"subprocess module access is not allowed\"),\n (r\"\\bshutil\\.\", \"shutil module access is not allowed\"),\n (r\"\\bctypes\\.\", \"ctypes module access is not allowed\"),\n (r\"\\bsocket\\.\", \"socket module access is not allowed\"),\n (r\"\\bpickle\\.\", \"pickle module access is not allowed\"),\n]\n```\n\nThirty-four regex tuples. Imports, the dynamic-execution and introspection builtins, the type-hierarchy and code-object dunders, the seven standard-library modules that reach the OS. Read as a flat list, this is the developer's enumerated threat model: every Python primitive they could think of that lets sandboxed code escape, written down with an error message explaining why.\n\n`validate_custom_code()` takes the user's source as a string and calls `re.search(pattern, code)` against each entry. It raises `CustomCodeValidationError` on the first match and runs ONCE, before the source is handed to `compile()` and `exec()`. After validation passes, the source is compiled to bytecode and executed against `exec_globals = get_custom_code_primitives().copy()` with `exec_globals[\"__builtins__\"] = {}` set on top. Empty builtins as the second layer.\n\nThat two-layer model was the sandbox. Regex on source, empty builtins on exec.\n\n## The Python interpreter does not read regex\n\nX41 D-Sec's bypass uses four moves. None of them is exotic. Each is what a dynamic interpreter does for a living.\n\n**Move one: string concatenation.** The pattern `(r\"__globals__\", ...)` matches the literal eleven-character substring `__globals__` in the source. The expression `\"_\"+\"_gl\"+\"ob\"+\"als\"+\"_\"+\"_\"` contains those eleven characters spread across six string literals separated by quote-marks and `+` operators. The continuous substring `__globals__` does not occur at any character offset in the source string. `re.search()` returns `None`. At runtime, the `+` operator reduces the six literals to one string of value `__globals__`. The regex check sees a binary expression; the interpreter computes the string.\n\n**Move two: `mro()` instead of `__mro__`.** The blocklist matches the dunder spelling. It does not match the bare token `mro`. The bound method `str.mro()` returns the same tuple as the attribute `str.__mro__`. `str.mro()[1]` is `object`, the top of every type hierarchy in Python.\n\n**Move three: `gi_code` instead of `__code__`.** The blocklist matches `__code__`. It does not match `gi_code`. A generator iterator's `gi_code` attribute is the same code object as its function's `__code__`. Inline a one-shot generator and read `gi_code`; the blocklist never names either token.\n\n**Move four: `code.replace(co_names=...)`.** Python's code objects are immutable but expose a `replace()` method that returns a new code with selected fields swapped. `co_names` is the tuple of global names the bytecode resolves at execution time. Swap it, and the bytecode now reads from a name the source never spelled. Neither `replace` nor `co_names` is in the blocklist.\n\nThreaded together, the chain is: build the string `__setattr__` by concatenation, fetch `object.__setattr__` by traversing `str.mro()[1]`, install a forged code object (forged via `replace(co_names=...)`) on a sandbox-supplied helper function, then call the helper. The helper's bytecode now resolves `__import__` against the real interpreter builtins, which are reachable because the empty `__builtins__` dict only governs name lookup for the compiled source string; it does not govern what bytecode rewritten in flight resolves against. From `__import__` the attacker has `os`. From `os.popen(\"id\").read()` the attacker has shell. The advisory shows the response: `uid=0(root) gid=0(root)`. Default Docker.\n\n## The check ran against the spelling, the interpreter ran against the value\n\nThe structural reason this sandbox failed is not a missing pattern. Add `gi_code` to the blocklist and move three closes. Add `mro` and move two closes. Add `replace` and move four closes. The next bypass uses `__class_getitem__`, or coroutine `cr_code`, or chained `getattr(o, \"_\"+\"_globals_\"+\"_\")` rewritten to a homoglyph, or the next dunder. None of these additions moves the gate to a layer that can win.\n\nThe check runs against source text. The source text is one Python expression syntax: literal strings, identifiers, binary operators, attribute access, calls. The interpreter that executes the source is the runtime that, by design, resolves identifiers against globals, computes strings from binary expressions, dispatches attribute access through `__getattribute__`, and constructs values at execution time. The interpreter does not consult the regex engine before doing those things. The regex engine reads the spelling; the interpreter computes the value. They are not looking at the same artifact.\n\nThis is the source-grep-is-not-a-sandbox pattern. Substring or regex checks on source text applied to inputs handed to a dynamic interpreter can be defeated by reconstructing forbidden tokens from pieces the check never matched. Concatenation, attribute traversal, decoding, and `eval` are the four moves. Each is the language's defining feature; the interpreter is named \"dynamic\" because of them.\n\nThe blocklist becomes documentation of the author's threat model, written in a notation the runtime cannot read.\n\n## The fix the vendor wrote in February is the bug this CVE names\n\n`code_validator.py` was added to LiteLLM on 2026-02-24 in commit `e50b4486`, authored by a different developer, titled \"fix Unauthenticated RCE and Sandbox Escape in Custom Code Guardrail.\" The same commit added two defenses to `/guardrails/test_custom_code`: a proxy-admin authentication dependency (`Depends(user_api_key_auth)` plus a role check rejecting non-admins with HTTP 403), and the thirty-four regex patterns. The authentication closed the unauthenticated half of X41's earlier report. The regex was the post-auth defense in depth.\n\nThe X41 disclosure timeline reads: Feb 13 issue identified, Feb 18 vendor acknowledged with a \"fix in one week\" promise, Feb 24 the fix shipped (this commit). Then a fifty-day gap with X41 following up multiple times in late March, the vendor asking X41 to refile through GitHub's security reporting channel. Apr 7 X41 notified the vendor the embargo expired the next day. Apr 8 X41 published. Apr 15 the vendor deleted `code_validator.py`.\n\nCVE-2026-40217's affected range is 1.81.8 through 1.83.10. Those are the versions that shipped `code_validator.py` between Feb 24 and Apr 15. The CVE record covers the seven weeks when the vendor's response to a privately reported sandbox escape was itself the post-auth sandbox escape.\n\n## The replacement moves the gate to the AST\n\nThe Apr 15 patch (commit `0a1b442`) deletes `code_validator.py` and adds `sandbox.py`. The new compile path is:\n\n```python\nreturn compile_restricted(\n source=source,\n filename=filename,\n mode=\"exec\",\n policy=AsyncAwareTransformer,\n)\n```\n\n`compile_restricted()` (from the RestrictedPython package) parses source to an AST, walks the tree with the transformer, and rejects forbidden constructs at NODE level: `Import`, `ImportFrom`, `Exec`, `Eval`, `ClassDef`, every dunder attribute access, every node whose semantics include the dangerous machinery. The check is now applied to the parsed program, not its source text. A binary expression of two string-literal nodes is not the string `__globals__`; the AST gate does not match it as one. The runtime sees the computed string when the expression evaluates, and that is where the `_getattr_` hook (which RestrictedPython binds to `safer_getattr` in the executing globals) catches every attribute access whose computed name starts with underscore.\n\nThe gate runs in two places now: at compile, against structural-dangerous nodes; at runtime, against computed strings used as attribute names. Neither place is the source-text layer. The author's threat model is still present, in `safe_builtins` and the AST transformer's denylist, but it is enforced by tools that see what the interpreter sees.\n\nSixty-three lines deleted. Zero new lines that scan source code. The thirty-four-pattern blocklist is gone because the blocklist was never going to work.\n\nPoC: [X41-2026-001](https://x41-dsec.de/lab/advisories/x41-2026-001-litellm/)","closing_line":"The forbidden-patterns list is the developer's threat model. The Python interpreter does not read regex.","hook_md":"LiteLLM's `POST /guardrails/test_custom_code` endpoint compiles and runs admin-supplied Python on the proxy host. Before the compile step, the source string is filtered by `validate_custom_code()`, which scans it against thirty-four forbidden regex patterns: `__globals__`, `__class__`, `__import__\\s*\\(`, `\\bos\\.`, thirty more. CVE-2026-40217 is X41 D-Sec's bypass of that filter. The bypass is one line: `\"_\"+\"_gl\"+\"ob\"+\"als\"+\"_\"+\"_\"`. The patch is to delete `code_validator.py`.","post_id":285,"slug":"litellm-sandbox-was-a-regex-blocklist","title":"CVE-2026-40217: LiteLLM's Sandbox Was 34 Regex Patterns. The Fix Was to Delete the File.","type":"initial","unreadable_sentence":"The forbidden-patterns list is the developer's threat model. The Python interpreter does not read regex."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatuUzAAKCRDeZjl4jgkQ JnLzAQC72NlwaJvu20FvCJXU0ICHX1QaDgwtp44IXqf48Qa3rAEA1DqeczCLYU/7 KsKkv2LV8WqlfXlVqdsKweuYbpK3+ws= =zqGW -----END PGP SIGNATURE-----