Passa al contenuto principale

La backdoor .pth: LiteLLM 1.107.0 e il codice che gira prima del tuo import

Avanzato
What you'll learn
  • Capire perché il payload è girato su host dove nessuno aveva mai importato litellm — il modulo site di Python esegue le righe dei file .pth che iniziano con import, a ogni avvio dell'interprete, prima del tuo codice
  • Vedere perché la rassicurante regola «le wheel non eseguono codice in fase di installazione» era del tutto vera e del tutto irrilevante qui
  • Ripercorrere la causa radice che conta per chiunque gestisca un AI gateway: un agente di coding ha messo un token OAuth di GitHub nel corpo di una risposta, e il proxy registrava i corpi delle risposte
  • Capire perché Trusted Publishing, la firma delle immagini Docker e i tag immutabili — tutti già attivi da marzo — non hanno fermato una release costruita da un repository lookalike
  • Vedere l'attaccante adottare questa primitiva in tempo reale: a marzo 2026 litellm 1.82.7 richiedeva di usare il pacchetto, e la 1.82.8 — la release successiva — è passata a un .pth
  • Eseguire un audit verificato delle righe .pth eseguibili sulle tue macchine e leggere correttamente il risultato (setuptools ne include una legittima)
  • Applicare la tabella dei controlli: quali delle quindici difese abituali della supply chain avrebbero davvero bloccato questo attacco, quali avrebbero firmato il malware e la configurazione di una riga che avrebbe fermato entrambi gli incidenti LiteLLM

Il 21 ottobre 2026, per circa 100 minuti, pip install litellm ha installato un ladro di credenziali e un loader remoto di codice con cadenza oraria. Il thread di Hacker News è arrivato in prima pagina con 519 punti. È seguito il solito consiglio: fissa le versioni, ruota le chiavi.

Quel consiglio è giusto, ed è anche la cosa meno interessante di questo incidente. Tre dettagli giustificano una pagina:

  1. Il payload non aveva bisogno di essere importato. Era distribuito come file .pth, che il modulo site di Python esegue a ogni avvio dell'interprete in quell'ambiente. Un host che aveva installato LiteLLM in un virtualenv condiviso e poi eseguiva uno script non correlato ha comunque perso le proprie credenziali.
  2. L'accesso iniziale è passato dai log di un AI gateway. Secondo il post-mortem di LiteLLM stessa, Claude Code incorpora in un corpo di risposta il token OAuth della GitHub CLI dell'utente; il deployment interno di LiteLLM registrava i corpi delle risposte; quel deployment era raggiungibile da internet pubblico. L'attaccante ha letto da un log del proxy le credenziali GitHub di tre sviluppatori.
  3. La release era firmata, pubblicata tramite Trusted Publishing e attribuibile al repository vero — perché l'attaccante non ha falsificato il publisher. Ha modificato il workflow del publisher per costruire da Berri-AI/litellm invece che da BerriAI/litellm.

Se gestisci un proxy LLM davanti ad agenti di coding, il punto 2 è quello da leggere due volte. I log di richieste e risposte del tuo gateway sono un deposito di credenziali che probabilmente non hai mai classificato come tale.

La cronologia​

Tutti gli orari in UTC, 21 ottobre 2026, dal post-mortem dei maintainer.

OraCosa è successo
10:08:29La branch protection rifiuta una modifica non autorizzata al workflow di release
10:08:34Cinque secondi dopo, una credenziale di un owner dell'organizzazione disattiva la branch protection e fa force-push
10:46:39Il workflow modificato avvia un'esecuzione di staging contro il repo lookalike Berri-AI/litellm
11:08:53Inizia la pipeline di release in produzione
11:11:18Una seconda credenziale di sviluppatore supera il gate di approvazione della produzione
11:39:51–11:40:13Sette wheel avvelenate per litellm==1.107.0 arrivano su PyPI
11:46:56Immagine avvelenata pubblicata; i tag Docker v1.107.0, latest, main-latest, main-stable puntano tutti a essa
11:53:49Una terza credenziale elimina i record delle esecuzioni del workflow di release
12:09–12:24Il workflow non autorizzato viene rimosso e quello vero ripristinato
entro le 13:23PyPI rimuove litellm==1.107.0
13:51Tag GHCR confermati non disponibili
14:20Tag Docker Hub rimossi o riportati ad artefatti verificati

