Skip to content

Instantly share code, notes, and snippets.

@13ph03nix
Last active May 31, 2026 15:03
Show Gist options
  • Select an option

  • Save 13ph03nix/9ec616e1fdc77b3673509c60206e827f to your computer and use it in GitHub Desktop.

Select an option

Save 13ph03nix/9ec616e1fdc77b3673509c60206e827f to your computer and use it in GitHub Desktop.

Privilege Escalation from Internal User to Proxy Admin via Unrestricted allowed_routes in /key/generate

Summary

A low-privilege internal_user can escalate to proxy_admin by exploiting a privilege escalation vulnerability in the virtual key generation feature. The /key/generate endpoint allows any authenticated user with key management permissions (i.e., internal_user) to create keys with arbitrary allowed_routes, including routes normally restricted to administrators. By generating a key with access to /user/update, the attacker can then modify their own user_role to proxy_admin, gaining full administrative control over the LiteLLM proxy.

Background

Role-Based Access Control

LiteLLM Proxy defines a hierarchy of user roles, each with access to a specific set of API routes:

Role Description Key Route Permissions
proxy_admin Full administrator All routes — bypasses all route checks
proxy_admin_viewer Read-only admin Admin read routes (/user/info, /team/info, etc.)
internal_user Standard user LLM API routes + spend tracking + key management (/key/generate, /key/update, /key/delete, etc.)
internal_user_viewer Read-only user LLM API routes + spend tracking only

Route access is enforced by non_proxy_admin_allowed_routes_check() in route_checks.py. If the user's role is proxy_admin, all checks are skipped. For other roles, the function evaluates a cascading if/elif chain that maps user_role to a predefined route list (e.g., internal_userinternal_user_routes).

Virtual Keys and allowed_routes

An internal_user can create virtual API keys via /key/generate. Each key is stored in LiteLLM_VerificationToken and associated with the creator's user_id. Subsequent API requests authenticate by presenting one of these keys (Authorization: Bearer sk-xxx), and the system resolves the key back to the owning user to determine their role.

Each key has an optional allowed_routes field. Its intended purpose is to restrict a key's access to a subset of the user's permitted routes — for example, creating a key that can only call /chat/completions and nothing else. This allows users to issue least-privilege keys for specific use cases.

However, the implementation does not enforce that allowed_routes must be a subset of the user's role-based permissions. As detailed below, this turns a restriction mechanism into a privilege escalation vector.

Root Cause

The vulnerability is a chain of two flaws: an unrestricted allowed_routes parameter in key generation, combined with a missing field-level check in the user update endpoint.

Flaw 1: /key/generate allows allowed_routes to exceed the user's role permissions

When an internal_user calls /key/generate, the authorization logic (_is_allowed_to_make_key_request) only validates that the user_id on the new key matches the caller's own identity. The allowed_routes parameter is not validated at all — there is no check to ensure the specified routes fall within the routes permitted by the caller's role:

# key_management_endpoints.py:148-179
def _is_allowed_to_make_key_request(
    user_api_key_dict: UserAPIKeyAuth,
    user_id: Optional[str],
    team_id: Optional[str],
) -> bool:
    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
        return True

    if user_id is not None:
        assert user_id == user_api_key_dict.user_id  # only validates user_id ownership

    return True  # ← allowed_routes is never checked

The allowed_routes value from the request body flows directly into the database:

# key_management_endpoints.py:2605
key_data = {
    "token": token,
    ...
    "allowed_routes": allowed_routes or [],  # ← stored as-is, no filtering
}

This means an internal_user can create a key with allowed_routes: ["/user/update"], even though /user/update is an admin-only route (management_routes) that internal_user cannot normally access.

When this key is subsequently used to authenticate, the route check in non_proxy_admin_allowed_routes_check() evaluates a cascading if/elif structure. The role-based checks (which would deny internal_user access to /user/update) come first, but when they don't match, the function falls through to a token-level override:

# route_checks.py:148-258 (simplified)
def non_proxy_admin_allowed_routes_check(user_obj, _user_role, route, valid_token, ...):

    if RouteChecks.is_llm_api_route(route):
        pass
    elif _user_role == LitellmUserRoles.INTERNAL_USER.value
        and RouteChecks.check_route_access(route, LiteLLMRoutes.internal_user_routes.value):
        pass  # /user/update is NOT in internal_user_routes → falls through

    # ... other role-based checks also don't match ...

    elif valid_token.allowed_routes is not None:      # ← key has ["/user/update"]
        route_allowed = False
        for allowed_route in valid_token.allowed_routes:
            if RouteChecks._route_matches_allowed_route(
                route=route, allowed_route=allowed_route  # exact match → True
            ):
                route_allowed = True                  # ← PASS, regardless of user role
                break

    else:
        RouteChecks._raise_admin_only_route_exception(...)  # default deny

The key's allowed_routes acts as an independent grant rather than a restriction — it is evaluated as an alternative to the role-based check, not intersected with it. This allows the internal_user to access any route they specify in allowed_routes, bypassing the role-based access control entirely.

Flaw 2: /user/update allows users to modify their own user_role

Once the internal_user gains access to /user/update via the key's allowed_routes, they can escalate their role because this endpoint lacks field-level authorization.

The endpoint allows users to update their own record (can_user_call_user_update checks user_id match), but does not restrict which fields a non-admin user can modify:

# internal_user_endpoints.py:1013-1024
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 user_role field from the request body passes through _update_internal_user_params() without any filtering and is written directly to the database.

Proof of Concept

Prerequisites: A running LiteLLM proxy with an authenticated internal_user account.

Step 1: Generate a key with allowed_routes: ["/user/update"]:

1

Step 2: Use the new key to escalate own role to proxy_admin:

2

Step 3: Re-login to verify — successfully escalated to proxy_admin.

PoC video: https://drive.google.com/file/d/1g978pLQGQUFl12Nu1fZWd5QaJHrvVVK1/view?usp=sharing

Impact

An attacker with internal_user privileges can achieve full administrative takeover — gaining proxy_admin privileges, managing all users, teams, keys, models, and accessing all prompt history.

Suggested Remediation

  1. Validate allowed_routes during key generation (Primary): In common_key_access_checks(), reject allowed_routes entries that exceed the caller's role-based route set.

  2. Add field-level authorization to /user/update (Defense in Depth): Strip sensitive fields (user_role, spend) when the caller is not proxy_admin.

  3. Enforce role-based routes take precedence over token-level routes: In non_proxy_admin_allowed_routes_check(), intersect valid_token.allowed_routes with the user's role-permitted routes instead of using it as a standalone override.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment