DevSecOps

Node.js lo dejó en «alto» y Google en «crítico 9.2»: el fallo de Brotli que ya está en KEV

# Node.js lo dejó en «alto» y Google en «crítico 9.2»: el fallo de Brotli que ya está en KEV

El 21 de octubre de 2026, el proyecto Node.js publicó actualizaciones de seguridad para sus tres ramas activas y corrigió diez vulnerabilidades: cuatro altas, tres medias y tres bajas. En la nota oficial, la severidad más alta corregida es HIGH en las tres líneas. Esa es la lectura que se ha publicado en casi todas partes.

Pero hay otra lectura, y es la que importa. La vulnerabilidad crítica no la asignó Node.js: la asignó Google, que actúa como CNA del proyecto Brotli, del que Node.js consume el decodificador. Google le dio un CVSS 4.0 de 9.2, crítico, con vector de ataque de red, sin privilegios y sin interacción de usuario. Y el 27 de octubre, CISA añadió esa vulnerabilidad a su catálogo de vulnerabilidades explotadas conocidas, con fecha límite de remediación el 10 de noviembre.

Es decir: el 21 de octubre se publicó un parche, el 22 salió la puntuación de 9.2, el 27 se añadió a KEV, y el 26 ya se observaba tráfico de explotación real. La fuente en español que sirve de origen a esta noticia no mencionaba ni la severidad de 9.2 ni la entrada en KEV. Las dos cosas cambian la conversación.

El fallo: ocho bytes, un desplazamiento de dos gigabytes

El identificador es CVE-2026-111497. Es una escritura fuera de límites en el decodificador de Brotli que el propio Node.js admite que puede permitir ejecución remota de código.

El mecanismo merece una explicación, porque no es un fallo del tipo habitual. Brotli es un formato de compresión. Su decodificador funciona como una máquina de estados: va leyendo bloques y va cambiando de tipo de bloque según lo que la cabecera del stream declara. Existe una construcción llamada block switch, que es simplemente una categoría que declara un solo tipo de bloque, sin necesidad de más datos.

El problema es que ese block switch inválido no se reportaba como un error de formato permanente, sino como una necesidad temporal de más entrada. La diferencia entre las dos cosas es todo el fallo: si el decodificador cree que solo necesita más bytes, sigue acumulándolos. Los añade a un buffer interno de ocho bytes. Y sigue añadiendo, porque la condición de error nunca se cumple, hasta que escribe fuera de ese buffer.

La geometría de la corrupción, según el análisis de ELLIO, es lo que convierte esto en un fallo crítico y no en un denegación de servicio inocua. La escritura es relativa al buffer circular y usa un desplazamiento de 32 bits con signo, lo que significa que un cliente que controla el stream puede elegir un destino dentro de un rango aproximado de dos gigabytes alrededor del buffer. Y copia allí dieciséis bytes de contenido que él mismo controla. Eso no es un desbordamiento que corrompe el proceso y revienta: es una primitiva de escritura arbitraria con contenido elegido por el atacante.

El parche upstream, en google/brotli, da al helper de block switch su propio tipo de error. Un switch inválido devuelve ahora BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH con el valor -17. En las versiones sin parchar, el decodificador falla más tarde, con un error de longitud de bloque.

Y aquí viene el dato que explica por qué esto no se vio en febrero: el commit upstream que arreglaba el problema entró en el repositorio público de Brotli el 27 de febrero de 2026. No se publicó como versión de seguridad hasta el 21 de octubre, en Brotli 1.2.1, 1.1.1 y 1.0.10. Entre una cosa y otra hay casi ocho meses en los que el arreglo estaba escrito a la vista de todos y ninguna distribución lo empaquetó. Node.js hizo un cherry-pick del commit 0ed4268a4401abcb4472096340477cf4af7d1a2b, con el título "Detect and report invalid stream earlier", con dos commits propios, uno para 26.12.1 y otro para 22.23.4.

Lo que ya se está explotando

