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

Personne n’a publié de nouvelle version

L’avis de Wordfence du 9 août sur la compromission de BdThemes décrit une attaque de chaîne d’approvisionnement sans chaîne d’approvisionnement. Aucune version publiée, aucun script d’installation, aucun typosquat, aucun fichier modifié dans le dépôt de l’extension. Les attaquants ont pris un accès en écriture au bucket de stockage de l’éditeur et modifié un flux JSON que 350 000 sites récupéraient à chaque chargement de page d’administration. Pour un développeur, la question que cela pose est celle de ce que la machine qui fait tourner votre agent de code a le droit d’écrire, et auprès de qui.

Chaque contrôle de chaîne d’approvisionnement que vous possédez inspecte des choses qui ont été publiées. Celui-ci n’a rien publié, et a atteint 350 000 sites quand même.

Le 7 août 2026, Wordfence a commencé à entendre parler de sites WordPress équipés d’extensions BdThemes où poussaient des comptes administrateur que personne n’avait créés. La liste compte sept extensions : Element Pack Addons for Elementor, Prime Slider, Pixel Gallery, Ultimate Post Kit, Ultimate Store Kit, Live Copy Paste et Smart Admin Assistant. Element Pack à lui seul dépasse les 100 000 installations actives, et les sept réunies franchissent les 350 000.

Au 8 août, l’éditeur avait nettoyé son API et WordPress.org avait retiré les sept extensions le temps de l’enquête. Wordfence a publié son PSA sur la compromission le lendemain, et sa conclusion centrale est une négation : personne n’avait touché au code des extensions.

Rien n’a été publié

Les extensions BdThemes embarquent un composant interne appelé Biggopti dont le rôle est modeste et ennuyeux : récupérer des bannières promotionnelles depuis l’API distante de l’éditeur et les afficher dans le tableau de bord d’administration de WordPress. Les données de bannière sont un fichier JSON statique dans un bucket DigitalOcean Spaces, servi derrière Cloudflare.

Les attaquants ont obtenu un accès en écriture à ce bucket. C’est toute l’intrusion.

Biggopti prenait le champ display_id de la réponse JSON et le déposait dans un attribut HTML id sans l’échapper, une faille de cross-site scripting que Wordfence a notée CVSS 5.4, moyenne et fait remonter au 1er mars 2026, quand un développeur l’a écrite une fois puis recopiée d’une extension à la suivante. Un display_id porteur d’un gestionnaire onanimationstart est donc du JavaScript, et il s’exécute dans le navigateur de chaque administrateur connecté, à chaque chargement de page wp-admin, sur chaque site où l’extension est installée.

La charge utile a bien exploité ce siège. Un script nommé w2.js demandait ses instructions de ciblage à un serveur de commande et de contrôle, la machine qu’un attaquant utilise pour indiquer à une charge utile quels sites valent la peine d’être pris, puis créait des comptes administrateur cachés via l’API REST de WordPress, sur le dos de la session et des nonces du véritable administrateur. Une variante dérivait des noms d’utilisateur prévisibles du nom d’hôte du site. Une fausse extension, souvent nommée quelque chose comme wp-smart-thumbnails, livrait un web shell à emer-run.php, c’est-à-dire un script qu’un attaquant pilote en chargeant une URL ordinaire. Des must-use plugins, que WordPress charge à chaque requête et masque de l’écran des extensions, portaient une porte dérobée à connexion magique et un module qui dissimulait les nouveaux comptes en réécrivant les totaux d’utilisateurs qu’affiche l’écran d’administration.

Les horodatages des enregistrements empoisonnés placent le début le plus ancien possible au 23 juin 2026, sept semaines avant que quiconque ne remarque quoi que ce soit.

Alignez cela sur les contrôles qu’une équipe rigoureuse fait tourner. Épinglage des versions : les versions n’ont jamais changé. Lockfiles et empreintes d’intégrité : elles concordaient, parce que les fichiers qu’elles couvraient étaient intacts. Revue des scripts d’installation : il n’y a pas eu d’installation. Détection de typosquat : bon nom, bon éditeur, bonne entrée de dépôt. Revue de code du diff entre versions : pas de diff, puisque pas de version. Certificat et CDN : tous deux en parfaite santé. Cloudflare et TLS ont prouvé que le JSON arrivait au site sans modification depuis le bucket d’où il était censé venir, ce qui est précisément ce qui le rendait utile.