Due cose da notare. Il gate di approvazione ha funzionato esattamente come progettato — richiedeva un secondo essere umano, e l'attaccante aveva il token di un secondo essere umano. E la finestra di esposizione Docker è stata più lunga di quella di PyPI: circa due ore e mezza, durante le quali sia latest sia main-stable erano avvelenati. Chiunque abbia scaricato un tag flottante in quella finestra l'ha preso, compresi coloro che credevano che main-stable significasse qualcosa.

Parte 1 — Il meccanismo: codice che gira prima del tuo import​

Cos'è un file .pth e cosa può fare​

Un file .pth in site-packages normalmente è noioso: un elenco di directory da aggiungere a sys.path. È così che funzionano le installazioni editable. Ma il formato ha un secondo comportamento, molto meno noto, ed è nella documentazione della libreria standard:

Una riga di un file .pth che inizia con import seguito da uno spazio o da una tabulazione viene eseguita con exec().

Il cook.pth dell'attaccante è lungo 12 byte. Tutto il suo contenuto è import cook. Basta questo. Il modulo site di Python, importato automaticamente durante l'avvio dell'interprete, trova il file, vede il prefisso import e lo esegue — il che importa cook.py, un blob su una riga in base64 e zlib che contiene il ladro.

Insieme a esso la wheel ha depositato sitecustomize.py, che non ha bisogno di alcun .pth: il modulo site tenta di importare un modulo con esattamente quel nome a ogni avvio, per progetto, come hook per l'amministratore. E c'è un dettaglio nella documentazione di cui l'attaccante si avvantaggia direttamente — se l'import di sitecustomize solleva qualunque cosa diversa da un ImportError corrispondente, l'errore viene inghiottito in silenzio. Un payload malfunzionante non produce traceback né avvisi.

Quindi: nessuno deve importare litellm. Qualunque processo Python in quell'ambiente esegue entrambi i payload. Un cron job. Un controllo --version. Uno script non correlato nello stesso container.

Verificalo tu stesso​

È un comportamento documentato, non una vulnerabilità, quindi puoi confermarlo in un virtualenv usa e getta in trenta secondi. Riprodotto su CPython 3.14.6:

Prove the .pth mechanism in a disposable venv

python3 -m venv /tmp/pthdemo
SP=$(/tmp/pthdemo/bin/python -c "import site;print(site.getsitepackages()[0])")
printf 'import sys; sys.stderr.write("[pth ran: no import needed]\n")\n' > "$SP/zz_demo.pth"

# A command that imports nothing at all:
/tmp/pthdemo/bin/python -c "print('hello')"
#   [pth ran: no import needed]
#   hello

# -S skips the site module entirely, so nothing runs:
/tmp/pthdemo/bin/python -S -c "print('hello')"
#   hello

rm -rf /tmp/pthdemo

Due osservazioni utili dall'esecuzione. Primo, -S sopprime l'intero meccanismo — il che conta più avanti, perché vuoi fare l'audit dei payload senza eseguirli. Secondo, la riga di demo è stata stampata due volte per ogni avvio dell'interprete: in un virtualenv la stessa directory site può essere enumerata più di una volta, e la documentazione è esplicita nel dire che le voci di percorso vengono deduplicate mentre i punti di ingresso eseguibili no. Un payload può scattare più di una volta per processo.

La parte che sopravvive all'incidente​

Se ricordi un solo fatto duraturo di questa pagina, che sia questo: le righe di import nei .pth sono deprecate da Python 3.15 e ne è prevista la rimozione in Python 3.20. La sostituzione è un file di punto d'ingresso esplicito .start, e una riga di import in un .pth viene ignorata in silenzio quando esiste un file .name.start corrispondente.

È una vera fine, con data, per questa specifica primitiva di attacco — e un motivo per trovare adesso le righe .pth eseguibili nei tuoi ambienti, perché alcune sono portanti e si romperanno.

Altri due comportamenti documentati da conoscere, entrambi a favore di un payload silenzioso: dalla 3.15 un errore su una riga .pth non interrompe più l'elaborazione delle righe restanti, e durante il periodo di deprecazione la diagnostica per una riga di import eseguita viene emessa solo con -v.

