La señal delatora era la marca de tiempo
El 8 de julio de 2026 npm lanzó su mayor cambio de seguridad en dieciséis años: scripts de instalación desactivados por defecto. En menos de una semana, dos compromisos distintos de npm movieron su carga útil para dispararse cuando se importa el paquete en vez de cuando se instala, esquivando directamente el nuevo valor por defecto y --ignore-scripts. El mecanismo que el ecosistema acababa de pasar un año aprendiendo a vigilar ya se había movido. Lo único que no se movió fue la edad de la versión maliciosa, y esa es la señal que comprueba la barrera de edad de Bromure.
El ecosistema entero pasó un año aprendiendo que los scripts de instalación de npm eran el peligro. El 8 de julio npm los desactivó por defecto. Tres días después, un compromiso sacó su carga útil del script de instalación; tres días más tarde, un segundo llegó sin ningún script de instalación. Ambos se disparan cuando importas el paquete. El arreglo y el rodeo se cruzaron en la misma semana.
Alguien añade @asyncapi/generator a un proyecto el 14 de julio y ejecuta
npm install. El paquete no tiene preinstall, ni install, ni gancho
postinstall: nada que un escáner de scripts pueda marcar, nada que el
flamante valor por defecto de npm deba omitir. La instalación termina
limpia. La primera vez que el código hace require('@asyncapi/generator'),
un loader conectado al propio punto de entrada del paquete lanza un proceso
Node separado, descarga una segunda etapa de 8,2 MB por IPFS, la descifra y
se pone a cazar credenciales. Nada en la instalación pareció mal, porque la
parte que estaba mal no ocurrió en la instalación.
El arreglo que llegó para el ataque del año pasado
Durante casi un año, la historia de los ataques a la cadena de suministro
de npm fue la misma: un paquete comprometido lleva un gancho postinstall,
y npm install lo ejecuta automáticamente en tu máquina. Así que el 8 de
julio npm lanzó la versión 12: el mayor
rediseño de seguridad en los dieciséis años de la herramienta. Los scripts
de ciclo de vida de las dependencias ahora están desactivados por defecto:
“los scripts preinstall, install y postinstall de las dependencias no se
ejecutan salvo que los permitas”. Las dependencias Git y las fuentes por
URL remota también pasaron a ser opcionales. Es una mejora real, y cierra
una puerta por la que pasó un año de gusanos.
Cierra una puerta. Los atacantes ya habían encontrado la otra.
La carga útil se mudó al momento de importar
El 11 de julio, el paquete npm jscrambler fue
comprometido:
cinco versiones maliciosas publicadas a lo largo de unas tres horas. Las
tres primeras, de la 8.14.0 a la 8.17.0, “ejecutaban el dropper desde un
gancho preinstall”, el patrón clásico. Luego, a mitad de campaña, el
atacante cambió de puerta. “A partir de la 8.18.0 el gancho de instalación
desaparece por completo: el mismo dropper se inyecta en su lugar como una
función autoejecutable al inicio de dist/index.js”, de modo que “se
dispara cuando el paquete se importa o se ejecuta su CLI en vez de en la
instalación”. Socket nombra el motivo sin rodeos: “Esto es una evasión
deliberada: derrota a los escáneres que solo inspeccionan scripts
preinstall/postinstall y sobrevive a npm install --ignore-scripts”.
Tres días después, la organización npm
@asyncapi
fue comprometida de la misma forma, pero construida para ello desde el
principio. Cinco versiones en cuatro paquetes — @asyncapi/specs
6.11.2-alpha.1 y 6.11.2, @asyncapi/generator 3.3.1,
@asyncapi/generator-components 0.7.1, @asyncapi/generator-helpers
1.1.1 — fueron republicadas después de que un atacante usara un flujo de
trabajo de GitHub Actions mal configurado para filtrar el token del bot del
proyecto. El hallazgo de Microsoft es el que importa aquí: “Todos los
paquetes afectados no declaraban ningún gancho preinstall, install ni
postinstall en package.json”. El loader se alojaba en su lugar dentro del
punto de entrada normal de cada paquete — index.js para las specs, un
validator.js en lo hondo de las plantillas del generador. Y el consejo de
mitigación es explícito: “No confíes en npm install --ignore-scripts como
mitigación; esta campaña se ejecuta cuando el módulo se importa, no a
través de un gancho de ciclo de vida”.
Dos actores sin relación, con tres días de diferencia, dando ambos con el mismo movimiento en la misma semana en que el arreglo estrella del ecosistema entró en servicio. Eso no es coincidencia. Es lo que ocurre cuando una defensa nombra un mecanismo: el mecanismo se convierte en un punto de apoyo, y estar en otro sitio le cuesta al atacante unas pocas líneas de código. Un escáner que pregunta “¿tiene este paquete un gancho de instalación?” recibe un “no” veraz de AsyncAPI, y lo deja pasar.
Lo único que no se movió
Quita el mecanismo de entrega y mira qué se mantuvo constante en ambos compromisos. Las versiones de jscrambler estuvieron activas unas horas. La versión estable de AsyncAPI se publicó a las 08:30 UTC y la detección aguas abajo empezó a las 08:49: diecinueve minutos. Cada versión maliciosa de ambos incidentes fue retirada bastante dentro de las dos horas de haberse subido. La carga útil se movió del gancho de instalación al punto de entrada; la entrega pasó de un script de ciclo de vida a un loader en tiempo de ejecución. La única propiedad que no se movió es que cada versión envenenada era completamente nueva. Una versión recién publicada es la huella compartida de un compromiso, porque esa es la forma del ataque: robar un token, publicar, ser descubierto, ser retirado. La ventana es corta por construcción.
Esa es la propiedad que comprueba la barrera de edad de
Bromure, y está activa por defecto. Cada descarga de paquete — npm, PyPI,
Cargo y las demás — pasa por el proxy man-in-the-middle del host antes de
que la instalación del agente vea un solo byte, y el proxy rechaza
cualquier versión más joven que el umbral, dos días por defecto. Una
referencia flotante como latest o un rango semver se resuelve en silencio
a la versión más reciente que sea lo bastante antigua; una referencia
fijada a una versión demasiado fresca vuelve como un 451 con un error de
Bromure que explica por qué. Nunca pregunta dónde vive la carga útil. No le
importa si el código se dispara en preinstall o en require(), si el
gancho está presente o ausente, si --ignore-scripts está puesto. Una
versión publicada hace diecinueve minutos se rechaza porque tiene
diecinueve minutos. Ambas campañas estaban muertas al llegar frente a una
barrera de dos días, no porque Bromure reconociera la carga útil, sino
porque no miró la carga útil en absoluto.
La barrera de edad no afirma que los paquetes nuevos sean maliciosos: la mayoría están bien, y una versión de dos días sigue siendo muy nueva. Afirma algo sobre dónde se concentra el riesgo. La ventana peligrosa de un compromiso son las primeras horas tras que un token robado empuja una versión, antes de que un mantenedor lo note y la retire. Esperar dos días no hace seguro un paquete, pero saca tu instalación del intervalo exacto en que viven estos ataques, y lo hace sin inspeccionar una sola línea del código que estás a punto de ejecutar. Para un paquete que necesitas el día en que sale, la barrera admite una excepción por paquete; todo lo demás espera sus dos días.
Cuando una versión ya es lo bastante vieja de todos modos
Una barrera de edad es un filtro, no un muro. Pon un umbral más corto, exime un paquete que necesitas el primer día, o retira una versión maliciosa que quedó sin detectar el tiempo suficiente para envejecer más allá de dos días, y el código envenenado llega a la instalación. La respuesta de Bromure para ese caso es la que describen los artículos anteriores sobre la cadena de suministro, y es la razón por la que la barrera de edad puede permitirse ser un filtro en vez de toda la defensa: el agente que ejecuta la instalación no está en tu Mac.
Está dentro de una VM Linux desechable, a una frontera de hipervisor de
distancia. Así que cuando el loader de AsyncAPI se dispara y su recolector
de credenciales sale a buscar — y busca a fondo, barriendo más de un
centenar de variables de
entorno
entre ellas NPM_TOKEN, AWS_ACCESS_KEY y ANTHROPIC_API_KEY, además de
.npmrc, ~/.aws/credentials, id_ed25519 y el kubeconfig en disco —
encuentra las copias de la VM, que son cadenas brm_… de relleno. Los
valores reales viven en el host. Cuando el agente hace una petición
legítima que necesita uno, el proxy del host cambia el falso por el valor
real en el cable, solo para ese salto, y lo cambia de vuelta; el secreto
nunca aterriza en la memoria de la VM para que el recolector lo raspe. La
clave ~/.ssh que la carga copia es un par desechable acuñado para ese
perfil. Y la subida al C2 que la carga intenta — a 85.137.53.71, en este
caso — sale por el mismo proxy del host que registra cada destino, así que
un proceso Node que nunca iniciaste abriendo un socket a una IP desconocida
es una línea en el Registro de seguridad, no una transferencia silenciosa.
Cuando la ventana se cierra, la VM y todo lo que la carga hizo dentro
desaparecen.
Es la misma familia de runtime Miasma que se coló en el propio ámbito npm de Red Hat a principios de este año. El recolector es maduro y se reutiliza entre campañas; el mecanismo de entrega es la parte que no para de cambiar.
La lección incómoda de esta semana en concreto es que una defensa atada a un mecanismo tiene una vida media que se mide en días. El ecosistema pasó un año haciendo de los scripts de instalación la cosa a vigilar, lanzó el arreglo y vio cómo dos actores distintos reubicaban la carga útil antes de que las notas de la versión se enfriaran. Esa carrera no se gana nombrando el próximo mecanismo, porque siempre hay otro sitio donde el código puede dispararse. Se gana comprobando algo que el atacante no puede cambiar a bajo coste — qué edad tiene la versión — y ejecutando lo que se cuela en un sitio donde no pueda llevarse nada. Pruébalo.