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

Le test de recrutement interdisait les assistants IA

Kaspersky a publié le 1er septembre la campagne de faux recruteurs de Mirage Kitten. L'appât est un test de programmation à faire chez soi, et son README formule trois demandes : terminer en trois heures, ne pas modifier server.js parce qu'il est déjà exempt de bogues, et ne pas utiliser d'assistant IA. La première ligne de server.js importe un paquet npm trojanisé qui n'a jamais été publié sur npm. Il est arrivé dans le zip, déjà installé dans node_modules. Tous les contrôles de chaîne d'approvisionnement jamais conçus surveillent une requête au registre que cette attaque ne fait pas. Bromure Agentic Coding plaide pour l'autre moitié : la machine sur laquelle le code s'exécute.

L'attaquant vous a demandé de ne pas utiliser d'agent de code. Un outil qui ouvre tous les fichiers du dépôt, que les instructions le disent ou non, était la seule chose qui séparait cette campagne d'une porte dérobée, et le README vous en a dissuadé.

Un recruteur vous écrit sur LinkedIn. Le poste a l'air réel, le nom de l'entreprise vous dit quelque chose, et l'étape suivante est un test technique à faire chez vous. Vous recevez un zip. À l'intérieur, une application de gestion de projet appelée TaskFlow, bâtie avec Express, React et Vite : quelques centaines de fichiers et un README à la racine. Corrigez les défauts du frontend. Vous avez trois heures.

C'est la chose la plus ordinaire qui arrive à un ingénieur en activité. C'est aussi, d'après l'analyse publiée par Kaspersky le 1er septembre, la façon dont une équipe d'espionnage iranienne que le rapport nomme Mirage Kitten, suivie ailleurs sous les noms d'UNC1549, Smoke Sandstorm ou Nimbus Manticore, dépose des portes dérobées multiplateformes chez des développeurs en Égypte, en Éthiopie et en Afghanistan, avec d'autres soumissions venues d'Inde, de Turquie, d'Israël, d'Irak, d'Allemagne et d'Irlande. Les archives arrivent sous des noms comme Front-Technical-Challenge.zip et Task-FullStack.zip, hébergées sur AWS pour que le téléchargement ressemble à de l'infrastructure plutôt qu'à une charge utile. The Hacker News a couvert la campagne le 9 septembre.

Trois règles, toutes au sujet de la lecture

Kaspersky relève trois instructions dans le README. Regardez ce que chacune rapporte à l'attaquant.

Le README imposait également une limite de trois heures et interdisait l'usage d'assistants IA.

Il affirmait aussi que server.js était exempt de bogues et ne devait pas être modifié.

Le chronomètre est la règle évidente : trois heures pour corriger une base de code inconnue, c'est un emploi du temps qui ne laisse aucune place à la curiosité. La deuxième règle dirige votre attention vers le frontend et l'écarte d'un fichier précis. La troisième est la plus intéressante, parce que c'est l'attaquant qui vous dit quel outil lui fait peur.

Un agent de code à qui l'on confie ce dépôt ne respecte pas le périmètre que le README suggère. Pointez Claude Code ou Codex sur le projet et demandez-lui de corriger le frontend : il lira quand même server.js, parce que comprendre comment l'application démarre, c'est comme cela qu'il comprend à quoi le frontend parle. Il lit l'arborescence entière. Il n'a pas de chronomètre de trois heures et aucune raison de sauter un fichier que quelqu'un a déclaré sain. Bannir l'assistant retire de la pièce le seul lecteur qui lit tout, ce qui est une étrange exigence pour un processus de recrutement.

Et ce qu'il aurait lu, c'est la première ligne.

La dépendance n'a jamais été sur npm

La première ligne de server.js importe un paquet nommé colorized_terminal, en version 2.1.0. Un échantillon voisin utilise pretty-log, également en 2.1.0. Ni l'un ni l'autre n'est un typosquat de quoi que ce soit, ni une publication détournée, parce que ni l'un ni l'autre n'est sur le registre :

Les attaquants ont empaqueté la dépendance directement dans le répertoire node_modules de l'archive du test, plutôt que de la publier sur le registre npm.

À l'import, le paquet lançait silencieusement un implant depuis node_modules/.cache/.320697f1/index.js sous forme de processus détaché en arrière-plan.

Aucun npm install ne s'exécute ici, et aucun script postinstall. Il n'y a aucune entrée de lockfile à vérifier, aucune empreinte d'intégrité à comparer, aucune attestation de provenance à contrôler, aucune date de publication à faire vieillir, aucun fournisseur de réputation à interroger, et aucune requête au registre à laquelle accrocher tout cela. La dépendance était dans le dossier avant que vous n'ouvriez le dossier. Lancer l'application que le README vous demande de corriger, ce qui est tout le devoir, signifie un simple require, et ce require fait bifurquer une porte dérobée.

Trois ans de défenses contre les paquets malveillants reposent toutes sur une requête au registre. Mirage Kitten n'en fait aucune.

La chaîne d'approvisionnement sans registreFront-Technical-Challenge.zipREADME.mdtrois heures · pas d'IA« server.js est sans bogue »server.jsligne 1 : require('colorized_terminal')le fichier à ne pas ouvrirnode_modules/[email protected]déjà installé · absent de npmvous lancez l'appl'import lance un implantnode_modules/.cache/.320697f1/index.jsprocessus détaché en fondNodeRabbit appelle chez luiplugplay.azurewebsites.netrgbteller.azurewebsites.net11 commandes, puis 23ce qui ne se déclenche jamaisaucune requête au registreâge du paquet — pas de dateintégrité — pas de lockfileprovenance — rien n'a été publiéréputation — aucun paquet à chercherscripts retirés — pas d'installationtyposquat — aucun nom à comparerreste la machine qui exécute le code
L'archive arrive avec ses dépendances déjà installées, si bien que la chaîne d'approvisionnement ne touche jamais un registre. La première ligne du fichier que le README vous disait de ne pas ouvrir importe un paquet qui n'existe nulle part ailleurs, et cet import fait bifurquer un implant depuis un répertoire caché à l'intérieur de node_modules. Aucune étape d'installation, aucune vérification de lockfile, aucune date de publication : les contrôles qui gardent les récupérations de paquets n'ont plus rien à garder.

Cela ne reste pas dans le dossier du test

NodeRabbit commence petit. La première variante de Kaspersky, issue d'une machine en Afghanistan, porte onze commandes : lire un fichier, écrire un fichier, lister un répertoire, extraire la configuration réseau, démarrer un processus, exécuter un script, se mettre en sommeil. La variante égyptienne ajoute l'évasion de bac à sable, la prise en charge du proxy et la sélection dynamique de port. La troisième, venue d'Éthiopie, atteint vingt-trois, et les nouvelles sont celles où la machine d'un développeur cesse d'être une seule machine.

outlook:emails récolte les artefacts de messagerie. persist:projects:scan parcourt le disque à la recherche de dépôts git. persist:project:inject écrit dans ceux qu'il trouve, en atterrissant dans .git/hooks/post-merge et .git/hooks/post-checkout pour que l'implant se relance au prochain pull. persist:vscode installe une extension malveillante dans votre éditeur, qui la charge ensuite dans chaque projet que vous ouvrez. La formulation de Kaspersky est précise quant à l'intention :

Au-delà des mécanismes de persistance décrits ci-dessus, la variante 3 introduit deux mécanismes supplémentaires qui relancent le logiciel malveillant via des flux de travail courants des développeurs.

Vos autres dépôts sont la cible. La persistance plus banale qui se trouve dessous est ajustée par plateforme : une tâche planifiée ou une clé Run qui se déclenche à 10 h chaque jour sous Windows, une entrée cron @reboot sous Linux, un LaunchAgent dans ~/Library avec RunAtLoad et KeepAlive tous deux à vrai sous macOS. Le cousin PollCat arrive par un test React nommé RankChallenge-react dont le README raccourcit le chronomètre à une session d'une heure, protégée par un code à six chiffres censé tourner toutes les trente secondes. Même conception de la pression, moins de temps.

sur le portablel'implant hérite de tout votre comptepersist:projects:scanchaque dépôt du disque reçoit un hookpersist:vscodeune extension chargée dans chaque projetoutlook:emails · fs:read~/.aws · ~/.kube · ~/.git-credentialsnettoyagetrouver les hooks, changer les clés, espérerdans un workspaceune VM Ubuntu, son disque, son homele scan ne voit que ce que vous montezdossiers partagés seulement, huit au plusla persistance tombe sur un disque jetableReset disk · Erase home · checkpointschaque fichier d'identifiants est facticeghp_… · brm-k8s-… · sk-ant-api03-brm-…nettoyageeffacer disque et home, ne rien changer
Une archive, deux rayons de souffle. Sur un portable, l'implant hérite du compte entier : chaque dépôt git qu'il trouve reçoit un hook, l'éditeur reçoit une extension, le courrier et la configuration cloud sont lisibles, et les identifiants présents dans l'environnement du shell sont les vrais. Dans un workspace Bromure Agentic Coding, les mêmes commandes s'exécutent contre une VM Linux qui ne contient que des identifiants factices, ne monte que les dossiers que vous avez partagés, et n'atteint le réseau qu'à travers une socket sur l'hôte.

Exécutez-le, ailleurs que sur votre machine

Bromure Agentic Coding exécute les agents de code dans une VM Linux virtualisée matériellement sur votre Mac, avec tous les contrôles de sécurité du côté hôte de cette frontière. Le zip d'un inconnu est le travail pour lequel il a été conçu, parce que les contrôles décrivent la pièce plutôt que l'invité. Aucun d'eux n'a besoin de reconnaître colorized_terminal.

Un workspace est l'unité d'isolement. Une VM Ubuntu, son propre disque système, son propre home persistant, ses propres identifiants et ses propres politiques, et deux workspaces ne partagent jamais un octet. Créez un workspace jetable pour le test. L'implant s'exécute, l'import se déclenche, le processus détaché démarre, et tout cela se passe dans une machine qui est venue au monde ce matin et qui contient un seul fichier zip. Quand vous avez terminé, Reset disk re-clone le disque système depuis la base et Erase home… efface l'image du home ; les points de restauration par démarrage derrière Restore home… le font à une granularité plus fine. L'entrée cron @reboot que la branche Linux écrit part avec eux.

Les branches de persistance Windows et macOS n'ont aucun terrain où se poser. Pas d'AppData, pas de clé Run, pas de tâche planifiée, pas de ~/Library, pas de LaunchAgent, pas de messagerie Outlook à lire. L'invité est un Linux et ce n'est pas votre Linux.

persist:projects:scan ne voit que les dossiers que vous avez partagés. Les dossiers de l'hôte sont attachés comme montages virtiofs dans /home/ubuntu/<basename>, plafonnés à huit par workspace, et ils sont la seule partie du système de fichiers de votre Mac que la VM peut atteindre. Partagez le dossier du test et rien d'autre, et l'injection de hooks git trouve un seul dépôt : celui qui était déjà hostile. Gardez la liste courte pour une deuxième raison. Un dossier partagé est une fenêtre ouverte sur votre Mac, et un effacement après compromission le laisse intact par conception.

fs:read revient avec des remplaçants. Bromure remplace chaque identifiant que vous configurez par un faux préservant la structure, dérivé de la vraie valeur et d'un sel propre à l'installation via HKDF-SHA256 : sk-ant-api03-brm-…, un jeton ghp_ de la bonne longueur, glpat-…, brm-k8s-…, brm-docker-…. Les faux sont écrits dans les variables d'environnement ainsi que dans ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config et les configurations MCP, c'est-à-dire la liste qu'un voleur d'informations énumère. Les vraies valeurs restent chiffrées sur le Mac, et le proxy de l'hôte les substitue sur le fil, cantonnées à l'hôte de destination pour lequel chacune a été frappée. AWS va plus loin : l'invité signe avec une fausse clé secrète et l'hôte resigne, si bien qu'une requête qui contourne le proxy meurt chez AWS avec InvalidSignatureException. Les octets des clés privées SSH n'entrent jamais dans la VM, et seules les signatures traversent. Aucun réglage ne gouverne tout cela, parce que le proxy est la seule route de la VM vers le réseau.

