Volver a todas las publicaciones
Publicado el · por Renaud Deraison

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”.

jscrambler · 11 jul.8.18.0: hook eliminadoAsyncAPI · 14 jul.sin ningún hookPUERTA DE INSTALACIÓNpreinstall / postinstallcerrada por defecto (npm v12)cerrada por --ignore-scriptscerradaPUERTA DE IMPORTACIÓNrequire() / importloader en el punto de entradanpm v12 no la tocaabiertaMISMA CARGA ÚTILproceso node separadoetapa de 8,2 MB por IPFSrecolector de credencialesC2 · 85.137.53.71
Misma carga útil, una puerta distinta. Durante un año la carga vino por la puerta de los scripts de instalación: npm ejecuta preinstall/postinstall automáticamente. npm v12 (8 de julio) cierra esa puerta por defecto, y --ignore-scripts la cierra a mano. Pero una segunda puerta lleva a la misma sala: el código que se ejecuta cuando importas el paquete. jscrambler (11 de julio) movió su dropper de un gancho preinstall a dist/index.js; AsyncAPI (14 de julio) llegó sin ningún gancho de instalación, con su loader conectado al propio punto de entrada del paquete. Cerrar la puerta de instalación no toca la puerta de importación.

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.

COMPROBACIÓN DE MECANISMO · MIRA LA CARGA“¿tiene un hook de instalación?”preinstall / install / postinstallAsyncAPI responde, con verdad:nopasa la comprobaciónloader dispara en require()BARRERA DE EDAD BROMURE · MIRA LA VERSIÓN“¿qué edad tiene esta versión?”límite: 2 días, activo por defectoel registro dice publicada:hace 19 minutosrechazada · 451la carga nunca se resuelve
Dos maneras de decidir si dejar pasar un paquete. Una comprobación de mecanismo hace una pregunta sobre la carga útil — ¿tiene un gancho de instalación? — y AsyncAPI responde “no” con verdad, así que la comprobación lo deja pasar. La barrera de edad de Bromure hace una pregunta sobre la versión — ¿qué edad tiene? — y una versión publicada hace diecinueve minutos se rechaza con un 451 sin importar dónde se dispare su código. El mecanismo pasó de la instalación a la importación entre dos ataques en una semana; la edad no se movió, porque una publicación fresca es la forma del compromiso.

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.