Actualités

crates.io : arrayref piégé pendant 107 minutes — auditer vos runners et sortir le réseau de `cargo build`

Le 20 août 2026, trois crates dont arrayref ont été republiées depuis un compte mainteneur compromis, avec une dépendance typosquattée dont le build.rs télécharge et exécute un binaire distant : un simple `cargo build` suffisait. Retour sur le mécanisme, protocole d'audit des runners et des caches, puis runbook de durcissement spécifique à Cargo — build.rs, macros procédurales, egress firewall et Trusted Publishing.

| | | 9 min read · by
Terminal affichant une compilation Cargo interrompue par une alerte de connexion réseau sortante suspecte

Nos précédents articles sur les attaques de supply chain parlaient de npm et de GitHub Actions : Shai-Hulud, CHAINDROP, TeamPCP. L’incident du 20 août 2026 sur crates.io change de terrain, et surtout de mécanique. Ici, la charge ne s’exécute ni au postinstall, ni au runtime : elle s’exécute dans le compilateur. Les contre-mesures que vous avez déployées côté JavaScript ne s’appliquent quasiment pas.

Ce qui s’est passé, à la minute près

Le 20 août 2026, trois crates appartenant au même compte propriétaire ont été republiées sur crates.io : arrayref@0.3.10 (environ 245 millions de téléchargements cumulés pour la crate), internment@0.8.7 et append-only-vec@0.1.9. Le signalement à l’équipe Rust Security Response a été rapide, et les versions malveillantes ont été retirées du registre en 86 à 107 minutes selon la crate. Le compte du mainteneur a été verrouillé dans la foulée.

L’équipe Rust Security Response estime que la machine ou les identifiants du mainteneur ont été compromis, sans intention malveillante de sa part. C’est une nuance importante : il n’y a pas eu de transfert d’ownership suspect ni de nouveau contributeur à surveiller. Le compte légitime a simplement publié, avec les droits légitimes, du code qui ne l’était pas.

Le mécanisme est élégant, et c’est ce qui le rend intéressant. Le code des trois crates populaires n’a pratiquement pas été modifié. Ce qui a changé, c’est le manifeste : une dépendance vers proc-macro1, typosquat de l’omniprésente proc-macro2, a été ajoutée. Pour arrayref, c’était la première dépendance en dix ans d’existence — une crate volontairement sans dépendances, ce qui aurait dû sauter aux yeux sur une revue de diff, mais qui ne saute aux yeux de personne quand la mise à jour arrive via une résolution transitive.

# arrayref 0.3.10 (version malveillante, extrait reconstitué)
[dependencies]
proc-macro1 = "1.0"   # première dépendance ajoutée depuis 2015

Le payload ne vit donc pas dans arrayref, mais dans le build.rs de la dépendance injectée : téléchargement puis exécution d’un binaire distant, au moment de la compilation. Aucun appel de fonction n’est nécessaire. cargo build, cargo test, cargo check sur un projet qui tire arrayref de manière transitive suffit à déclencher la chaîne.

Dernier détail qui compte pour vos outils : l’adresse du serveur de commande n’apparaît pas en clair. Elle est stockée en fragments encodés en base64, recomposés au build. Un grep sur une IP ou un nom d’hôte dans les sources vendorisées ne remonte rien. La détection statique naïve — recherche de chaînes, de reqwest, de Command::new — passe à côté ou noie l’analyste sous les faux positifs.

Wiz relève par ailleurs un chevauchement d’infrastructure avec des campagnes attribuées à des acteurs nord-coréens, et note qu’arrayref est présente dans la très grande majorité des environnements où Rust est utilisé.

Pourquoi Cargo est structurellement exposé

build.rs n’est pas un bug, c’est une fonctionnalité : générer des bindings, compiler du C avec cc, détecter des features de la plateforme. C’est aussi, par construction, de l’exécution de code arbitraire à la compilation, avec les droits de l’utilisateur ou du runner CI, avant tout sandbox runtime, avant tout contrôle de permissions applicatives. Le modèle de menace de Rust — pas de UB, pas de data race — s’arrête à la frontière du compilateur.

Les macros procédurales posent exactement le même problème sous un autre nom : une proc-macro est un cdylib chargé et exécuté dans le processus rustc. Générer du code et lire /etc/passwd sont, pour le compilateur, la même catégorie d’opération.