Perché «le wheel non eseguono codice in fase di installazione» non ha salvato nessuno​

C'è un'idea molto ripetuta del packaging Python che è davvero corretta: una sdist può eseguire codice arbitrario in fase di installazione tramite setup.py, mentre una wheel è solo un archivio zip che l'installer decomprime copiando file. Le wheel sono il formato più sicuro. Preferisci le wheel.

Tutto questo è vero. Nulla di ciò è servito, perché il payload non aveva bisogno di esecuzione in fase di installazione. L'attaccante ha aggiunto una riga a pyproject.toml sotto la configurazione di build di maturin —

[tool.maturin]
data = "cook"

— che istruisce il backend di build a impacchettare la directory cook/ come dati della wheel, facendone arrivare il contenuto alla radice di site-packages all'installazione. Nessun hook, nessuno script, nessun setup.py. Solo tre file copiati al loro posto, uno dei quali Python esegue a ogni avvio successivo.

La lezione si estende oltre questo incidente: «questo pacchetto esegue codice quando lo installo?» è la domanda sbagliata. Quella giusta è «quali file questo pacchetto colloca nel mio ambiente, e il mio runtime ne esegue qualcuno da solo?»

Watch out
  • Tratta un file .pth in una wheel come equivalente a uno script di installazione. È strettamente più potente: uno script di installazione gira una volta, sotto il tuo installer, dove potresti vederlo. Una riga .pth gira a ogni avvio dell'interprete, per sempre, sotto qualunque processo avvii Python subito dopo.

Parte 2 — La wheel e il sorgente non coincidevano​

Ecco la scoperta che dovrebbe cambiare il modo in cui riesamini una release. La distribuzione sorgente non conteneva il payload. Secondo SafeDep e JFrog, la directory cook/ è assente dalla sdist — ma il suo pyproject.toml porta la riga data = "cook", e quella riga non compare in alcun commit pubblico di BerriAI/litellm. Era l'unica traccia visibile della manomissione, in una configurazione di build, nel solo artefatto.

Segui le conseguenze:

  • Chi ha confrontato con diff il tag git v1.107.0 contro v1.106.2 non ha visto nulla di sbagliato — il tag è pulito.
  • Chi ha letto la pagina di release su GitHub non ha visto nulla di sbagliato.
  • Chi ha installato con --no-binary :all: non poteva aver ricevuto i tre file del payload, perché la sdist non li distribuisce.
  • Chi ha eseguito pip install litellm — la cosa normale, che preferisce le wheel — ha ricevuto il ladro.

JFrog aggiunge un dettaglio forense da conoscere: i timestamp delle voci ZIP dei file del payload coincidono con quelli dei file legittimi nella wheel, il che indica che il malware è stato incluso durante una normale build della wheel e non aggiunto riconfezionando l'archivio in seguito. L'artefatto è esattamente ciò che ha prodotto la build avvelenata — il che ci porta alla parte scomoda.

Parte 3 — Perché l'hardening non è servito​

LiteLLM non era stata trascurata sulla sicurezza delle release — e il motivo per cui non lo era fa parte della storia. Questa è stata la seconda compromissione di LiteLLM del 2026. A marzo, secondo JFrog, la pipeline di build del progetto ha eseguito una release malevola di Trivy 0.69.4 non fissata, che ha esposto credenziali di CI/CD e di pubblicazione e ha portato alle versioni con backdoor 1.82.7 e 1.82.8. JFrog descrive i payload e l'infrastruttura di ottobre come diversi, senza alcuna connessione apparente tra le due campagne.

L'hardening di marzo era la risposta a quel primo incidente. Per ammissione degli stessi maintainer, da allora avevano spostato le release su repository privati, adottato i Trusted GitHub Publishers, reso immutabili i tag Docker e abilitato la firma delle immagini Docker. È una postura migliore di quella della maggior parte dei progetti di pari dimensioni — e si noti che la prima compromissione è iniziata con una dipendenza non fissata in una pipeline di build, che è la stessa lezione con cui si chiude la tabella dei controlli più sotto.

Ognuno di quei controlli era soddisfatto dalla release di ottobre. Ecco perché.

Guided walkthrough1 of 6
  1. Il workflow malevolo è girato dentro il repository legittimo, attraverso la pipeline di release legittima, con l'identità di pubblicazione legittima. Ciò che è cambiato è stato quale sorgente ha fatto checkout. Trusted Publishing lega un artefatto al workflow e al repository che l'hanno prodotto; non dice nulla su cosa quel workflow abbia scelto di costruire.

Il riassunto onesto è che la toolchain mainstream della supply chain del software è costruita per rispondere a «questo artefatto proviene dalla vera pipeline del progetto?» Ha risposto sì, correttamente. Quasi nulla è costruito per rispondere a «la vera pipeline del progetto ha costruito il vero sorgente del progetto?» — ed è questa la domanda su cui ha fatto leva l'attacco.

Parte 4 — La causa radice: i log del tuo gateway sono un deposito di credenziali​

Ora la parte specifica dell'infrastruttura AI, e il motivo per cui questo incidente sta su questo sito e non solo in una newsletter di packaging.

L'attaccante non ha fatto phishing a un maintainer né violato una password. Secondo il post-mortem dei maintainer, la catena è stata:

Guided walkthrough1 of 4
  1. L'indagine ha trovato un 'account utente inatteso dal nome innocuo' in quel deployment. Il team sospettava che l'accesso fosse avvenuto tramite una vulnerabilità patchata di recente, ma al momento della scrittura non aveva confermato il vettore.

Quest'ultima voce chiude il cerchio, ed è il punto strutturale. Un gateway LLM è, per costruzione, l'unico host del tuo parco che detiene ogni chiave di provider e vede ogni prompt e ogni completamento. Aggiungi la registrazione dei corpi delle risposte e detiene anche qualunque segreto i tuoi agenti vi facciano transitare. Comprometterne uno vale più che compromettere il laptop di uno sviluppatore, e la lista della spesa del malware mostra che l'attaccante lo sapeva.

What you'll learn
  • Se gestisci un AI gateway, l'azione da portare a casa da questo incidente non è solo «fissa litellm». È: scopri se il tuo gateway conserva i corpi di richieste e risposte, decidi se dovrebbe farlo e, se deve, oscura le stringhe che sembrano credenziali. Il traffico degli agenti non è traffico API ordinario — gli agenti di coding portano in-band token per GitHub, provider cloud e registri di pacchetti.

Cosa ha fatto il payload sull'host​

Per completezza, il secondo file. sitecustomize.py ha creato con fork un demone distaccato — setsid(), I/O su /dev/null, directory di lavoro /, così sopravvive al genitore — che si risvegliava ogni 3.600 secondi, risolveva il proprio prossimo host di comando e controllo da un dead drop (un domain.txt in un account GitHub poi eliminato), scaricava /codepy da esso via HTTP semplice e lo eseguiva tramite exec(). L'esfiltrazione andava a www.litellm[.]dev/healthcheck, anch'essa HTTP semplice, con il corpo cifrato AES-256-CBC sotto una chiave incapsulata con RSA-4096 — un lookalike del vero litellm.ai del progetto, registrato il 13 ottobre 2026, otto giorni prima della release. (I domini sono neutralizzati su questa pagina; non risolverli.)

StepSecurity osserva che il percorso di daemonizzazione fallisce in silenzio su Windows, dato che si basa su fcntl e os.fork. È stata pubblicata una wheel per Windows, quindi considera il ladro come eseguito lì e il loader orario come la parte che probabilmente non lo è stata.

Watch out
  • Disinstallare il pacchetto non chiude l'incidente, per quattro ragioni indipendenti. Le credenziali rubate sono già uscite. Il demone generato con fork è un processo separato che non ha più alcun rapporto con il pacchetto. Ogni avvio di Python su quell'host nel frattempo ha eseguito di nuovo il ladro. E — come fa notare JFrog — pip è esso stesso un processo Python, quindi il tuo comando di bonifica esegue il payload ancora una volta all'uscita. L'indicazione degli stessi maintainer è di trattare l'host come compromesso: termina il demone, rimuovi i file, ricostruisci e ruota tutto.

Parte 5 — Lo stesso pacchetto, sette mesi prima, e l'escalation dell'attaccante​

L'incidente di marzo merita uno sguardo attento, perché le sue due versioni malevole mostrano la tecnica .pth che viene adottata — in un solo incremento di versione.