Esto ya no es una vulnerabilidad teórica. Hay tres hitos públicos en menos de una semana.

El 22 de octubre, el equipo de rojo de Imperva demostró ejecución de código sobre la imagen oficial de Node.js 24.22.0 para Linux arm64. Lo hizo por dos vías: a través del fetch() integrado consumiendo una respuesta comprimida con Brotli, y a través de Express 5 con body-parser usando los métodos raw() y json(). Los mismos insumos fueron rechazados por 24.22.1.

ELLIO reprodujo ejecución de código en x86-64 contra cuatro objetivos con los binarios oficiales y la configuración por defecto: un servidor node:http a pelo, la imagen oficial node:24.22.0-slim ejecutando como root, Express 5.3.0 y Koa 3 con su parser de cuerpo. En los cuatro casos el resultado fue una shell ejecutándose con la cuenta del propio servicio.

Y el 26 de octubre, ELLIO registró tráfico de explotación en el mundo. La secuencia observada es reveladora: el día anterior, un comprobador de exposición público que se identificaba como node2shell-check envió veinte cuerpos Brotli en dos ráfagas para mapear qué servidores descomprimen Brotli. Al día siguiente llegó el tráfico de explotación desde un origen distinto, con el exploit público. Bun rebautizó el nombre del problema como HTTP2Shell el 24 de octubre.

El vector de ataque tiene tres caminos que determinan qué código tuyo está expuesto. El primero son los cuerpos de petición entrantes que el framework descomprime automáticamente: Express 5, body-parser 2.x y los parsers de Koa. El segundo son las respuestas salientes que el cliente descomprime solo: el fetch() integrado, undici, axios, node-fetch, got, SuperAgent, y en Python requests, urllib3, httpx o aiohttp si tienen Brotli instalado. El tercero son las llamadas directas a zlib, con brotliDecompress, brotliDecompressSync y createBrotliDecompress. Un servidor node:http pelado no infla cuerpos Brotli por su cuenta, y Express 4 con body-parser 1.x rechaza los cuerpos br con un 415.

Los otros nueve, y por qué hay que leer la letra pequeña

El resto del lote no es ruido, pero hay que leerlo con precisión, porque dos de los titulares sobre este lanzamiento se equivocan.

Las tres vulnerabilidades TLS del lote no son fallos de cadena de confianza. Son fallos de verificación del nombre de host, clasificados como CWE-295, y los tres funcionan de la misma manera: el cliente no reconoce una forma no canónica de escribir una dirección IP como si fuera una IP, la normaliza y la compara contra los nombres del certificado, de modo que un certificado emitido para una IP puede autenticar a otra.

CVE-2026-105483, alta, es una regresión: la función no reconocía como dirección IP un host escrito con dígitos de ancho completo, y lo comparaba, y lo comparaba contra los nombres DNS del certificado. CVE-2026-106069, alta, es la más elegante: el motor normaliza el host con reglas URL del WHATWG, leyendo un octeto con cero inicial como octal, de modo que 0127.0.0.1 se convierte en 87.0.0.1, mientras que la conexión se hace con el resolutor del sistema operativo, que en macOS lo lee como decimal, 127.0.0.1. Las dos interpretaciones no coinciden y la conexión aterriza en un servidor distinto del que el certificado identifica. CVE-2026-106072, media, acepta grafías no canónicas como 0177.0.0.1, 0x7f.0.0.1, 2130706433 o 127.1 siempre que el certificado tenga un SAN DNS numérico.

Los tres requieren que el cliente abra una conexión TLS a una dirección que el atacante pueda influenciar, una URL de webhook, una previsualización de enlace, un host elegido por el usuario, y que acepte un certificado de confianza que nombre la IP de destino. Las conexiones a nombres de host fijos no se ven afectadas. Lo que no son es fallos de cadena de confianza, y quien los presente como tales se equivoca.