Vient ensuite le piège de la transitivité. Personne, ou presque, n’écrit arrayref = "0.3" dans son Cargo.toml. Elle remonte via tiny-skia, winit, egui/eframe, iced, et une longue liste d’autres. Vous ne l’avez jamais ajoutée, vous ne la connaissez peut-être pas, et elle est pourtant compilée sur votre CI toutes les nuits.

Enfin, crates.io n’impose aucune revue de code avant publication, et le retrait d’une version n’est pas synchrone avec la publication d’un advisory. Il existe une fenêtre — parfois plusieurs heures — pendant laquelle la version a disparu du registre alors que cargo audit ne remonte encore rien. Absence d’alerte ≠ absence de compromission.

Suis-je touché ? Le protocole d’audit

La fenêtre à examiner est étroite : les builds effectués approximativement entre 07h00 et 09h00 UTC le 20 août 2026. Cela couvre les runners CI, les images de build reconstruites ce jour-là, les postes développeurs, et — point souvent oublié — les caches partagés (sccache, caches ~/.cargo montés entre jobs, layers Docker).

Commencez par le cache local et les lockfiles :

# Artefacts téléchargés
ls ~/.cargo/registry/cache/*/proc-macro1-*.crate 2>/dev/null
ls -d ~/.cargo/registry/src/*/proc-macro1-* 2>/dev/null
ls -d ~/.cargo/registry/src/*/arrayref-0.3.10 2>/dev/null

# Présence dans un lockfile actuel
grep -n 'name = "proc-macro1"' Cargo.lock

# Présence historique : le lockfile a-t-il été modifié puis reverté ?
git log -S 'proc-macro1' --all -- '**/Cargo.lock'
git log -S 'arrayref' -p --all -- '**/Cargo.lock' | head -50

Vérifiez ensuite d’où vient la dépendance, pour savoir quels projets sont dans le périmètre :

cargo tree -i arrayref --workspace

Côté réseau, l’indicateur de compromission publié est l’hôte 23.254.165.112 sur le port 9089. Corrélez avec vos flow logs VPC, votre collecte eBPF ou vos logs de proxy sortant, sur la fenêtre du 20 août. C’est la preuve la plus solide dont vous disposerez : les artefacts locaux ont pu être nettoyés par un cargo clean ou une rotation d’image, pas les logs réseau.

Si un runner est suspect, traitez-le comme compromis, pas comme sale. Concrètement : révoquer tout secret ayant été exposé au processus de build — tokens de registre, clés de signature, credentials de container registry, identités OIDC et rôles cloud assumables depuis ce runner — puis reconstruire l’image plutôt que la nettoyer. Un cargo clean supprime target/, il ne supprime ni une persistance ni les données déjà exfiltrées.

Durcir la compilation : le runbook

Verrouiller la résolution. En CI, --locked (ou --frozen, qui interdit aussi l’accès réseau) doit être la valeur par défaut. Le lockfile est committé, y compris pour les bibliothèques : il ne sera pas utilisé par vos consommateurs, mais il rend votre propre CI déterministe et fait apparaître toute nouvelle dépendance dans une diff relue.

cargo build --locked --workspace
cargo test  --locked --workspace

Un build qui échoue parce que le lockfile est obsolète est un signal, pas une nuisance.

Couper le lien direct avec crates.io. Un miroir interne ou un cargo vendor committé permet d’appliquer une quarantaine temporelle : aucune version publiée depuis moins de 24 à 72 heures n’entre dans les builds. Face à un incident retiré en 107 minutes, cette seule mesure aurait suffi.

Egress firewall sur les runners. C’est la contre-mesure la plus efficace, et la plus sous-déployée. Un job de compilation n’a besoin que de son registre interne et de son cache d’artefacts. Tout le reste est refusé par défaut, et le refus est journalisé — ce log devient votre détecteur.

# nftables, runner de build
table inet build_egress {
  chain output {
    type filter hook output priority 0; policy drop;
    ct state established,related accept
    ip daddr 10.20.0.0/16 accept          # registre + cache internes
    log prefix "BUILD-EGRESS-DROP " limit rate 5/second
  }
}

