← Retour à tous les articles
Publié le · par Renaud Deraison

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 module site de Python exécute les lignes d'import des fichiers .pth à chaque démarrage de l'interpréteur. Personne n'a besoin de faire import 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 lit domain.txt dans un dépôt GitHub contrôlé par l'attaquant, récupère http://<host>/codepy et exécute le résultat avec exec().

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 version avait disparu bien avant que la barrière ne s'ouvre21 oct. 08:0022 oct. 00:0023 oct. 00:0023 oct. 14:00litellm 1.107.0 sur PyPI11:39 à 13:23 · environ 49 000 téléchargementstags Docker Hub jusqu'à 14:20Barrière d'âge Bromure, 2 jours par défautpip sur une machine Bromure voit 1.106.2 comme le litellm le plus récent23 oct. 11:39 : la barrière s'ouvrirait. Plus rien à servir.retiréeHeures tirées du rapport d'incident de LiteLLM. Estimation des téléchargements : Wiz.
litellm 1.107.0 sur PyPI, du 21 au 23 octobre, en UTC. La version malveillante est restée en ligne 1 heure 43 minutes et avait disparu plus de 46 heures avant que la barrière d'âge par défaut de Bromure ne laisse pip la voir.

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_KEY et 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/config et 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 ~/.ssh n'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.

Quatre étapes, quatre contrôles sur le MacL'attaque, 21 octobre1 · Des tokens dans un logtokens OAuth gh copiésdu trafic modèle journalisé2 · Workflow forcéprotection contournée,build depuis Berri-AI/litellm3 · Wheels sur PyPIlitellm==1.107.011:39 à 13:23 UTC4 · cook.pth s'exécutecollecte, puis POST verswww.litellm.devDans une machine BromureToken de substitutionla VM détient un faux ghp_ ;le log garde le faux.GitHub le refuse ailleurs.Politique d'écriturepush et changements deprotection de brancheattendent un dialogue.Barrière d'âge par défautpip se résout vers 1.106.2 ;une 1.107.0 figéereçoit un HTTP 451.Faux, et refus par défautle butin : des substituts ;le POST et /codepysont refusés au switch.
La chaîne de litellm 1.107.0, et où une machine Bromure la coupe. Chacun des quatre contrôles s'exécute sur le Mac, hors de la VM dans laquelle le paquet s'installe.

« 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.