Saltar al contenido principal

Sobre esa vulnerabilidad de inundación de hash en Node.js…

· 7 min de lectura
Yang Guo ([@hashseed](https://twitter.com/hashseed))

A principios de julio de este año, Node.js lanzó una actualización de seguridad para todas las ramas actualmente mantenidas para abordar una vulnerabilidad de inundación de hash. Esta solución intermedia tiene el costo de una regresión significativa en el rendimiento al inicio. Mientras tanto, V8 ha implementado una solución que evita la penalización de rendimiento.

En esta publicación, queremos dar algunos antecedentes e historia sobre la vulnerabilidad y la solución final.

Ataque de inundación de hash

Las tablas hash son una de las estructuras de datos más importantes en la informática. Son ampliamente utilizadas en V8, por ejemplo, para almacenar las propiedades de un objeto. En promedio, insertar una nueva entrada es muy eficiente en 𝒪(1). Sin embargo, las colisiones de hash podrían llevar a un caso peor de 𝒪(n). Eso significa que insertar n entradas puede tomar hasta 𝒪(n²).

En Node.js, los encabezados HTTP se representan como objetos JavaScript. Los pares de nombres y valores de los encabezados se almacenan como propiedades del objeto. Con solicitudes HTTP cuidadosamente preparadas, un atacante podría realizar un ataque de denegación de servicio. Un proceso de Node.js se volvería no respondiera, estando ocupado con inserciones de tabla hash en el peor de los casos.

Este ataque se dio a conocer desde diciembre de 2011, y se demostró que afecta a una amplia gama de lenguajes de programación. ¿Por qué tomó tanto tiempo para que V8 y Node.js abordaran finalmente este problema?

De hecho, muy pronto después de la divulgación, los ingenieros de V8 trabajaron con la comunidad de Node.js en una mitigación. Desde Node.js v0.11.8 en adelante, este problema había sido abordado. La solución introdujo un llamado valor de semilla de hash. La semilla de hash se elige aleatoriamente en el inicio y se utiliza para sembrar cada valor de hash en una instancia particular de V8. Sin el conocimiento de la semilla de hash, a un atacante le resulta difícil alcanzar el peor de los casos, y mucho menos idear un ataque que apunte a todas las instancias de Node.js.

Este es parte del mensaje del commit de la solución:

Esta versión solo resuelve el problema para aquellos que compilan V8 por sí mismos o para aquellos que no usan instantáneas. Una V8 precompilada basada en instantáneas todavía tendrá códigos de hash de cadena predecibles.

Esta versión solo resuelve el problema para aquellos que compilan V8 por sí mismos o para aquellos que no usan instantáneas. Una V8 precompilada basada en instantáneas todavía tendrá códigos de hash de cadena predecibles.

Instantánea de inicio

Las instantáneas de inicio son un mecanismo en V8 para acelerar dramáticamente tanto el inicio del motor como la creación de nuevos contextos (es decir, a través del módulo vm en Node.js). En lugar de configurar objetos iniciales y estructuras de datos internas desde cero, V8 deserializa desde una instantánea existente. Una compilación actualizada de V8 con instantánea se inicia en menos de 3ms y requiere una fracción de milisegundo para crear un nuevo contexto. Sin la instantánea, el inicio toma más de 200ms, y un nuevo contexto más de 10ms. Esta es una diferencia de dos órdenes de magnitud.

Cubrimos cómo cualquier integrador de V8 puede aprovechar las instantáneas de inicio en una publicación anterior.

Una instantánea preconstruida contiene tablas hash y otras estructuras de datos basadas en valores hash. Una vez inicializada desde la instantánea, la semilla de hash ya no se puede cambiar sin corromper estas estructuras de datos. Una versión de Node.js que agrupa la instantánea tiene una semilla de hash fija, lo que hace que la mitigación sea ineficaz.

De eso se trataba la advertencia explícita en el mensaje del commit.

Casi solucionado, pero no del todo

Avanzando hasta 2015, un problema en Node.js informa que la creación de un nuevo contexto ha tenido un retroceso en el rendimiento. No es sorprendente, ya que la instantánea de inicio ha sido deshabilitada como parte de la mitigación. Pero para ese momento no todos los participantes en la discusión eran conscientes de la razón.

Como se explica en este post, V8 utiliza un generador de números pseudoaleatorios para generar resultados de Math.random. Cada contexto de V8 tiene su propia copia del estado del generador de números aleatorios. Esto es para evitar que los resultados de Math.random sean predecibles entre contextos.

El estado del generador de números aleatorios se inicializa a partir de una fuente externa justo después de que se crea el contexto. No importa si el contexto se crea desde cero o se deserializa desde un snapshot.

De alguna manera, el estado del generador de números aleatorios se ha confundido con la semilla del hash. Como resultado, un snapshot preconstruido comenzó a formar parte de la versión oficial desde io.js v2.0.2.

Segundo intento

No fue hasta mayo de 2017, durante algunas discusiones internas entre V8, el Project Zero de Google y la plataforma en la nube de Google, cuando nos dimos cuenta de que Node.js aún era vulnerable a ataques de inundación de hash.

La respuesta inicial vino de nuestros colegas Ali y Myles del equipo detrás de las ofertas de Node.js de la plataforma de nube de Google. Trabajaron con la comunidad de Node.js para deshabilitar el snapshot de inicio por defecto, nuevamente. Esta vez, también agregaron un caso de prueba.

Pero no queríamos dejarlo ahí. Deshabilitar el snapshot de inicio tiene impactos significativos en el rendimiento. Durante los años, hemos añadido muchas nuevas características del lenguaje webassembly y optimizaciones sofisticadas de expresiones regulares en V8. Algunas de estas mejoras hicieron que iniciar desde cero fuera aún más costoso. Inmediatamente después de la publicación de seguridad, comenzamos a trabajar en una solución a largo plazo. El objetivo es poder volver a habilitar el snapshot de inicio sin ser vulnerables a la inundación de hash.

Entre las soluciones propuestas, elegimos e implementamos la más pragmática. Después de deserializar desde un snapshot, escogeremos una nueva semilla de hash. Luego, las estructuras de datos afectadas serán rehashadas para garantizar la consistencia.

Resulta que, en un snapshot ordinario de inicio, pocas estructuras de datos están realmente afectadas. Y, para nuestra alegría, el rehashing de tablas hash se ha facilitado en V8 en el intermedio. La sobrecarga que esto añade es insignificante.

El parche para volver a habilitar el snapshot de inicio ha sido fusionado en Node.js. Forma parte de la reciente versión Node.js v8.3.0 lanzada.