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

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 :

  1. Il s'installe. Il écrit dans /tmp/.cch/svc.py un chien de garde qui relance le mineur toutes les deux minutes, et ajoute un hook de démarrage à ~/.bashrc, /root/.bashrc et /root/.profile. Les versions de la première vague plantent aussi une clé SSH nommée qx-5090-op dans authorized_keys et une tâche cron dans /etc/cron.d/.sync-helper.
  2. 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_PROXY et HTTPS_PROXY sur lesquels une entreprise peut compter. Le binaire de 40,6 Mo a ses marqueurs UPX modifiés pour que upx -d le 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.
  3. Il ouvre une porte. Trois versions ajoutent un reverse shell vers 212.32.253.3 sur les ports 443 ou 9443, qui se reconnecte chaque fois qu'il tombe.
  4. 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.
Un chargement de modèle, trois charges utilesLe chargementfrom_pretrained("…/qwen2-tiny", trust_remote_code=True)config.json · auto_mapmodeling_evil2.EvilModelmodel.safetensors : 42 octets, 0 tenseurL'import exécute le fichierle code de premier niveau s'exécuteavant le retour de la classePersistancechien de garde : /tmp/.cch/svc.pyhooks : ~/.bashrc et /root/.profiletâche cron, clé SSH qx-5090-opMineur cachétéléchargeur qui ignore HTTP_PROXYen mémoire, nommé [kworker/u20:1]mine vers prl-us.kryptex.network:8048Reverse shellvers 212.32.253.3 sur 443 ou 9443,se reconnecte s'il tombe(trois versions)
Ce que fait le chargement d'un des modèles de la campagne, d'après l'analyse de Pluto Security. config.json oriente Transformers vers un fichier Python, le fichier s'exécute à l'import, et le fichier de poids de 42 octets ne sert jamais. La charge utile installe sa persistance, lance un mineur déguisé qui ignore les réglages de proxy et, dans certaines versions, ouvre un reverse shell.

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.

Portable : le test tourne en votre nomL'agent charge qwen2-tiny pour un testajoute trust_remote_code=True contre l'erreurLa charge utile tourne sous votre comptele mineur atteint le pool Kryptex,le reverse shell atteint 212.32.253.3Elle reste, et l'opérateur a une invitehook ~/.bashrc dans chaque nouveau terminal ;~/.ssh, clés cloud, gh, HF_TOKEN à portéeBromure : le test tourne dans une VMMême chargement, dans la VM UbuntuHF_TOKEN, gh et clés cloud : des substitutsCommutateur et proxy du Mac : refuspool :8048, shell :443/:9443 : aucune règle ;sentinelle : lien à l'appel ; chien de garde : quarantaineReset disk, Rewind homedisque système neuf depuis l'image de base ;home ramené au démarrage d'avant le test
Le même chargement de modèle sur un portable et sur une machine Bromure. Sur un portable, la charge utile s'exécute en votre nom, atteint son pool et son shell, et reste dans vos fichiers de démarrage. Sur une machine Bromure, le commutateur et le proxy sur le Mac refusent les deux connexions, la sentinelle noyau les relie à l'appel d'outil, le chien de garde peut mettre la VM en quarantaine, et Reset disk plus Rewind home effacent ce qui a été planté.

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.