Secondo Datadog Security Labs, che lo traccia come la campagna TeamPCP:

  • litellm 1.82.7 portava il payload dentro litellm/proxy/proxy_server.py. Per scattare, la vittima doveva installare il pacchetto e poi usarlo davvero.
  • litellm 1.82.8 — la release immediatamente successiva — ha aggiunto litellm_init.pth. Stesso progetto, stessa campagna, a pochi giorni di distanza, e ora il payload scattava all'avvio dell'interprete, senza bisogno di importare LiteLLM.

È l'attaccante che scopre la primitiva di cui parla tutta questa pagina, ed è il motivo per cui il payload di ottobre si apriva con un .pth da 12 byte invece che con un modulo iniettato. Se stai facendo il triage delle tue dipendenze, l'escalation da «codice a livello di modulo» a «dropper .pth» è lo schema da aspettarsi, non un trucco isolato.

Il payload di marzo è anche riconoscibilmente lo stesso mestiere di quello di ottobre: AES-256 con chiave incapsulata RSA-4096, esfiltrazione verso un dominio lookalike costruito col nome stesso del progetto (models.litellm[.]cloud allora, litellm[.]dev a ottobre), raccolta di credenziali tra variabili d'ambiente, chiavi SSH, credenziali cloud e Kubernetes — più, a marzo, persistenza systemd tramite ~/.config/sysmon/sysmon.py, polling di comandi su checkmarx[.]zone/raw e la capacità di creare pod Kubernetes per diffondersi in un cluster. La campagna di marzo si è propagata a catena da Trivy il 19 marzo, attraverso npm (CanisterWorm) e gli strumenti Checkmarx, arrivando a PyPI il 24 marzo (litellm) e il 27 marzo (telnyx).

What you'll learn
  • Le fonti non concordano sul fatto che i due incidenti LiteLLM siano collegati, e questa pagina non lo risolve. JFrog afferma che i payload e l'infrastruttura di ottobre sono diversi, senza alcuna connessione apparente con la campagna di marzo. In senso contrario: entrambi usavano un dropper .pth, entrambi AES-256 sotto una chiave incapsulata RSA-4096, ed entrambi esfiltravano verso un dominio lookalike derivato dal nome del progetto — mentre l'etichetta di campagna di Datadog (TeamPCP) e il nome autoscelto dal ladro di ottobre (privcollect, marcatore PRIVCOLLECT_ACTIVE) invitano a un confronto su cui nessuno ha pubblicato un verdetto. L'attribuzione spetta ai ricercatori; tratta la somiglianza come inspiegata, non come una scoperta.

L'unico controllo che li avrebbe fermati entrambi​

Il rapporto di incidente di PyPI su marzo è dove sta il numero più utile: LiteLLM viene installata circa 1.700 volte al minuto, e 119.000 download sono avvenuti durante la finestra di esposizione di marzo. PyPI ha messo in quarantena la release di marzo 2 ore e 32 minuti dopo il caricamento; a ottobre le wheel sono sparite in circa 1 ora e 43 minuti. La risposta sta diventando più rapida — e non sarà mai abbastanza rapida, perché a quel ritmo di installazione i primi dieci minuti sono già un evento di compromissione di massa.

Ecco perché la raccomandazione principale di PyPI ai consumatori non è «leggi il codice». È un dependency cooldown: rifiutarsi di risolvere qualunque release più giovane di N giorni. Entrambe le compromissioni di LiteLLM sono state rilevate e ritirate nel giro di ore. Un cooldown di tre giorni le avrebbe rese entrambe un non-evento per chiunque ne avesse uno.

È il controllo di alto valore meno adottato dell'elenco, ed è una riga di configurazione.

Dependency cooldowns in the three tools that support them

# --- uv: pyproject.toml (or uv.toml at the root) ---
# exclude-newer accepts an RFC 3339 timestamp, a friendly duration,
# or an ISO 8601 duration. Relative durations landed in uv 0.9.17.
[tool.uv]
exclude-newer = "7 days"     # equivalently: "P7D"

# --- Renovate: renovate.json ---
# The oldest implementation of the idea (formerly stabilityDays).
{
"minimumReleaseAge": "7 days"
}