Le chemin que tout surveilleWordPress.orgsept extensions, 350 000+installations activesce que les contrôles voientversions, empreintes, scriptsd’installation, le diffverdict : propreet c’est exact. Aucun fichier dudépôt n’a jamais été modifié.Le chemin que rien ne surveillaitle bucket de l’éditeurDigitalOcean Spaces,derrière Cloudflarequelqu’un pouvait y écrireBiggopti prend la bannièredisplay_id atterrit dans unattribut id, sans échappementonanimationstart l’exécutedans la session de l’admincomptes admin cachés via REST,web shell emer-run.php,magic-login dans mu-pluginspremier enregistrement empoisonné : 23 juin 2026Wordfence alerté : 7 août 2026Sept semaines pendant lesquelles chaque contrôle de l’artefact publié a réussi, et à juste titre.
Deux chemins vers un site équipé de l’extension. Le chemin publié, celui que tout contrôle de chaîne d’approvisionnement inspecte, n’a jamais changé. Le chemin d’exécution récupérait un fichier JSON dans un bucket où quelqu’un d’autre pouvait écrire, et ce fichier arrivait sous forme de JavaScript dans le navigateur de l’administrateur.

L’incident commence par un identifiant qui pouvait écrire

Lisez l’avis du côté de l’éditeur et la chaîne d’attaque se réduit à une étape. Quelqu’un a obtenu un accès en écriture à un bucket de stockage. Tout ce qui suit est une conséquence.

Les compromissions en amont arrivent sans cesse sous cette forme. Le ver du scope npm de Red Hat, le dépôt qui était vraiment celui de Microsoft, les places de marché d’extensions, les registres de conteneurs : dans chacun d’eux, la charge utile est la partie qui se fait raconter, et la partie qui comptait, c’est quelqu’un qui s’est retrouvé avec un identifiant capable d’écraser un artefact que d’autres gens récupèrent à intervalles réguliers.

Où vit un identifiant pareil ? Dans l’environnement d’un développeur. Un DIGITALOCEAN_ACCESS_TOKEN dans un shell, une configuration doctl dans ~/.config, un login de registre dans ~/.docker/config.json, un profil AWS, un jeton GitHub dans ~/.git-credentials, une clé SSH qui peut pousser. Sur un portable, dans un terminal, à côté du projet. En 2026, à côté du projet, c’est aussi là que vous faites tourner votre agent de code.

La plupart des articles de ce blog décrivent une façon de retourner cet agent contre vous. Un rapport de bug qui a exécuté une commande. Une page web qui a réécrit la configuration de l’agent. Un commentaire que personne ne pouvait voir. Une compétence qui s’est exécutée avec votre identité. Trois contournements de harnais dans une seule conférence Black Hat. Prenez le catalogue pour acquis, et supposez qu’un mardi l’un d’eux vous tombera dessus.

À quel point ce mardi tourne mal dépend d’une question à laquelle vous pouvez répondre aujourd’hui, sans savoir quelle injection l’emportera : qu’est-ce que cette machine a le droit d’écrire, et auprès de qui ? Sur un portable, la réponse est tout ce que vous avez le droit d’écrire, et si vous publiez quoi que ce soit, cela inclut l’artefact que plusieurs centaines de milliers d’inconnus récupèrent sans regarder.

Le jeton n’est pas dans la machine

Bromure Agentic Coding fait tourner l’agent dans une VM Linux jetable sur Apple Silicon, et chaque octet de son trafic traverse un proxy sur l’hôte, hors de la boîte où tourne l’agent. Bromure dispose les identifiants autour de cette frontière au lieu de les mettre à l’intérieur.

Mettez votre jeton d’accès personnel DigitalOcean dans le panneau Credentials et Bromure garde la vraie valeur sur le Mac. Ce qui atterrit dans la VM est un leurre : une variable d’environnement DIGITALOCEAN_ACCESS_TOKEN et un ~/.config/doctl/config.yaml qui font marcher doctl sans doctl auth init, avec un espace réservé dedans. Quand la VM émet une requête vers api.digitalocean.com, le proxy de l’hôte substitue le vrai jeton sur le fil, pour cette destination, et rien d’autre.