Les serveurs de commande sont exactement ce à quoi sert une liste d'autorisation. La plupart des serveurs de commande de NodeRabbit vivent sous *.azurewebsites.net, et ceux de PollCat aussi. Aucun flux de réputation ne mettra jamais ce domaine sur liste noire, et aucune heuristique d'âge de domaine ne le signalera. Une liste d'autorisation s'en moque : dans Guardrails, réglez Unmatched traffic sur Deny et écrivez les deux ou trois lignes dont le travail a besoin. L'application est côté hôte et doublée : le commutateur virtuel apparie chaque flux par IP de destination et par nom d'hôte espionné dans le DNS, tous protocoles confondus, et le proxy apparie de nouveau par nom de serveur TLS et méthode HTTP. L'interception transparente détourne le trafic des ports 80 et 443 de l'invité vers le proxy sans que rien à l'intérieur de la VM puisse l'annuler, ce qui est la réponse à la variante qui embarquait la prise en charge du proxy et la sélection dynamique de port.

Et si l'implant tente d'emporter un jeton, le faux devient un fil-piège. Le proxy analyse chaque requête sortante, en-têtes et corps, à la recherche d'un faux qui part vers un hôte pour lequel il n'a pas été frappé. En cas de correspondance, il refuse la requête sans transmettre un seul octet, met la VM en pause sur-le-champ, inscrit une ligne rouge Credential brokering dans la Security Timeline, et marque le workspace comme compromis pour que le prochain lancement efface d'abord le disque et le home. Vous ne changez rien ensuite, parce que ce que l'implant tenait était un remplaçant.

Vous pouvez voir toute la conversation. Avec le traçage sur Activity only, le proxy écrit une ligne de métadonnées par requête et aucun corps. bromure-cli trace hostnames affiche chaque hôte distinct que le workspace a contacté, avec les comptes, si bien que trois adresses azurewebsites.net que vous n'avez jamais listées sont à une commande de distance plutôt qu'à une enquête forensique.

Le workspace à créer avant que le zip n'arrive

Un workspace jetable sans aucun identifiant configuré au-delà de la clé de l'agent lui-même, un seul dossier partagé pour l'archive, et Unmatched traffic réglé sur Deny dans Guardrails. Ajoutez allow web registry.npmjs.org et tout ce que le projet récupère par ailleurs. Enregistrer pousse les règles vers les sessions en cours sans redémarrage, si bien que vous pouvez les desserrer ligne par ligne à mesure que le build se plaint.

Les deux commandes une fois que vous avez terminé

bromure-cli trace hostnames <workspace> vous dit où le code est allé, et bromure-cli trace leaks <workspace> affiche No leaks detected. ✓ quand rien n'a tenté d'emporter un jeton. Ensuite Reset disk et Erase home…, et le workspace revient à l'image dont il est parti.

Le lecteur n'a jamais été le contrôle

Lisez server.js quand même. C'est un bon conseil et une mauvaise défense, parce que la prochaine archive mettra l'import à la ligne 340 d'un fichier que vous n'avez aucune raison d'ouvrir, ou dans un bundle minifié, ou derrière un require dynamique assemblé à partir de deux chaînes. Les trois règles de Mirage Kitten sont une couverture contre votre attention, et l'attention s'épuise à 16 h un vendredi avec une heure restante au chronomètre.

L'endroit où le code s'exécute, lui, ne s'épuise pas. L'import se déclenche dans les deux cas et le processus détaché démarre dans les deux cas ; ce qu'il vous reste à choisir, c'est ce que ce processus trouve en regardant autour de lui : une machine qui détient votre jeton GitHub, votre kubeconfig, votre clé SSH et quarante dépôts, ou une VM Linux qui détient un fichier zip, un jeu de remplaçants, et une socket réseau qui ne va que là où vous l'avez dit.

Relevez le défi. Utilisez l'agent de code que le README vous disait de ne pas utiliser, et laissez-le lire server.js en premier. Installez Bromure Agentic Coding et donnez au zip une machine que cela ne dérange pas.