Isoler. L’étape de compilation tourne dans un container sans secrets montés, sans identité cloud attachée, sans accès au socket Docker. La publication est un job distinct, qui consomme un artefact déjà produit et n’exécute aucun code tiers.

Outiller — en sachant ce que chaque outil voit. cargo-audit compare votre lockfile à la base RustSec : il détecte ce qui a déjà fait l’objet d’un advisory, donc rien pendant les premières heures d’un incident. cargo-deny va plus loin : bans nominatifs, sources autorisées, refus des versions yanked.

# deny.toml
[advisories]
yanked = "deny"

[bans]
multiple-versions = "warn"
deny = [{ name = "proc-macro1" }]

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

Aucun de ces outils ne vous alerte spontanément sur l’apparition d’un build.rs. Cet inventaire, faites-le vous-même et suivez-le en diff :

cargo vendor vendor/ > /dev/null
find vendor -maxdepth 2 -name build.rs \
  | sed 's|vendor/||; s|/build.rs||' | sort > build-scripts.txt
git diff --exit-code build-scripts.txt || echo "Nouveau build script — à relire"

Pour les composants réellement critiques, cargo-vet permet d’exiger qu’une version précise ait été auditée — par vous ou par une organisation en qui vous avez confiance — avant d’entrer dans un build.

Côté publication : réduire le risque compte mainteneur

L’incident vient d’un compte mainteneur, pas d’une faille de crates.io. La réponse structurelle existe : le Trusted Publishing par OIDC, qui supprime le token API longue durée stocké dans un secret CI — exactement le type de secret qu’un poste compromis exfiltre.

jobs:
  publish:
    permissions:
      id-token: write
      contents: read
    environment: crates-io
    steps:
      - uses: actions/checkout@v4
      - uses: rust-lang/crates-io-auth-action@v1
        id: auth
      - run: cargo publish --locked
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

Pour vos propres crates : tokens à portée restreinte (une crate, publish-update uniquement), expiration explicite, rotation planifiée, et un compte de publication distinct du compte de développement quotidien.

Grille de décision et anti-patterns

Toutes les équipes n’ont pas le même budget de sécurité. Une équipe produit qui livre un service interne tire l’essentiel du bénéfice avec trois mesures : --locked en CI, cargo-deny bloquant, et egress firewall sur les runners. Une équipe embarqué, crypto ou finance — où le binaire signé part chez un client ou gère des clés — ajoute le registre interne avec quarantaine, cargo-vet sur le noyau critique, et une séparation stricte build/publish avec attestation de provenance.

Trois anti-patterns à éliminer en priorité :

  • « On scanne les CVE, donc on est couvert. » Un scanner de vulnérabilités n’a rien à dire d’une crate publiée depuis 40 minutes. Sur cet incident, il aurait été silencieux pendant toute la fenêtre d’exposition.
  • Dependabot en auto-merge. L’automatisation des mises à jour transforme une republication malveillante en déploiement automatique. Auto-merge sur les patchs de dépendances suppose un délai de quarantaine ; sans lui, c’est un raccourci vers le runner.
  • Runner de build partageant les secrets de déploiement. Tant que le job qui exécute du code tiers détient les credentials de production, la surface de compilation est la surface de production.

Checklist à coller dans le backlog : --locked partout ; Cargo.lock committé, y compris pour les libs ; cargo-deny en gate bloquante ; inventaire versionné des build.rs ; egress deny-by-default sur les runners avec logs conservés ; quarantaine de 72 h sur les nouvelles versions ; Trusted Publishing pour vos crates ; séparation build/publish.


La leçon de ces 107 minutes n’est pas qu’il faut réagir plus vite : l’équipe Rust Security Response a été remarquablement rapide, et cela n’aurait rien changé pour un runner ayant compilé à 07h30 UTC. Elle est que le compilateur est un environnement d’exécution comme un autre, et qu’il mérite le même traitement : pas de secrets, pas de réseau, pas d’identité cloud, des entrées figées. La plupart des équipes appliquent déjà ces règles à leur runtime de production. Il reste à les appliquer à l’endroit où le code tiers s’exécute pour la première fois — c’est-à-dire bien avant la production.

Comments (0)

Please log in to leave a comment.

Log In

No comments yet. Be the first to comment!