De los demás, dos merecen atención por lo que dicen de la plataforma. CVE-2026-105482, alta, trunca en silencio los nombres de host con un byte nulo en las funciones dns.resolve y dns.reverse, porque el nombre se pasa a c-ares como cadena C: resolver attacker.example con un nulo seguido de trusted.example consulta en realidad el primero, sin error, lo que permite saltarse listas de permitidos. Y CVE-2026-106074 es la que más relevante para a quien usa instantáneas: las cachés de entropía internas de randomUUID, randomUUIDv7 y randomInt se guardan en las instantáneas de arranque y en las aplicaciones ejecutables únicas, así que cada proceso lanzado desde esa instantánea devuelve los mismos valores, hasta 127 UUID y alrededor de mil valores de randomInt repetidos.

La trampa de verificar: tu Node.js dice que sigues vulnerable

Este es el punto que más daño va a hacer si no lo tienes en la cabeza. Node.js hizo el cherry-pick del arreglo upstream sin subir la cadena de versión de Brotli que lleva dentro. La consecuencia práctica es brutal en su simplicidad: process.versions.brotli sigue diciendo 1.2.0 tanto en una instalación parcheada como en una vulnerable. Si tu inventario se basa en ese campo, estás leyendo un número que no significa nada.

Hay una comprobación que sí funciona. Si el decodificador parcheado expone la nueva constante de error, tu instalación está bien:

node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH"

Un binario parcheado imprime -17. Si tu proceso no lo reconoce, o se lanza una excepción, ese runtime no lleva el arreglo.

Y hay una segunda trampa, distinta y más silenciosa. Las instalaciones como paquete de distribución, ya sea Debian, Ubuntu o Homebrew, enlazan contra una biblioteca compartida de Brotli, y ahí el arreglo llega con el paquete brotli de tu distribución y no con la versión de Node.js. Un ldd sobre el binario te dice cuál de los dos mecanismos te aplica.

Para inventario rápido, tres órdenes que resuelven el caso más común:

docker run --rm node:22 node --version

docker run --rm node:24 node --version

docker run --rm node:26 node --version

Si tu plataforma corre contenedores, recuerda que una imagen construida antes del 21 de octubre sigue siendo vulnerable aunque la etiqueta base exista. Las imágenes oficiales de Docker Hub para node:22, node:24 y node:26 se publicaron ese día, pero una imagen fijada a una etiqueta anterior, del tipo node:22.23.3-alpine o node:24.22.0, no se actualiza sola. Hay que volver a construirla. Lo mismo ocurre con las aplicaciones ejecutables únicas y con las instantáneas de arranque, que llevan su propio runtime dentro.

Y hay una tercera cosa que no es opcional: reiniciar. Un proceso de Node.js en marcha mantiene el decodificador viejo en memoria hasta que termina. Parchear el disco no protege al proceso que sigue vivo.

Lo que no tiene arreglo y lo que llega con retraso

Las ramas 25.x, 23.x, 20.x y anteriores no reciben corrección. Node.js 20, Iron, llegó al final de vida el 30 de abril de 2026, y Node.js 25 el 1 de junio. Y la postura del proyecto es explícita: las versiones fuera de soporte siempre están afectadas cuando hay un lanzamiento de seguridad. Si tienes todavía un 18 o un 20 en producción, el problema no es este CVE, es que llevas dos años de vulnerabilidades acumuladas.

Hay un segundo elemento en el mismo lanzamiento que merece atención aparte. El 20 de octubre se publicó que ocho claves de una autoridad de certificación intermedia estaban comprometidas, porque se generaron de forma incorrecta y eran recuperables a partir de los propios certificados que emitían. Node.js incorporates un bloqueo compilado de esas claves, eliminó tres raíz de emSign y actualizó el almacén de raíces a NSS 3.131.1. Pero Node.js no hace comprobaciones de revocación por defecto, de modo que ese bloqueo compilado es el control efectivo. Las sesiones TLS reanudadas antes de la actualización no se vuelven a comprobar hasta que expiran, y las ramas fuera de soporte no reciben el bloqueo en absoluto.