Tout ce qui tourne dans cette VM peut lire l’environnement, greper les dotfiles et parcourir le disque entier : l’agent, une dépendance, une étape de build, une commande shell sortie d’un ticket empoisonné. Ce qu’il récolte, c’est brm_…. Le même arrangement couvre le reste du lot : jetons GitHub, GitLab et Bitbucket, logins de registres de conteneurs écrits sous forme de faux blob base64 dans ~/.docker/config.json, un kubeconfig synthétique avec des certificats client jetables, Linear, les points d’accès HTTPS de bases de données, et tout ce que vous ajoutez d’autre sous Other API keys. AWS va plus loin : l’hôte resigne chaque requête en SigV4 avec le vrai secret, si bien que contourner le proxy rapporte une InvalidSignatureException plutôt qu’une écriture non autorisée.

Guardrails décide du verbe, sur le Mac

Cet incident avait besoin d’une seule opération : écraser un objet dans un bucket. Guardrails est un moteur de politiques à l’intérieur du proxy de l’hôte, avec un mode par ressource : Off, Block destructive ou Read-only. Réglez DigitalOcean sur Read-only et chaque mutation vers api.digitalocean.com revient en 403 franc que l’agent rapporte comme un échec d’API ordinaire. Bromure prend cette décision dans macOS. Il n’y a aucun réglage à changer dans la VM, aucune variable d’environnement à désactiver et aucun fichier à modifier.

Publier demande d’abord

Chaque entrée d’identifiant a Require approval to use. Activez-la et chaque échange leurre→vrai lève une boîte de dialogue de consentement sur l’hôte avant que la vraie valeur n’atteigne le fil, avec des autorisations limitées dans le temps sur le chemin SSH : cinq minutes, une heure, le reste de la session. Pousser une version, c’est quelque chose que vous vouliez faire, et cela vous coûte un clic. Une écriture que vous n’avez pas lancée apparaît comme une boîte de dialogue à laquelle vous ne vous attendiez pas, à un moment où vous n’en attendiez aucune.

Registres et git ont le même traitement

Guardrails classe le trafic des registres de conteneurs par méthode, contre le nom d’hôte du registre lui-même : GET et HEAD sont un pull, PUT et POST sont un push, DELETE est destructif. GitHub, GitLab et Bitbucket sont couverts à la fois pour l’API REST et pour git sur HTTPS, où git-receive-pack compte comme une écriture et est bloqué en mode Read-only alors que les fetches passent toujours. Un profil qui lit tout votre graphe de dépendances et ne pousse rien, c’est deux réglages.

L’environnement est une liste que vous avez écrite

Un profil Bromure voit les dossiers du Mac que vous avez partagés avec lui, jusqu’à huit, chacun monté sous /home/ubuntu. Tout le reste sur votre Mac n’est pas monté, pas joignable et pas énumérable. Le jeu d’identifiants est celui que vous avez ajouté à ce profil, si bien qu’un profil dont vous vous servez pour construire un frontend ne contient aucun identifiant de cluster à trouver, caché ou non.

Sur le portable du développeurDIGITALOCEAN_ACCESS_TOKEN=dop_v1_…~/.config/doctl, ~/.docker/config.json,~/.git-credentials, ~/.aws, ~/.sshl’agent est retourné, une foisun ticket, une page, un README, une ligne de logil lit désormais ce que vous lisezun seul PUT vers un bucketet l’artefact que vos utilisateursrécupèrent automatiquement est à euxDans un profil BromureDIGITALOCEAN_ACCESS_TOKEN=brm_…chaque dotfile est présent et chaquesecret qu’il contient est un leurrela vraie valeur reste sur le Macsubstituée sur le fil par le proxy de l’hôte,pour api.digitalocean.com et nulle part ailleursGuardrails : Read-only → 403l’écriture est tranchée dans macOS, etRequire approval met un dialogue avant
Le même identifiant d’écriture, à deux endroits. Sur un portable, c’est un vrai jeton dans un vrai fichier, à un agent retourné de distance de l’artefact que vos utilisateurs récupèrent. Dans un profil Bromure, le vrai jeton n’entre jamais dans la VM, et l’opération qu’il effectuerait est tranchée par l’hôte.

