La version avait deux heures
Le 21 octobre, des attaquants munis des tokens GitHub CLI volés à trois développeurs de LiteLLM ont publié litellm 1.107.0 sur PyPI. Chaque wheel contenait un voleur d'identifiants qui s'exécute à chaque démarrage de Python et un chargeur de code distant qui se réveille toutes les heures. PyPI l'a retirée 1 heure 43 minutes plus tard, après environ 49 000 téléchargements. Dans Bromure Agentic Coding, la barrière d'âge, activée par défaut, masque toute version de moins de deux jours : pip, sur une machine Bromure, n'a jamais vu la 1.107.0.
La version malveillante est restée 1 heure et 43 minutes sur PyPI. Avec les réglages par défaut de Bromure, pip ne l'aurait vue sur la machine d'un agent que deux jours après sa mise en ligne.
Le mardi 21 octobre à 11 h 39 UTC, sept wheels de litellm==1.107.0 sont
arrivées sur PyPI. LiteLLM est la bibliothèque Python et le proxy qu'utilisent
beaucoup d'équipes pour router leur trafic vers les modèles, et on la trouve
dans une foule d'outils d'IA. La nouvelle version avait tout l'air d'une
version officielle : le workflow de release du projet l'avait signée, et elle
était accompagnée d'une provenance SLSA. Elle contenait aussi un voleur
d'identifiants et une porte dérobée.
PyPI l'a retirée à 13 h 23 UTC. Les images Docker infectées, étiquetées
v1.107.0, latest, main-latest et main-stable, sont restées sur Docker
Hub jusqu'à 14 h 20.
Wiz estime
à environ 49 000 le nombre de téléchargements de la version malveillante
pendant cette fenêtre. Le rapport
d'incident de
LiteLLM demande à quiconque l'a installée sur une machine ayant accès à
Internet de « considérer que l'exfiltration a réussi ».
C'est la deuxième compromission du projet cette année. La première, en mars, était passée par un scanner de sécurité piégé dans sa CI. Nous avions raconté comment les équipes de réponse avaient examiné la mauvaise fenêtre cette fois-là.
Comment les attaquants ont eu les clés
L'équipe de LiteLLM a publié une chronologie minute par minute. Les
attaquants détenaient les tokens OAuth GitHub CLI de trois développeurs. Le
lundi, les permissions de release avaient été élargies pour que deux
ingénieurs puissent livrer des rétroportages de correctifs. Le mardi à
10 h 08, la protection de branche a refusé une modification du workflow de
release privé. Cinq secondes plus tard, le token de l'un des deux
propriétaires de l'organisation a contourné la protection et poussé la
modification. Le workflow modifié compilait depuis Berri-AI/litellm, un
sosie du vrai BerriAI/litellm, et le token d'un deuxième développeur a
franchi la validation de production. À 11 h 53, le token d'un troisième
développeur a supprimé les traces d'exécution du workflow.
LiteLLM fait remonter les tokens à deux découvertes. La première est un « compte utilisateur inattendu, au nom anodin » sur son déploiement interne de LiteLLM, exposé sur Internet. La seconde concerne les agents de code. D'après le rapport, LiteLLM a « observé des situations précises dans lesquelles Claude Code insère le token OAuth GitHub CLI de l'utilisateur dans le corps d'une réponse, et lorsque les corps de réponse sont journalisés, ces tokens peuvent apparaître dans les logs de LiteLLM ».
Une passerelle qui journalise le trafic vers les modèles garde une copie de tout ce que l'agent a écrit. Ici, cette copie contenait le token capable de publier le paquet.
Ce que faisaient les wheels
L'analyse de
StepSecurity
explique pourquoi le code source sur GitHub semblait propre. Les attaquants
ont ajouté une seule ligne à la configuration de build, data = "cook" sous
[tool.maturin]. Elle copiait un répertoire cook/ dans chaque wheel. Ce
répertoire n'apparaissait jamais dans la distribution source, et le tag
v1.107.0 sur GitHub n'en porte aucune trace.
Chaque wheel installe trois fichiers dans site-packages :
cook.pth, 12 octets :import cook. Le modulesitede Python exécute les lignes d'import des fichiers.pthà chaque démarrage de l'interpréteur. Personne n'a besoin de faireimport litellm. Lancer pip suffit.cook.py, une ligne obfusquée qui se décode en environ 61 000 octets. C'est un voleur d'identifiants qui marque sa sortie"tool": "privcollect"et fait tourner 16 modules de collecte.sitecustomize.py, un chargeur qui se détache en démon. Toutes les heures, il litdomain.txtdans un dépôt GitHub contrôlé par l'attaquant, récupèrehttp://<host>/codepyet exécute le résultat avecexec().
Le voleur prend les clés SSH et GPG, les identifiants cloud et les
métadonnées d'instance, ~/.kube/config, la configuration Docker, les tokens
GitHub CLI, .git-credentials, .netrc, les tokens de registre pour npm,
PyPI, Cargo et d'autres, les fichiers .env sur deux niveaux, les
identifiants et cookies des navigateurs, l'historique du shell et des bases
de données, et l'environnement de chaque processus lisible via
/proc/<pid>/environ. Il liste aussi les fichiers d'authentification et de
configuration de Claude Code, Claude Desktop, Cursor, Windsurf, Kiro, GitHub
Copilot, Gemini CLI, Codex, Continue, Aider, Goose et une douzaine d'autres
outils d'IA. Sur un hôte qui fait tourner le proxy LiteLLM, il lit les clés
d'API des fournisseurs dans la base de données du proxy.
Le voleur chiffre le paquet en AES-256, enveloppe la clé avec la clé
publique RSA-4096 de l'attaquant, et envoie le résultat en HTTP en clair à
http://www.litellm.dev/healthcheck. L'attaquant a enregistré le domaine le
13 octobre. Tant que le serveur ne répond pas 200, le voleur recommence au
démarrage suivant de Python.
Le domain.txt du chargeur était vide à la publication, si bien que le
second étage ne s'est jamais armé. Le compte GitHub qui l'hébergeait a été
supprimé, mais les machines infectées continuent d'interroger cette URL
toutes les heures. Si quelqu'un réenregistre ce nom d'utilisateur, toutes ces
machines exécuteront son code.
Pourquoi les agents de code étaient exposés
Un agent qui travaille sur un projet Python lance pip install -U litellm
parce qu'un README ou un message d'erreur le suggère. Il lance pip install -r requirements.txt avec une ligne litellm sans version figée. Il crée un
virtualenv neuf et installe ce qu'il y a de plus récent, à n'importe quelle
heure, et personne ne lit la version qui est revenue.
Le mardi, entre 11 h 39 et 13 h 23 UTC, « le plus récent » voulait dire
1.107.0. Sur la machine d'un agent, cook.pth s'exécutait alors à chaque
appel à python, pytest et pip que l'agent faisait pendant le reste de
la session. Les identifiants du développeur étaient à portée du voleur, et
les connexions de l'agent figuraient sur sa liste de cibles.
Bromure n'a jamais servi la 1.107.0
Bromure Agentic Coding fait tourner Claude Code, Codex, Grok, Kimi et omp
dans des VM Linux sur votre Mac. Chaque téléchargement de paquet depuis la VM
passe par un proxy sur le Mac, qui applique la politique de supply chain de
la machine avant que la réponse n'atteigne pip. La VM ne peut pas modifier
cette politique. Le pip.conf dans la VM ne peut que la restreindre.
Une couche de cette politique est active sur toute nouvelle machine : la
barrière d'âge, avec un âge minimum de 2 jours. Quand pip demande à
l'index simple de PyPI quelles versions de litellm existent, le proxy
retire de la liste tous les fichiers plus jeunes que le seuil avant que pip
ne la lise. Le mardi à 11 h 45 UTC, une machine Bromure qui lançait pip install -U litellm recevait une liste qui s'arrêtait à 1.106.2, la dernière
version saine, et l'installait. Pour l'agent, la 1.107.0 n'existait pas
encore, et le temps que la barrière s'ouvre, PyPI l'avait supprimée.
Si l'agent fige la version, le téléchargement de l'artefact est le second contrôle. Le proxy récupère la date de publication du fichier sur PyPI et refuse le téléchargement avec un HTTP 451 :
$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum
pip affiche la raison telle quelle, donc l'agent la lit aussi, et il fige souvent de lui-même une version plus ancienne.
La barrière n'a eu besoin d'aucun avis de sécurité, d'aucune signature de
malware, ni de personne pour nommer le paquet. Registres et chercheurs
repèrent et retirent généralement une version détournée en quelques heures,
et attendre deux jours ne coûte presque rien dans le développement courant.
Si votre propre équipe publie un paquet qui doit s'installer dès sa sortie,
ajoutez-le dans Exempt packages, dans le panneau Supply Chain de la
machine, sous la forme pypi:yourpkg. Le reste de PyPI attend toujours deux
jours.
Le même panneau ajoute d'autres couches. Le contrôle OSV refuse toute version faisant l'objet d'un avis publié au niveau de gravité que vous choisissez ou au-dessus. Le filtre socket.dev bloque les paquets signalés comme malveillants. Si une source de réputation que vous avez activée ne peut pas rendre de verdict, le téléchargement se met en pause et Bromure vous interroge sur ce paquet et cette version précise.
Le token du log était un substitut
La publication n'a été possible que parce que les attaquants détenaient de vrais tokens GitHub. LiteLLM pense qu'ils les ont obtenus d'un déploiement qui journalisait le trafic vers les modèles, où Claude Code avait écrit le token GitHub CLI d'un développeur dans une réponse.
Dans une machine Bromure, ce token est un substitut. Au démarrage de la VM,
Bromure écrit un faux token ghp_ dans la configuration de gh et dans
~/.git-credentials, et l'exporte en GH_TOKEN. Le faux a le bon préfixe et
la bonne longueur, donc gh l'accepte. Le vrai token reste sur le Mac. Quand
une requête quitte la VM vers GitHub, le proxy remplace le faux par la vraie
valeur sur le fil, pour les hôtes de GitHub uniquement.
Un agent peut toujours coller le token dans une réponse. Il n'a que le substitut à coller. Le fournisseur du modèle et tout log de passerelle sur le trajet enregistrent eux aussi le substitut. Rejoué depuis l'ordinateur de quelqu'un d'autre, GitHub le refuse, parce qu'aucun proxy Bromure n'est là pour faire l'échange.
Les permissions de release bénéficient de la même protection. Dans le
panneau Guardrails d'une machine, chaque token de forge a une politique
d'écriture, et les nouvelles machines démarrent sur Prompt before write.
Un force-push sur un workflow de release, ou un appel d'API qui désactive la
protection de branche, attend un dialogue sur votre Mac qui affiche
METHOD /path, et c'est vous qui décidez.
Ce que privcollect aurait trouvé
Supposons que la 1.107.0 ait atteint une machine, parce que quelqu'un a désactivé la barrière ou ajouté une exemption. La liste du voleur est longue. Dans une VM Bromure, l'essentiel de ce qu'il peut ouvrir contient des substituts :
GH_TOKEN,ANTHROPIC_API_KEYet tous les autres identifiants confiés à la machine, dans l'environnement de chaque processus que le voleur lit via/proc, sont des faux.~/.git-credentials,~/.docker/config.json,~/.kube/config,~/.aws/configet les configurations MCP contiennent des faux. Tout comme les fichiers de connexion des agents eux-mêmes : les abonnements Claude, ChatGPT, Grok et Kimi sont connectés et renouvelés sur le Mac.- Les clés des fournisseurs de modèles n'entrent jamais dans la VM, il n'y a donc pas de base de données de proxy remplie de clés amont à lire. Le proxy de Bromure les ajoute sur le Mac, comme nous l'avons décrit dans Le provider s'exécute là où sont les clés.
- Les clés SSH signent via un pont d'agent vers le Mac. Les octets de la clé
privée n'entrent jamais dans la VM, donc
~/.sshn'a rien à offrir.
Un substitut ne fonctionne qu'à travers le proxy de cette machine, vers l'hôte pour lequel il a été émis. L'attaquant peut déchiffrer l'archive et rejouer chaque valeur qu'elle contient : GitHub, AWS et Anthropic les refuseront une à une.
Le POST n'avait nulle part où aller
Le butin part en POST HTTP en clair vers www.litellm.dev, un domaine dont
la machine n'a jamais eu besoin. Avec Unmatched traffic réglé sur
Deny dans le panneau Guardrails, la VM n'atteint que les hôtes que
vous listez :
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny
Le switch du Mac refuse la connexion, et la Security Timeline affiche une
ligne rouge deny avec l'hôte et le port. Le second étage du chargeur
échoue de la même façon : quel que soit l'hôte indiqué dans domain.txt,
http://<host>/codepy n'est pas dans la liste, et l'exec() horaire n'a
rien à exécuter.
Sur un workspace qui utilise le moteur de sandbox OpenShell + Sentry,
Show the Whole Chain sur cette ligne ouvre la lignée du flux bloqué. On y
voit l'appel d'outil pip install de l'agent et la justification que le
modèle en a donnée, puis le processus pip dans lequel cook.pth a lancé le
voleur, puis la connexion refusée. Vous voyez l'instruction qui a fait entrer
le paquet, et la raison que le modèle a donnée pour la lancer.
« L'avons-nous installée ? » se règle avec un filtre
Le mardi après-midi, toutes les équipes de réponse se posaient la même
première question : lesquelles de nos machines ont exécuté la 1.107.0 ?
LiteLLM prévient que l'absence de cook.pth ne prouve rien : elle ne
signifie pas qu'une machine est saine.
Bromure enregistre chaque téléchargement de paquet que voit le proxy, quelle
que soit la politique, et qu'un contrôle soit actif ou non. Ouvrez
Window → Security Timeline…, choisissez Supply chain dans le
filtre de moteur, et cherchez litellm. Chaque ligne nomme le paquet et la
version, pypi · [email protected], et ce qu'il en est advenu. L'historique
reste sur le Mac pendant 90 jours, et Export… l'enregistre en CSV pour le
ticket d'incident. Sur un Mac inscrit auprès de bromure.io, chaque
téléchargement est aussi un événement supply_chain.fetch avec l'écosystème,
le paquet, la version et le résultat. Un administrateur peut lister toutes
les machines de l'organisation qui ont téléchargé [email protected] sans
interroger personne.
Le nettoyage après coup est rapide. Le guide de LiteLLM dit de tuer le démon
qui détient /tmp/remote_code_loader.lock avant de lancer pip, de supprimer
les fichiers de la charge de chaque site-packages, de reconstruire et de
renouveler les secrets. Dans Bromure, Reset disk reclone le disque
système depuis l'image de base, et Rewind home… ramène le home à un point
de restauration antérieur à l'installation. Les clés que vous auriez sinon
renouvelées dans l'urgence étaient des substituts. Les vraies n'ont jamais
quitté le Mac.
Les nouvelles versions attendent deux jours
La barrière d'âge est active sur toute nouvelle machine. Les installations depuis PyPI, npm, Cargo, RubyGems et Packagist se résolvent vers la version la plus récente plus ancienne que le seuil. Une version récente figée reçoit un 451 avec la raison.
Le token du log est un faux
Les identifiants GitHub, cloud, de registre et de modèle entrent dans la VM sous forme de substituts. Le proxy insère la vraie valeur sur le Mac, pour l'hôte propre à chaque identifiant.
L'exfiltration a besoin d'une route
Avec le trafic non listé réglé sur deny, un POST vers un domaine enregistré la semaine dernière meurt au switch et apparaît en ligne rouge.
Chaque installation est consignée
La Security Timeline liste chaque paquet et chaque version que chaque machine a téléchargés, pendant 90 jours. Les Mac inscrits envoient les mêmes événements à l'organisation.
Laissez le registre faire son travail d'abord
Il a fallu quelques minutes aux attaquants pour publier la 1.107.0. Il a fallu 1 heure 43 minutes à PyPI pour la retirer, ce qui est rapide pour un registre. Votre agent installe des paquets toute la journée, à toute heure, et prend ce qu'il y a de plus récent : il tombe dans ce genre de fenêtre. Sur une machine Bromure, il attend deux jours. D'ici là, PyPI, Wiz, StepSecurity et l'équipe de LiteLLM elle-même ont examiné la version, et une mauvaise version a disparu.
Pour le voleur qui s'en prenait aux identifiants des agents de code plus tôt ce mois-ci, lisez Le stealer a emporté les substituts. Pour savoir comment les tokens finissent dans les transcriptions et les logs des modèles, lisez Le jeton est resté dans la transcription.