# --- Dependabot: .github/dependabot.yml ---
# Note: Dependabot already applies a DEFAULT 3-day cooldown to version
# updates even with no cooldown block at all -- and never to security
# updates. Configure it only to lengthen or target the window.
updates:
- package-ecosystem: "pip"
  directory: "/"
  schedule: { interval: "weekly" }
  cooldown:
    default-days: 7
    semver-patch-days: 3

Il compromesso è esplicito e piccolo: ricevi gli aggiornamenti non di sicurezza con fino a una settimana di ritardo. In cambio, la finestra in cui una release avvelenata può raggiungerti si riduce da «tutto il tempo che passa finché qualcuno se ne accorge» a «zero, purché qualcuno se ne accorga entro il cooldown» — e in entrambi i casi LiteLLM qualcuno se n'è accorto nel giro di ore.

Pro tip
  • Un cooldown non deve ritardare le patch di sicurezza, altrimenti hai scambiato un rischio con un altro. Dependabot lo fa giusto per costruzione: il cooldown è un'opzione solo degli aggiornamenti di versione ed è documentato come non applicabile agli aggiornamenti di sicurezza. Se implementi un cooldown da te — con l'exclude-newer di uv in CI, per esempio — tieni un percorso di override documentato per una release che corregge un advisory noto.
  • Controlla cosa hai già prima di aggiungere configurazione. Il default di 3 giorni di Dependabot significa che molti repository sono parzialmente coperti senza che nessuno l'abbia scelto, mentre un Dockerfile che scarica un tag flottante e uno step di CI che esegue pip install -U non sono coperti da nulla di tutto questo.

Triage: riguarda te?​

Esegui questi in ordine. La prima domanda è economica e risolve il caso per la maggior parte delle persone.

1. Did 1.107.0 ever get resolved anywhere?

# Lockfiles, requirement files, constraints — anywhere a resolver wrote a version down
grep -rniE "litellm[=<>~! ]*1\.107\.0([^0-9rc]|$)" . \
--include="requirements*.txt" --include="*.lock" --include="*.txt" \
--include="poetry.lock" --include="Pipfile.lock" --include="uv.lock" 2>/dev/null

# Installed right now, in this environment
pip list 2>/dev/null | grep -i litellm

# Container images you are actually running (compare against the poisoned digest)
docker images --digests | grep -i litellm

2. Look for the three dropped files and the runtime markers

# The payload files, at the ROOT of site-packages (not inside a litellm/ directory)
find / -name "cook.pth" -path "*site-packages*" 2>/dev/null
find / -name "cook.py" -path "*site-packages*" 2>/dev/null
find / -name "sitecustomize.py" -path "*site-packages*" 2>/dev/null

# Known SHA-256 of the three files
#   cook.pth          7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb
#   cook.py           9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25
#   sitecustomize.py  34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa

# Execution markers
ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null
ls -d /tmp/.pc_sq_* /tmp/.pc_e_* 2>/dev/null

# The live hourly loader: whatever process holds the lock (parent PID 1, cwd /)
lsof /tmp/remote_code_loader.lock 2>/dev/null

3. Network-side, if you keep egress or DNS logs

# Exfiltration, dead drop, and the fetched payload path
#   litellm.dev                                    (NOT litellm.ai — the lookalike)
#   www.litellm.dev/healthcheck                    plain HTTP POST, encrypted body
#   raw.githubusercontent.com/letters9813/...      dead drop for the next C2 host
#   any request path ending in /codepy             the fetched code

grep -hiE "litellm\.dev|letters9813|/codepy" /var/log/**/*.log 2>/dev/null | head

