Oct 24, 2026·9 min read·7 visits
LiteLLM reuses the same AES-256-GCM encryption context without domain separation for both stored metadata secrets and administrative session bearer tokens. Authenticated internal users can store forged administrator JSON payloads inside API key metadata, retrieve the encrypted string, and replay it as an HTTP Bearer token to gain full proxy_admin privileges and remote command execution.
LiteLLM versions 1.87.0 through 1.104.0-rc.1 contain a cryptographic domain separation vulnerability (CWE-269, CWE-345, CWE-441) that allows an authenticated low-privilege user to escalate privileges to full proxy administrator. The application reuses a single symmetric cryptographic context and key derivation path for both at-rest secret encryption and ephemeral UI/CLI session authentication tokens without providing Associated Authenticated Data (AAD). By submitting crafted JSON session claims into user-accessible encrypted metadata fields, an attacker can use the API-key generation flow as an encryption oracle, extract the resulting ciphertext, and replay it as a bearer token to achieve administrative control and subsequent remote command execution via the Model Context Protocol (MCP) stdio interface.
LiteLLM is an open-source AI Gateway and proxy server developed by BerriAI that standardizes API calls across multiple Large Language Model (LLM) providers. In multi-tenant or enterprise deployments, LiteLLM manages authentication, cost tracking, logging callbacks, and role-based access control (RBAC) across low-privilege internal users (internal_user) and system administrators (proxy_admin). To protect sensitive operational parameters at rest—such as third-party API keys and logging callback credentials—LiteLLM encrypts configuration fields using a master symmetric key (LITELLM_SALT_KEY or MASTER_KEY).
Starting in version 1.87.0, LiteLLM introduced session authentication mechanisms (ExperimentalUIJWTToken) for UI and CLI workflows that issued encrypted JSON payloads as bearer tokens rather than standard signed JWTs. Instead of establishing a separate cryptographic key or domain-specific context for session tokens, the authentication handler directly invoked the general-purpose storage encryption routine encrypt_value_helper(). In versions 1.87.0 through 1.90.x, this functionality required EXPERIMENTAL_UI_LOGIN=true to be explicitly enabled, whereas versions 1.91.0 and later enabled this code path in the default configuration.
This design flaw results in a confused-deputy vulnerability (CWE-441) combined with improper privilege management (CWE-269) and insufficient verification of data authenticity (CWE-345). Because both at-rest secrets and active bearer tokens share the exact same key derivation and AES-256-GCM encryption parameters without cryptographic binding to their intended purpose, any API endpoint that encrypts user-controlled strings and returns the ciphertext acts as an encryption oracle. An authenticated low-privilege user can submit a JSON structure containing `
The root cause of CVE-2026-114612 is the absence of cryptographic domain separation in litellm/proxy/common_utils/encrypt_decrypt_utils.py. When encrypting values using Authenticated Encryption with Associated Data (AEAD) via AES-256-GCM, the underlying _encrypt_aes_gcm() and _decrypt_aes_gcm() functions derived a 256-bit key by taking a single SHA-256 hash of the configured signing_key (LITELLM_SALT_KEY or MASTER_KEY). Crucially, both functions hardcoded the Associated Authenticated Data (aad) parameter in AESGCM.encrypt() and AESGCM.decrypt() to None.
In AEAD constructions, the aad parameter binds ciphertext to a specific context, protocol header, or data type so that a valid ciphertext generated in one subsystem cannot be transplanted into another. Because aad was omitted, all ciphertexts produced by encrypt_value_helper() were cryptographically interchangeable across the entire application. The only differentiator applied to stored values was an outer string prefix (litellm_enc::) prepended outside the authenticated encryption boundary, while the inner ciphertext began with the version marker v2:gcm: followed by the base64url-encoded 12-byte nonce, ciphertext, and 16-byte authentication tag.
Concurrently, the session token handler in litellm/proxy/auth/auth_checks.py implemented ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token() and get_cli_jwt_auth_token() by serializing user session models to JSON and passing them directly to encrypt_value_helper(). When authenticating incoming HTTP requests, ExperimentalUIJWTToken.get_key_object_from_ui_hash_key() took the bearer token from the Authorization header and passed it to decrypt_value_helper(). If decryption succeeded and the resulting plaintext parsed as valid JSON containing a user_role attribute, the proxy trusted the deserialized role without verifying how or where the ciphertext was originally minted.
Because the outer litellm_enc:: marker was not authenticated by the GCM tag, an attacker could strip it from any reflected secret stored in database metadata, leaving a bare v2:gcm:... string that was byte-for-byte identical in structure to a legitimate UI or CLI session token.
Prior to the patch, litellm/proxy/auth/auth_checks.py minted and validated session tokens by directly calling encrypt_value_helper and decrypt_value_helper. In litellm/proxy/common_utils/encrypt_decrypt_utils.py, the AES-256-GCM implementation passed None for the aad argument.
# Vulnerable implementation in litellm/proxy/common_utils/encrypt_decrypt_utils.py
def _derive_key(signing_key: str) -> bytes:
return hashlib.sha256(signing_key.encode()).digest()
def _encrypt_aes_gcm(value: str, signing_key: str) -> str:
"""Encrypt under AES-256-GCM and return the versioned ``v2:gcm:`` string."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce: Final = os.urandom(12)
# VULNERABILITY: Associated Authenticated Data (AAD) is set to None,
# providing no domain separation between stored secrets and bearer tokens.
blob: Final = AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), None)
return _V2_GCM_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode("utf-8")
def _decrypt_aes_gcm(value: str, signing_key: str) -> str:
"""Decrypt a versioned ``v2:gcm:`` string produced by :func:`_encrypt_aes_gcm`."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
raw: Final = base64.urlsafe_b64decode(value[len(_V2_GCM_PREFIX) :])
nonce, blob = raw[:12], raw[12:]
# VULNERABILITY: Decrypts any v2:gcm: payload encrypted with the same key.
return AESGCM(_derive_key(signing_key)).decrypt(nonce, blob, None).decode("utf-8")In commit 1d5fdc87fed66b49e1df8cf7d708ad93249eb7d0 (and its branch equivalents), the maintainers refactored the encryption utilities to support explicit AAD binding via low-level _seal_aes_gcm() and _open_aes_gcm() primitives. They introduced dedicated encrypt_bearer_token() and decrypt_bearer_token() functions that enforce a mandatory prefix (LITELLM_SESSION_TOKEN_PREFIX = "litellm_login_") both as a wire-format prefix and as the cryptographic AAD passed into AES-GCM.
# Patched implementation in litellm/proxy/common_utils/encrypt_decrypt_utils.py
def _seal_aes_gcm(value: str, signing_key: str, aad: bytes | None) -> bytes:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce: Final = os.urandom(12)
# Passes explicit AAD into AESGCM.encrypt to bind ciphertext to its context
return nonce + AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), aad)
def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Verifies the GCM authentication tag against the expected AAD
return AESGCM(_derive_key(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8")
def encrypt_bearer_token(value: str, prefix: str) -> str:
"""AES-256-GCM as unpadded base64url behind ``prefix``, which is also the AAD so a token can't change kind."""
salt_key: Final = _get_salt_key()
if not isinstance(salt_key, str):
raise ValueError("Set LITELLM_SALT_KEY or a master key to mint bearer tokens")
# Binds the token ciphertext to 'litellm_login_' via AAD
sealed: Final = _seal_aes_gcm(value=value, signing_key=salt_key, aad=prefix.encode("utf-8"))
return prefix + base64.urlsafe_b64encode(sealed).decode("ascii").rstrip("=")
def decrypt_bearer_token(token: str, prefix: str) -> str | None:
"""None unless ``token`` came from :func:`encrypt_bearer_token` with the same ``prefix``."""
salt_key: Final = _get_salt_key()
if not isinstance(salt_key, str) or not token.startswith(prefix):
return None
encoded: Final = token.removeprefix(prefix)
try:
sealed: Final = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
return _open_aes_gcm(sealed=sealed, signing_key=salt_key, aad=prefix.encode("utf-8"))
except Exception:
return NoneCorrespondingly, ExperimentalUIJWTToken in litellm/proxy/auth/auth_checks.py was updated to call encrypt_bearer_token(..., prefix=LITELLM_SESSION_TOKEN_PREFIX) and decrypt_bearer_token(..., prefix=LITELLM_SESSION_TOKEN_PREFIX).
> [!NOTE]
> Fix Completeness Assessment: The patch completely resolves the cross-domain cipher replay vulnerability. General at-rest encryption in _encrypt_aes_gcm() continues to pass aad=None and prepend v2:gcm:, whereas bearer tokens require the litellm_login_ prefix and validate aad=b"litellm_login_" during GCM tag verification. Even if an attacker strips litellm_enc::v2:gcm: from an encrypted metadata value and prepends litellm_login_, _open_aes_gcm() will reject the payload with an InvalidTag exception because the authentication tag was computed with aad=None.
Exploiting CVE-2026-114612 requires network access to the LiteLLM HTTP API and a valid low-privilege credential (internal_user). In LiteLLM versions 1.91.0 and later, no special configuration flags are required; in versions 1.87.0 through 1.90.x, EXPERIMENTAL_UI_LOGIN must be enabled. The attack proceeds in four distinct phases: oracle encryption, ciphertext extraction, prefix manipulation, and administrative token replay.
First, the attacker invokes an endpoint accessible to internal_user accounts that accepts user-defined secrets and encrypts them at rest. As demonstrated in the official regression test (tests/e2e/other/test_session_token_e2e.py), the /key/generate endpoint accepts a metadata.logging array containing per-key callback configurations such as langfuse_secret_key. The attacker sets langfuse_secret_key to a serialized JSON string matching the schema expected by ExperimentalUIJWTToken.get_key_object_from_ui_hash_key():
POST /key/generate HTTP/1.1
Host: litellm-proxy.internal:4000
Authorization: Bearer sk-low-privilege-internal-user-key
Content-Type: application/json
{
"key_alias": "session-oracle-key",
"metadata": {
"logging": [
{
"callback_name": "langfuse",
"callback_vars": {
"langfuse_secret_key": "{\"token\": \"forged-admin-session\", \"user_id\": \"attacker-admin\", \"user_role\": \"proxy_admin\", \"team_id\": \"litellm-dashboard\"}"
}
}
]
}
}Second, the attacker queries /key/info for the newly created key. LiteLLM retrieves the key record from the database and returns the metadata object containing the encrypted secret in the format litellm_enc::v2:gcm:<base64url-nonce-ciphertext-tag>.
Third, the attacker strips the outer storage prefix litellm_enc:: from the returned string, isolating the inner v2:gcm:<base64url-nonce-ciphertext-tag> payload. Fourth, the attacker submits this string as an HTTP Bearer token to administrative endpoints such as /user/list or the Model Context Protocol (MCP) management routes:
GET /user/list HTTP/1.1
Host: litellm-proxy.internal:4000
Authorization: Bearer v2:gcm:<base64url-nonce-ciphertext-tag>When LiteLLM processes this request, get_key_object_from_ui_hash_key() decrypts the v2:gcm: payload using the proxy's salt key, parses the forged JSON claims, and constructs an authenticated UserAPIKeyAuth session with user_role="proxy_admin". From this position, the attacker can register an MCP server configured with the stdio transport to execute arbitrary operating system commands on the underlying host.
CVE-2026-114612 carries a CVSS v3.1 base score of 9.9 (Critical) with the vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H. The Scope metric is marked as Changed (S:C) because escalating privileges within the LiteLLM authorization layer directly enables post-exploitation code execution on the underlying host operating system via administrative features.
Upon achieving proxy_admin privileges, an attacker gains unrestricted read and write access to all resources managed by the AI Gateway. In terms of Confidentiality (C:H), the attacker can retrieve all stored upstream LLM provider API keys (OpenAI, Anthropic, AWS Bedrock, Azure, Vertex AI), read prompt and completion logs across all tenants, and extract database or callback credentials. Furthermore, because the attacker has administrative control over the proxy configuration, they can inspect or manipulate virtual keys belonging to any organization or team.
In terms of Integrity (I:H) and Availability (A:H), the attacker can modify model routing rules to redirect enterprise LLM traffic to attacker-controlled endpoints, alter spend limits, revoke legitimate API keys, or execute arbitrary system commands through the MCP stdio client configuration. Host-level command execution allows the attacker to access environment variables—including the plaintext LITELLM_SALT_KEY, MASTER_KEY, and DATABASE_URL—enabling persistent compromise of the backend PostgreSQL database and adjacent cloud infrastructure.
Organizations running LiteLLM Proxy must upgrade to a patched release that enforces cryptographic domain separation for bearer tokens. BerriAI released patches across five active release tracks: 1.100.4, 1.101.3, 1.102.2, 1.103.1, and 1.104.0-rc.2. Upgrading immediately invalidates any v2:gcm: ciphertexts replayed as bearer tokens because decrypt_bearer_token() strictly requires the litellm_login_ prefix and verifies the corresponding AEAD Associated Authenticated Data.
For environments running versions 1.87.0 through 1.90.x where an immediate upgrade is not feasible, administrators can mitigate the vulnerability by ensuring EXPERIMENTAL_UI_LOGIN is unset or explicitly set to false in the proxy environment and configuration files. In versions 1.91.0 and later, the vulnerable code path is active by default, making version upgrades or upstream reverse-proxy filtering mandatory.
As an immediate compensating control at the network perimeter, security teams can configure a Web Application Firewall (WAF) or reverse proxy (such as NGINX or Envoy) to block incoming HTTP requests where the Authorization header contains a Bearer token beginning with v2:gcm: or litellm_enc::. Additionally, if a vulnerable LiteLLM instance was exposed to untrusted internal users, administrators should rotate LITELLM_SALT_KEY (using LiteLLM's key rotation utilities to re-encrypt database secrets), rotate MASTER_KEY, and audit database tables (LiteLLM_VerificationToken and MCP server configurations) for unauthorized administrative entries or anomalous langfuse_secret_key metadata values.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
litellm BerriAI | >= 1.87.0, < 1.100.4 | 1.100.4 |
litellm BerriAI | >= 1.101.0, < 1.101.3 | 1.101.3 |
litellm BerriAI | >= 1.102.0, < 1.102.2 | 1.102.2 |
litellm BerriAI | >= 1.103.0, < 1.103.1 | 1.103.1 |
litellm BerriAI | >= 1.104.0-rc.1, < 1.104.0-rc.2 | 1.104.0-rc.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-269 / CWE-345 / CWE-441 |
| Attack Vector | Network (Authenticated Low-Privilege API) |
| CVSS v3.1 Score | 9.9 (Critical) |
| EPSS Score | 0.24% (13.63th Percentile) |
| Primary Impact | Privilege Escalation to proxy_admin & RCE via MCP stdio |
| Exploit Status | Proof-of-Concept (Official E2E Regression Test) |
| CISA KEV Status | Not Listed |
The software does not properly assign, modify, track, or check privileges for an actor, creating an unintended sphere of control for that actor. Additionally classified under CWE-345 (Insufficient Verification of Data Authenticity) and CWE-441 (Unintended Proxy or Intermediary / 'Confused Deputy').
A resource allocation vulnerability (CWE-770) exists in the Server-Sent Events (SSE) parser within the Node.js undici HTTP client library. In affected versions, the EventSourceStream implementation fails to restrict the count of internal Buffer chunk references and accumulated string nodes prior to event dispatch boundaries. A malicious or compromised SSE endpoint can exploit this behavior by streaming fragmented, unterminated lines or continuous sequences of short data fields without empty separator lines, bypassing configured byte-size limits and causing a fatal Out-of-Memory (OOM) crash in the client Node.js process.
CVE-2026-109869 is a high-severity denial-of-service vulnerability in undici, the default HTTP/1.1 client and Fetch implementation for Node.js. When a server responds with more than five Content-Encoding directives and prematurely closes the connection before transmitting the body, undici allocates an internal Readable stream before enforcing the content-encoding limit. Upon rejecting the fetch promise, the stream is orphaned without an error listener or abort signal, causing the premature socket closure to emit an unhandled stream error that terminates the Node.js process.
An origin validation vulnerability in @azure/msal-node allows attackers to process unchecked authorization code response payloads or request query parameters in ConfidentialClientApplication, potentially directing sensitive client credentials to external endpoints.
A path traversal vulnerability in PyPA virtualenv prior to version 21.14.9 allows local attackers to cause arbitrary file and directory deletion when a virtual environment target directory containing untrusted distribution metadata is reused or reseeded.
In virtualenv versions prior to 21.14.9, creating isolated Python virtual environments using the standard library creator (`--creator venv`) allows configuration key injection into `pyvenv.cfg` if an attacker can control or influence the destination directory path containing Unicode line break characters.
A command injection vulnerability in PyPA virtualenv prior to version 21.14.9 allows local attackers to execute arbitrary commands when a user sources a generated shell activation script containing a crafted prompt value.