La récupération à l’exécution traverse le proxy elle aussi

Prenez maintenant le versant aval de l’histoire, celui où vous êtes l’un des 350 000. Ce que vous pouvez y attraper dépend de l’endroit où vous placez l’inspection.

Le panneau Supply Chain de Bromure filtre les récupérations de paquets sur npm, PyPI, Cargo, RubyGems, Maven, NuGet, les modules Go et Packagist : une barrière d’âge activée par défaut qui refuse tout ce qui a été publié dans les deux derniers jours, des recherches OSV, socket.dev ou Delpi comme fournisseur de filtrage, le retrait des scripts d’installation qui réécrit le tarball et corrige l’empreinte des métadonnées du registre pour que npm vérifie toujours, et une invite avant que les tarballs épinglés par lockfile ne passent inchangés. Les .npmrc et pip.conf à l’intérieur de la VM peuvent resserrer ces règles et ne peuvent pas les desserrer.

Ces règles paient leur écot à cause de l’endroit où Bromure les applique. Le proxy de l’hôte les applique à une requête plutôt qu’à un paquet, si bien qu’un fichier JSON tiré d’un bucket d’éditeur à l’exécution par une étape de build, une CLI, un serveur MCP ou l’application elle-même traverse la même frontière qu’un tarball. Tout ce que la VM dit au réseau la traverse.

Cela change ce à quoi ressemblent les sept semaines. Avec Session trace sur Activity, Bromure enregistre l’hôte, le statut, la latence, le rapport de substitution et les éventuels avertissements de fuite pour chaque requête que fait la VM. Réglez-le sur Everything et il garde aussi les corps, pour chaque hôte, chiffrés avec la même clé de trousseau que les secrets de votre profil et lisibles dans le Trace Inspector. Le jour où un flux dont dépend votre build se met à répondre différemment est un diff que vous pouvez ouvrir, sur le Mac, dans un journal où la VM n’a aucun accès en écriture.

Inspection dans le gestionnaire de paquetsvoit : tarballs, versions, lockfilesse déclenche quand vous installezGET banner.json → le bucket de l’éditeurpas une installation, donc pas un événementsept semaines, aucune trace gardéeInspection dans le proxy de l’hôtevoit : chaque requête de la VMâge, OSV, filtrage, retrait des scriptsGET banner.json → le bucket de l’éditeurhôte, statut, latence, rapport de swapet le corps, au niveau Everything
L’endroit où se fait l’inspection décide de ce qu’elle peut voir. Un contrôle dans le gestionnaire de paquets n’inspecte que des choses qui ont été publiées ; un contrôle dans le proxy de l’hôte inspecte chaque requête que fait la VM, y compris la récupération à l’exécution qui a porté cette charge utile.

Un flux empoisonné est aussi, au bout du compte, du texte qui arrive sur une machine. Quand il atteint l’agent sous forme de tool_result, de fichier, de page récupérée ou de fichier d’instructions comme CLAUDE.md ou AGENTS.md, le panneau Prompt Injection le note sur l’appareil avec un modèle local, et rien ne quitte le Mac. Choisissez Ask me what to do et la requête se met en pause avec le passage signalé affiché ; choisissez Block unilaterally et l’agent reçoit un 451 franc. Quand vous voulez que la machine disparaisse, Erase home réinitialise /home/ubuntu et Reset to base reclone le disque système du workspace depuis l’image de base.

BdThemes corrigera l’échappement, le bucket recevra des clés plus strictes, et la prochaine version de cette histoire arrivera avec un autre éditeur et un autre flux. Ce qui dure, c’est l’arrangement en dessous. Votre environnement de développement détient une poignée d’identifiants, dont chacun peut réécrire quelque chose que des milliers de gens récupèrent à intervalles réguliers, et ce même environnement fait maintenant tourner un agent qui lit du texte d’inconnus toute la journée.

La plupart des articles ici demandent ce que votre agent devrait avoir le droit de lire. Répondez aussi à l’autre question, celle qui est inconfortable sur un portable et facile dans un profil : qu’a-t-il le droit d’écrire, et auprès de combien de gens ?

Installez Bromure Agentic Coding, et donnez à l’agent une machine qui ne détient la clé de rien de ce que vous publiez.