Se uno qualsiasi di questi dà riscontro: termina il demone, elimina cook.pth, cook.py, sitecustomize.py e le relative voci in __pycache__, fissa litellm==1.106.2 (o l'immagine v1.106.2 per digest), poi ricostruisci l'host e ruota tutto — credenziali IAM cloud, chiavi API dei provider, password dei database, chiavi SSH, segreti di CI, token degli strumenti degli agenti — e verifica CloudTrail o l'equivalente per l'uso di quelle credenziali da fonti sconosciute. I maintainer affermano che i clienti sulla release enterprise privata non sono stati colpiti.

L'audit da tenere: quali file .pth eseguono codice?​

Indipendentemente da questo incidente, dovresti sapere cosa viene eseguito all'avvio di Python sulle macchine a cui tieni. Nota il -S — l'audit non deve innescare proprio ciò che sta cercando.

List every executable .pth line in an environment (run with -S)

cat > /tmp/audit_pth.py <<'EOF'
import glob, os, site, sys
dirs = list(site.getsitepackages())
try:
  if site.ENABLE_USER_SITE:
      dirs.append(site.getusersitepackages())
except Exception:
  pass
seen, hits = set(), 0
for d in dirs:
  if d in seen or not os.path.isdir(d):
      continue
  seen.add(d)
  for p in sorted(glob.glob(os.path.join(d, "*.pth"))):
      for n, line in enumerate(open(p, encoding="utf-8", errors="replace"), 1):
          s = line.strip()
          if s[:6] == "import" and s[6:7] in (" ", "\t"):
              hits += 1
              print(f"{p}:{n}: {s}")
print(f"\n{hits} executable line(s) across {len(seen)} site dir(s): {sys.executable}")
EOF

python3 -S /tmp/audit_pth.py

Leggi correttamente l'output. Un riscontro non è una scoperta. Su un virtualenv nuovo con solo setuptools, questo segnala una riga eseguibile, ed è legittima:

.../site-packages/distutils-precedence.pth:1: import os; var = 'SETUPTOOLS_USE_DISTUTILS'; ...

È setuptools che installa il suo shim di distutils, ed è l'uso legittimo più comune del meccanismo. Le installazioni editable (__editable__*.pth) sono un altro. Il segnale che cerchi è una riga eseguibile che non sai attribuire a un pacchetto che hai installato di proposito — e un file alla radice di site-packages il cui nome non corrisponde a nessuna distribuzione che sai nominare è la versione più forte di quel segnale. Sugli host compromessi il nome era cook.

Cosa lo avrebbe davvero fermato​

Questa è la tabella da portare nella tua configurazione. «Sì» significa che il controllo blocca questo specifico attacco, non che sia sufficiente in generale.

ControlloLo ferma?Perché
Dependency cooldown (3-7 giorni)Sì — e anche marzoEntrambe le release sono state ritirate nel giro di ore. L'unico controllo di questa tabella che copre anche la prossima.
Pin esatto della versione (litellm==1.106.2)Sì1.107.0 non entra mai nella risoluzione. Il controllo di maggior valore qui.
Pin degli hash (pip install --require-hashes)SìStesso motivo, e in più intercetta un nuovo caricamento sotto un numero di versione esistente.
Lockfile committato e rispettato in CISìLa finestra di 100 minuti raggiunge solo i resolver a cui è permesso scegliere una nuova versione.
pip install -U / installazioni non fissate in quella finestraNoÈ esattamente chi è stato colpito.
Docker latest / main-latest / main-stableNoTutti e tre sono stati riportati sul digest avvelenato per ~2,5 ore.
Pin Docker per digestSìUn digest non può essere riassegnato.
Trusted Publishing / attestazioni PyPINo (inferenza)Il workflow vero nel repo vero l'ha pubblicata; la provenienza li avrebbe nominati correttamente.
Firma delle immagini DockerNoLa firma è valida; attesta l'origine, non l'intento.
Tag Docker immutabiliNoL'immagine avvelenata è stata il primo artefatto mai pubblicato sotto quel tag.
«Evita le sdist, preferisci le wheel»NoQui capovolto: le wheel erano avvelenate e la sdist non distribuiva il payload.
«Blocca i pacchetti con script in fase di installazione»NoNon c'era alcun hook di installazione. Il payload era un file di dati.
Build da sorgente (--no-binary :all:)Sì, quiSolo perché la sdist ometteva i file del payload. Non è un controllo generale.
Allowlist di egress rigorosa su host dev/CIIn parteL'esfiltrazione verso un dominio nuovissimo via HTTP semplice è esattamente ciò che un'allowlist nega.
Non registrare i corpi delle risposte nel tuo gatewayAccesso inizialeAvrebbe eliminato le credenziali che l'attaccante ha usato per iniziare.
Token GitHub di breve durata e con ambito ristrettoAccesso inizialeUn token OAuth di owner d'organizzazione di lunga durata in un log è l'intero perno.

I controlli economici e noiosi vincono. Imposta un cooldown, fissa esattamente, blocca il pin, scarica le immagini per digest e non lasciare che un gateway conservi ciò che non gli serve. Nota quanto poco di tutto questo richieda di ispezionare codice: i tre controlli in cima alla tabella funzionano proprio perché non richiedono che qualcuno si accorga di qualcosa.

Key takeaways
  • Un file .pth è una primitiva di esecuzione di codice, non un elenco di percorsi. Il modulo site di Python esegue con exec ogni riga che inizia con import, a ogni avvio dell'interprete, senza che il pacchetto debba essere importato. Trattalo come strettamente più pericoloso di uno script di installazione.
  • Il consiglio «preferisci le wheel» riguarda l'esecuzione in fase di installazione e non copre l'esecuzione all'avvio del runtime. Una riga in una configurazione di build — data = "cook" — ha messo tre file alla radice di site-packages senza alcun hook.
  • Il sorgente e l'artefatto possono non coincidere. Il tag git era pulito e la sdist non distribuiva il payload; solo le wheel lo portavano, quindi confrontare il repository con un diff non ha trovato nulla. L'unica traccia visibile era una riga di configurazione di build presente in nessun commit pubblico.
  • La firma della supply chain risponde a «questo viene dalla vera pipeline?» — e qui la risposta era legittimamente sì. Non risponde a «la vera pipeline ha costruito il vero sorgente?», ed è lì che è passata un'organizzazione lookalike distante un trattino.
  • Un AI gateway è l'host di maggior valore in uno stack AI: ogni chiave di provider, ogni prompt e — se registri i corpi delle risposte — qualunque token i tuoi agenti di coding portino in-band. Classifica i suoi log come segreti o smetti di conservarli.
  • Le righe di import eseguibili nei .pth sono deprecate in Python 3.15 e rimosse nella 3.20, sostituite da file .start espliciti. Fai l'audit dei tuoi adesso, con python -S, e aspettati che setuptools sia un riscontro legittimo.
  • Questa è stata la seconda compromissione di LiteLLM del 2026, e la prima è iniziata con una dipendenza non fissata nella pipeline di build. Fissare le versioni non è solo il modo per proteggersi da una release avvelenata — è anche il modo in cui un progetto evita di pubblicarne una.
  • Il miglior controllo singolo è un dependency cooldown, perché è l'unico che non dipende dal fatto che qualcuno si accorga di qualcosa. Entrambe le release LiteLLM sono state ritirate nel giro di ore; un cooldown di 3 giorni le avrebbe rese entrambe un non-evento. Dependabot applica già 3 giorni per default, e mai agli aggiornamenti di sicurezza.
Nove termini di questo incidente
Premi Invio o Spazio per girare la carta. Usa le frecce sinistra e destra per spostarti tra le carte.Termine mostrato.
1 / 9

Verifica te stesso

0/8
  1. Un host ha installato litellm 1.107.0 in un virtualenv alle 11:50 UTC, e nulla su quell'host ha mai importato litellm. Qual è l'esposizione?
  2. La tua policy è «preferisci le wheel alle sdist, perché le sdist possono eseguire setup.py in fase di installazione». Come è andata questa policy qui?
  3. LiteLLM aveva Trusted Publishing, la firma delle immagini Docker e i tag immutabili attivi da marzo. Perché nessuno di questi ha bloccato questa release?
  4. Secondo il post-mortem dei maintainer, come ha ottenuto l'attaccante, per la prima volta, le credenziali GitHub degli sviluppatori?
  5. Quale coppia di controlli avrebbe bloccato questo attacco nel modo più economico?
  6. Entrambe le compromissioni di LiteLLM sono state rilevate e ritirate nel giro di ore — 2h32m a marzo, circa 1h43m a ottobre. Quale controllo lato consumatore trasforma questo in una protezione quasi totale?
  7. A marzo 2026, litellm 1.82.7 metteva il payload in proxy_server.py e la 1.82.8 distribuiva invece litellm_init.pth. Cosa ti dice questo cambiamento?
  8. Esegui l'audit dei .pth su una macchina di sviluppo e segnala una riga eseguibile, in distutils-precedence.pth. Cosa dovresti concludere?

Fonti e letture di approfondimento​

Prossimo passo​