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.
CVE-2026-40217: LiteLLM's Sandbox Was 34 Regex Patterns. The Fix Was to Delete the File.
pattern
cve
proof of concept
The forbidden-patterns list was the sandbox
The 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:
FORBIDDEN_PATTERNS: List[Tuple[str, str]] = [
# Import statements
(r"\bimport\s+", "import statements are not allowed"),
(r"\bfrom\s+\w+\s+import\b", "from...import statements are not allowed"),
(r"__import__\s*\(", "__import__() is not allowed"),
# Dangerous builtins
(r"\bexec\s*\(", "exec() is not allowed"),
(r"\beval\s*\(", "eval() is not allowed"),
(r"\bcompile\s*\(", "compile() is not allowed"),
(r"\bopen\s*\(", "open() is not allowed"),
(r"\bgetattr\s*\(", "getattr() is not allowed"),
(r"\bsetattr\s*\(", "setattr() is not allowed"),
(r"\bdelattr\s*\(", "delattr() is not allowed"),
(r"\bglobals\s*\(", "globals() is not allowed"),
(r"\blocals\s*\(", "locals() is not allowed"),
(r"\bvars\s*\(", "vars() is not allowed"),
(r"\bdir\s*\(", "dir() is not allowed"),
(r"\bbreakpoint\s*\(", "breakpoint() is not allowed"),
(r"\binput\s*\(", "input() is not allowed"),
# Dangerous dunder access
(r"__builtins__", "__builtins__ access is not allowed"),
(r"__globals__", "__globals__ access is not allowed"),
(r"__code__", "__code__ access is not allowed"),
(r"__subclasses__", "__subclasses__ access is not allowed"),
(r"__bases__", "__bases__ access is not allowed"),
(r"__mro__", "__mro__ access is not allowed"),
(r"__class__", "__class__ access is not allowed"),
(r"__dict__", "__dict__ access is not allowed"),
(r"__getattribute__", "__getattribute__ access is not allowed"),
(r"__reduce__", "__reduce__ access is not allowed"),
(r"__reduce_ex__", "__reduce_ex__ access is not allowed"),
# OS/system access
(r"\bos\.", "os module access is not allowed"),
(r"\bsys\.", "sys module access is not allowed"),
(r"\bsubprocess\.", "subprocess module access is not allowed"),
(r"\bshutil\.", "shutil module access is not allowed"),
(r"\bctypes\.", "ctypes module access is not allowed"),
(r"\bsocket\.", "socket module access is not allowed"),
(r"\bpickle\.", "pickle module access is not allowed"),
]Thirty-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.
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.
That two-layer model was the sandbox. Regex on source, empty builtins on exec.
The Python interpreter does not read regex
X41 D-Sec's bypass uses four moves. None of them is exotic. Each is what a dynamic interpreter does for a living.
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.
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.
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.
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.
Threaded 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.
The check ran against the spelling, the interpreter ran against the value
The 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.
The 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.
This 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.
The blocklist becomes documentation of the author's threat model, written in a notation the runtime cannot read.
The fix the vendor wrote in February is the bug this CVE names
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.
The 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.
CVE-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.
The replacement moves the gate to the AST
The Apr 15 patch (commit 0a1b442) deletes code_validator.py and adds sandbox.py. The new compile path is:
return compile_restricted(
source=source,
filename=filename,
mode="exec",
policy=AsyncAwareTransformer,
)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.
The 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.
Sixty-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.
PoC: X41-2026-001
The forbidden-patterns list is the developer's threat model. The Python interpreter does not read regex.