Le code s'exécutait avant le modèle
Pluto Security a trouvé 31 dépôts Hugging Face déguisés en petits checkpoints de test Qwen2. Aucun ne contenait de modèle. Chacun contenait un fichier Python qui s'exécute dès que le modèle se charge avec trust_remote_code, et installe un mineur caché, des hooks de démarrage et, dans certaines versions, un reverse shell. Le dépôt phare a passé les scanners de Hugging Face et atteint 28 412 téléchargements, et une copie presque identique est restée en ligne après le retrait. Les agents de code vont chercher de petits modèles de test quand ils écrivent des tests. Dans Bromure Agentic Coding, ce chargement s'exécute dans une VM d'où le mineur ne peut pas joindre son pool, la Security Timeline relie chaque connexion à l'appel d'outil qui l'a ouverte, et un retour en arrière efface ce que la charge utile a planté.
Le fichier de poids faisait 42 octets et ne contenait aucun tenseur. Le travail était fait par un fichier Python qui s'exécutait dès le chargement du modèle, avant que le moindre code du modèle ne fasse quoi que ce soit.
Le 29 octobre, Pluto Security a publié Load/Mine,
son analyse d'une campagne active sur Hugging Face et sur ModelScope, la
plateforme chinoise, depuis septembre. Pluto a trouvé 31 dépôts
malveillants répartis sur sept comptes. Ils se font passer pour de petits
checkpoints de test Qwen2, avec des noms comme qwen2-tiny, et
totalisaient plus de 32 000 téléchargements enregistrés. BleepingComputer
en a parlé le même jour.
Pluto résume la campagne en une phrase : « The code runs before the model does » (le code s'exécute avant le modèle).
Comment un chargement de modèle devient un mineur
L'attaque n'utilise ni poids empoisonnés ni exploit pickle. Elle utilise
une fonctionnalité. Un modèle Hugging Face peut embarquer son propre code
Python, et la bibliothèque Transformers exécute ce code si l'appelant
passe trust_remote_code=True. Le config.json du dépôt désigne un
module et une classe dans une entrée auto_map, comme
modeling_evil2.EvilModel. Transformers télécharge ce fichier .py et
l'importe. Tout ce qui se trouve au premier niveau du fichier s'exécute
pendant l'import, avant même que la classe soit renvoyée.
Dans ces dépôts, model.safetensors fait 42 octets. Il n'y a aucun modèle
dedans. Le fichier Python fait tout le travail :
- Il s'installe. Il écrit dans
/tmp/.cch/svc.pyun chien de garde qui relance le mineur toutes les deux minutes, et ajoute un hook de démarrage à~/.bashrc,/root/.bashrcet/root/.profile. Les versions de la première vague plantent aussi une clé SSH nomméeqx-5090-opdansauthorized_keyset une tâche cron dans/etc/cron.d/.sync-helper. - Il télécharge le mineur et le cache. Le téléchargeur est construit
avec un gestionnaire de proxy vide, si bien qu'il ignore les réglages
HTTP_PROXYetHTTPS_PROXYsur lesquels une entreprise peut compter. Le binaire de 40,6 Mo a ses marqueurs UPX modifiés pour queupx -dle refuse. Il tourne depuis un fichier anonyme en mémoire, sous le nom de processus[kworker/u20:1], et mine du Pearl via Kryptex sur le port 8048. - Il ouvre une porte. Trois versions ajoutent un reverse shell vers
212.32.253.3sur les ports 443 ou 9443, qui se reconnecte chaque fois qu'il tombe. - Il vise l'hôte. Un script apparenté de la deuxième vague monte les disques de l'hôte depuis un conteneur privilégié et se donne les droits root sur le serveur sous-jacent.
Scanné sans alerte, il est revenu
Hugging Face analyse les envois avec un antivirus et avec des outils
partenaires de Protect AI et de JFrog. Selon Pluto, ces outils cherchent
des malwares connus et des formats de modèle dangereux. Ils ne regardent
pas ce que fait le fichier Python. Le dépôt phare de la deuxième vague,
MENXIANGYU/qwen2-tiny, « scanned clean while it collected 28,412
downloads » (est passé sans alerte tout en cumulant 28 412
téléchargements). Le verdict d'un scanner partenaire sur le fichier de la
charge utile : « not a machine-learning model » (ce n'est pas un modèle
d'apprentissage automatique).
Pluto a signalé la première vague à Hugging Face et à ModelScope, qui ont
tous deux supprimé les comptes. En quelques jours, 19 nouveaux dépôts sont
apparus sous de nouveaux comptes. Quelques heures après le rapport de
Pluto, l'opérateur a publié un script d'évasion de conteneur. Hugging Face
a désactivé le dépôt phare, mais BleepingComputer a confirmé qu'une copie
presque identique, htt0118/qwen2-tiny, était toujours en ligne avec le
même mineur et le même reverse shell.
Un petit modèle de test, c'est ce que votre agent demande
La campagne visait des serveurs GPU qui font tourner des plateformes
d'inférence comme Xinference et GPUStack. L'appât fonctionne aussi sur la
machine d'un développeur, parce qu'un agent de code cherche un petit
checkpoint Qwen2 quand il écrit des tests pour du code qui appelle un
modèle. Demandez à un agent d'ajouter un test pour votre pipeline
d'embeddings ou votre script de fine-tuning, et il part chercher un modèle
assez petit pour se charger en quelques secondes. Les noms de cette
campagne, qwen2-tiny et ses variantes, sont faits pour correspondre à
cette recherche.
L'agent n'a pas besoin d'être négligent pour activer l'option. Quand un
dépôt embarque du code personnalisé, Transformers s'arrête et dit à
l'appelant de passer trust_remote_code=True. Pour un agent occupé à
réparer un test qui échoue, c'est une erreur dont la correction est
imprimée dans le message. Il ajoute l'argument et relance le test. Que le
test passe ou non n'a plus d'importance : le mineur a démarré pendant
l'import.
Sur un portable, ce fichier s'exécute en votre nom. Le hook de démarrage
dans ~/.bashrc s'exécute dans chaque terminal que vous ouvrez ensuite.
Le reverse shell donne à l'opérateur une invite de commande avec votre
~/.ssh, vos identifiants cloud, votre connexion gh et votre
HF_TOKEN. Le conseil de Pluto à quiconque a chargé ces modèles :
considérer la machine comme compromise et renouveler chaque identifiant
que le processus pouvait atteindre.
Parmi les conseils de durcissement de Pluto, une ligne décrit la configuration à viser : exécuter le code de modèle non fiable « with limited privileges, restricted egress and no production secrets » (avec des privilèges limités, des sorties réseau restreintes et aucun secret de production).
Le même test dans Bromure Agentic Coding
Bromure Agentic Coding fait tourner Claude Code, Codex, Grok, Kimi et omp dans des VM Ubuntu sur votre Mac. Chaque machine a son propre disque, son propre répertoire personnel et sa propre politique réseau, appliquée sur le Mac, hors de portée de l'agent. Voici le même test exécuté sur une machine Bromure, une étape de la charge utile à la fois.
Le mineur n'atteint jamais son pool
Chaque connexion ouverte par la VM passe par le commutateur virtuel et le proxy de Bromure sur le Mac. Les ports 80 et 443 sont détournés vers le proxy au niveau du commutateur, donc un téléchargeur muni d'un gestionnaire de proxy vide est intercepté quand même. L'astuce de la campagne pour passer outre les réglages de proxy d'entreprise n'a rien à contourner, car l'interception ne dépend d'aucun réglage dans la VM.
Réglez Unmatched traffic sur Deny dans Sandbox Policy, ou
passez la machine au moteur OpenShell + Sentry, où tout est refusé
sauf ce qu'une règle autorise. La politique de départ autorise GitHub, npm
et PyPI en lecture seule, plus les miroirs d'Ubuntu, et vous ajoutez
huggingface.co pour les téléchargements. La connexion du mineur vers
prl-us.kryptex.network:8048 ne correspond alors à aucune règle et elle
est refusée. Le reverse shell vers 212.32.253.3 aussi, sur l'un ou
l'autre port. Avec Tell the agent when policy blocks it, activé par
défaut sur les machines OpenShell, l'agent lit aussi dans sa conversation
ce qui a été bloqué et qu'il doit s'arrêter pour vous demander, au lieu de
passer les dix minutes suivantes à chercher un contournement.
Sur une machine OpenShell avec Enforce binaries, chaque connexion est
aussi rapprochée du programme qui l'a ouverte, et l'empreinte de chaque
programme est épinglée à sa première exécution. Un mineur lancé depuis un
fichier anonyme en mémoire est un programme qu'aucune règle ne nomme. Le
renommer en [kworker/u20:1] n'y change rien, car l'attesteur identifie
le binaire, pas le nom qu'il se donne.
Vous voyez quel appel d'outil l'a fait
L'opérateur a choisi le nom [kworker/u20:1] pour tromper quelqu'un qui
lit ps ou top sur un serveur chargé. Sur une machine Bromure avec la
sentinelle noyau, le noyau de la VM signale au Mac chaque lancement de
programme et chaque connexion, par un canal privé que l'agent ne peut pas
couper. Chaque connexion bloquée arrive dans la Security Timeline avec
la chaîne qui l'a produite, du genre
Tool pytest tests/test_embed.py: claude → bash → python → TCP 212.32.253.3:443.
Double-cliquez sur la ligne et la network lineage montre toute l'histoire : la consigne que vous avez donnée, ce que l'agent a écrit avant l'appel, la commande exacte, chaque processus depuis l'agent jusqu'à celui qui s'est connecté, et la couche qui a refusé. Quand une exécution de tests compose une adresse IP nue, vous obtenez une ligne rouge avec la commande du chargement de modèle dedans.
Le chien de garde de l'agent, réglé sur Quarantine dans Guardrails, agit sur les mêmes signaux sans vous attendre. Une rafale de blocages, ou de nouvelles destinations une fois la session stabilisée, au-delà de son seuil en moins d'une minute, et Bromure coupe le réseau de la VM et la met en pause en quelques millisecondes. L'alerte liste ce qu'il a vu. Keep quarantined relance la VM avec le réseau toujours coupé, pour que vous puissiez regarder à l'intérieur.
Les clés dans la VM sont des substituts
Supposons qu'une version de la charge utile aille plus loin, sur une
machine laissée sur le pare-feu par défaut. L'opérateur a un shell dans
une VM Ubuntu, et chaque identifiant qu'elle contient est un substitut.
Le GH_TOKEN de l'environnement et le jeton de ~/.git-credentials sont
des faux, que le proxy sur votre Mac remplace par le vrai seulement en
route vers GitHub. Ajoutez votre jeton Hugging Face dans Other API
keys avec le nom de variable HF_TOKEN et un filtre d'hôte
huggingface.co, et la VM reçoit un substitut brm_… qui ne fonctionne
que là. Les clés SSH n'entrent jamais dans la VM : l'agent peut demander
une signature, et les octets de la clé restent sur le Mac.
Un substitut est aussi un fil piège. Si la charge utile envoie le substitut GitHub ailleurs que vers GitHub en HTTPS, le proxy bloque la requête avec une erreur HTTP 451, met la VM en pause et ouvre une alerte qui nomme l'identifiant et l'hôte visé.
Le nettoyage tient en deux clics
La liste de nettoyage de Pluto pour un hôte infecté est longue : trouver
le chien de garde, retirer les hooks du shell, supprimer la tâche cron,
enlever la clé SSH de six chemins authorized_keys, chercher des
enregistrements malveillants, et réinstaller depuis une image de
confiance en cas de doute. Une machine Bromure a déjà cette image de
confiance. Reset disk reclone le disque système depuis l'image de
base, ce qui emporte /etc/cron.d/.sync-helper et tout ce que la charge
utile a écrit hors du répertoire personnel. Rewind home… ramène
/home/ubuntu à un point de contrôle d'un démarrage antérieur. Bromure en
prend un à chaque démarrage de la machine, donc le ~/.bashrc d'avant le
test en fait partie. Vos dossiers partagés avec l'hôte sont sur le Mac et
restent intacts.
Sur une machine OpenShell avec Enforce binaries, une bonne partie de cette
liste n'est jamais écrite. L'agent tourne sans root, donc les hooks dans
/root/.bashrc et /root/.profile, la tâche cron et les écritures dans
le authorized_keys de root sont refusés, et chaque refus devient une
ligne Guest sandbox dans la Timeline.
La sortie se décide sur le Mac
Les ports 80 et 443 vont au proxy quels que soient les réglages de la VM. Avec le trafic non couvert réglé sur refus, le pool et le shell sont refusés.
Chaque connexion a un nom
La sentinelle noyau relie chaque connexion à l'appel d'outil et à la chaîne de processus derrière elle. Un mineur déguisé apparaît quand même comme le chargement du modèle.
Des substituts, pas des clés
Les jetons GitHub, cloud et Hugging Face dans la VM sont des faux qui ne fonctionnent que sur leurs propres hôtes. Ailleurs, ils déclenchent une alerte.
Une machine propre à la demande
Reset disk et Rewind home remettent la VM dans l'état d'avant le test, persistance comprise.
Traitez un dépôt de modèle comme un paquet
Pluto liste ses indicateurs dans le rapport. Si vous avez chargé un de ces
modèles avec le code personnalisé activé, cherchez /tmp/.cch/, le
processus [kworker/u20:1] avec des arguments de minage et les hooks de
démarrage, puis renouvelez chaque identifiant que cette machine pouvait
atteindre. Avant d'activer trust_remote_code pour un dépôt, lisez son
modeling_*.py et épinglez le commit que vous avez lu.
Le rapport de Pluto montre un dépôt de modèle qui fonctionne comme un dépôt de code pendant que les scanners le vérifiaient comme un format de fichier. Si votre agent choisit des modèles pour ses tests, il finira par charger un de ces fichiers. Sur une machine Bromure, ce fichier s'exécute dans une VM avec des clés de substitution et une politique de sortie appliquée sur le Mac, et vous pouvez ensuite ramener la machine à un point de contrôle propre.
Pour un cas antérieur où la vérification de trust_remote_code elle-même
a échoué, lisez Le flag a dit non. Pour la façon
dont Bromure filtre les paquets qu'un agent installe, lisez La version
avait deux heures.