El mismo defecto de Brotli en upstream alcanza además a otros consumidores, y ninguno de ellos se salva. Bun lo corrigió en la versión 1.4.4, la gema brotli de Ruby en la 0.9.1, Envoy en 1.36.12, 1.37.8, 1.38.6, 1.39.3 y 1.40.0, Istio en 1.29.9, 1.30.6 y 1.31.2, PAN-OS de Palo Alto en la 11.2.4 con parches de inspección de Brotli desplegándose entre el 27 y el 30 de octubre, y en Python las versiones Brotli 1.2.1 y brotlicffi 1.2.1.0 publicadas el 23 de octubre.

Detectar lo que ya está pasando

Hay reglas de detección publicadas con identificadores concretos, útiles porque el fallo no siempre se manifiesta como un fallo visible. Hay reglas Sigma con identificador brotli-block-switch-probe y node-zbuf-eof, tres reglas Suricata 2026114971 a 2026114973, una de ModSecurity y una de Falco.

En los logs, lo que hay que buscar son señales de decodificador tocado. La cadena de error nueva, ERR__ERROR_FORMAT_BLOCK_SWITCH con el código -17, es la firma de un sistema parcheado que ha recibido un stream malicioso. En un sistema sin parchar, lo que verás es un Z_BUF_ERROR con "unexpected end of file", una cadena "Error: Decompression failed", o el mensaje de urllib3 que dice que recibió una respuesta con content-encoding br pero no pudo decodificarla.

En la red, el patrón es distinto. Las peticiones entrantes con Content-Encoding: br son sospechosas, porque ni los navegadores ni los clientes de API habituales la envían. Y sobre todo las respuestas salientes comprimidas con Brotli hacia destinos que tú no controlas: webhooks, previsualizaciones de enlaces y herramientas de agentes de IA.

Si necesitas mitigación mientras tanto, y no es consejo del proveedor, Express 5 permite desactivar la inflada con inflate: false, axios acepta decomprimir en false, y un proxy puede rechazar cualquier respuesta con Content-Encoding: br.

El plan, en orden

Inventaría primero. No confíes en process.versions.brotli. Usa la constante -17, mira si enlazas una biblioteca compartida y comprueba la versión real de cada runtime y de cada contenedor. Reparte el inventario entre procesos vivos, imágenes construidas y máquinas virtuales: cada una tiene su ritmo de parcheo.

Parchea y reinicia. Y reconstruye las imágenes, no basta con volver a etiquetar.

Cierra las ramas sin soporte. Es la decisión más cara de la lista.

Cazo en tus logs con las firmas de arriba, y busca tráfico Brotli entrante y saliente durante las últimas dos semanas. Si el 25 de octubre alguien escaneó tus servidores para mapear quién descomprime Brotli, eso está en tus registros.

Y si gestionas un firewall de aplicación, Vercel e Imperva publicaron reglas WAF el 21 y el 22 de octubre respectivamente. No son una solución, pero cubren la ventana.

El punto

Hay dos lecturas posibles de este lanzamiento y las dos importan. La primera es que Node.js publicó diez correcciones de seguridad el 21 de octubre, cuatro de ellas altas, y que el 27 de octubre CISA añadió una a su catálogo con fecha límite el 10 de noviembre. Eso es una operación de mantenimiento con una entrada de calendario.

La segunda es que un fallo con una primitiva de escritura arbitraria a dos gigabytes de distancia, corregido upstream el 27 de febrero y empaquetado el 21 de octubre, se explotó en producción antes de que la mitad de la prensa se enterara de que existía. Y que la nota de lanzamiento, la fuente de verdad para la mayoría de los equipos, lo etiquetaba como HIGH, mientras la CNA le ponía un 9.2 crítico.

La octava parte de tu estrategia de parcheo depende de que sepas cuál de las dos notas del proveedor estás leyendo. En este caso eran las dos, y solo una tenía el número correcto.