Medidas de seguridad a fondo: cómo evitar que un error del compilador de Wasm pase a mayores
La seguridad no se debe descuidar. En las aplicaciones actuales de la nube, la seguridad debe tenerse en cuenta e integrarse en todos los aspectos de cualquier producto o entorno, igual que los procesos de evaluación de dicha seguridad, como pueden ser la ejecución de pruebas continuas, la realización de pruebas de vulnerabilidad ante datos aleatorios o inesperados (fuzzing), y el análisis de seguridad de funciones y características nuevas. Todo ello forma parte del enfoque de Fastly respecto a la seguridad y, de hecho, lo hemos tenido que poner a prueba recientemente.
Hace poco descubrimos un error de compilador en Cranelift, parte del compilador de WebAssembly que utilizamos para Compute@Edge, que habría permitido a cualquier módulo de WebAssembly acceder a memoria ajena a su montón, aislado en un entorno seguro. Con la ayuda de nuestro equipo y de los eficientes procesos y herramientas a su disposición, detectamos el error y lo resolvimos en nuestra infraestructura antes de que alguien lo aprovechara. En esta entrada del blog, te contamos cómo dimos con el error, qué lo causó y por qué podría haber sido algo grave, así como la manera en que comprobamos que nadie había aprovechado esta vulnerabilidad.
Aparte de nuestro afán de transparencia, lo que nos anima a escribir estas líneas son las ganas de demostrar que un enfoque integrado de seguridad abarca tanto herramientas como procesos. En Compute@Edge hemos establecido firmes límites de seguridad: además de los entornos aislados seguros de WebAssembly, confiamos en mecanismos de seguridad implementados a nivel del sistema operativo. Sin embargo, como el software puede contener errores, parte de nuestro enfoque de seguridad se centra en cómo actuamos cuando surge algún problema. Entremos en materia.
Contexto: Cranelift y aislamiento de montones en entornos seguros
En Compute@Edge, con cada petición entrante, ejecutamos en el servidor código del cliente contenido en módulos de WebAssembly (Wasm). Lo que diferencia a nuestro diseño es que cada una de las peticiones del cliente se ejecuta en una instancia nueva del módulo: no se comparte memoria con otros controladores de peticiones ni con código de otros clientes.
Este confinamiento de memoria, o aislamiento de montones en entornos seguros, es una característica esencial de WebAssembly que le dota de la mayoría de sus potentes propiedades de seguridad. Por tanto, si desaparecieran los límites que separan los montones, podría originarse un problema grave de seguridad. Esto explica por qué nos tomamos la exactitud de los compiladores tan en serio y por qué disponemos de varias capas de procesos y de medidas de protección para garantizar que esta amenaza no afecte a los clientes.
Compilamos los módulos Wasm de los clientes de forma que se ejecuten en nuestros servidores con Cranelift, que es un compilador ahead-of-time (AOT) de códigos de bytes de Wasm a código máquina x86. Así, el código puede ejecutarse en cuanto se recibe la petición, lo cual recorta enormemente los tiempos de arranque en frío, una de las ventajas más relevantes de Compute@Edge. Además, Cranelift traduce los accesos a montones a código nativo x86. Cada una de las instancias de WebAssembly (recordemos, una por petición) tiene asignada su propia región del espacio de memoria virtual y durante su ejecución lleva aparejado un puntero a esta región. Cuando el código de bytes de Wasm accede al montón, Cranelift lo traduce a un acceso con un desplazamiento desde la base del montón. Dado que la anchura de los punteros de Wasm es de 32 bits, este desplazamiento no puede ser superior a 4 GiB (4 gigabytes binarios, o 232 bytes). Al ajustar las regiones de memoria virtual a un tamaño superior a este, nos aseguramos de que ninguna instancia de Wasm alcance la memoria de otra instancia sin necesidad de realizar verificaciones de límites de tiempos de ejecución, uno de los aspectos en los que Compute@Edge ofrece buenas prestaciones. Entre las regiones intercalamos páginas de protección, direcciones virtuales sin asignar que terminan la instancia de Wasm si se accede a ellas.
Foco: el error
Conviene subrayar una interesante propiedad de este diseño: presuponemos —como también lo suele presuponer el código— que el compilador traduce el código con fidelidad. Nuestro entorno aislado de Wasm (que forma parte de Lucet) genera una instrucción de suma dentro de la representación interna de Cranelift para sumar el puntero base y la dirección del montón de Wasm. ¿Qué ocurriría si esta suma de enteros diera un resultado erróneo?
Fue justo lo que nos sucedió. Para comprender el problema, debemos:
tener nociones sobre cómo gestionan los compiladores valores de anchura diferente (p. ej., 32 y 64 bits);
saber cómo escogen los compiladores las instrucciones de máquina para realizar operaciones aritméticas;
y saber cómo estos escogen registros en los que colocar valores (asignación de registros).
Analicemos cada uno de estos aspectos.
Aunque en x86-64 todos los registros de números enteros tienen una anchura de 64 bits, algunos valores son más estrechos, como los punteros Wasm, que tienen 32 bits. La mayoría de compiladores, incluido Cranelift, almacenan el valor estrecho en los bits bajos del registro y dejan sin definir los bits más altos. Al generarse, el código que calcula la dirección del montón de Wasm debe incluir un operador zero-extend que convierta el puntero Wasm de 32 bits en un sumando de 64 bits, el cual a su vez se suma a la dirección base.
Dado que estas operaciones son habituales —y sería muy caro ejecutarlas de manera explícita allí donde se generen—, el selector de instrucciones de Cranelift saca partido de otra peculiaridad de x86-64: hay ocasiones en las que instrucciones en 32 bits generan en realidad valores de 64 bits y borran los bits más altos. Esto quiere decir que el operador extend no aporta nada y se puede suprimir.
Hasta ahora, todo bien, pero pasemos a especificar el asignador de registros. Se trata de la parte del backend del compilador que escoge dónde almacenar valores. A veces, si el programa tiene demasiadas variables activas al mismo tiempo, debe perder algunos datos enviándolos al stack del procesador, para volver a cargarlos cuando sea necesario. Esta tarea es normal, se ejecuta sin que lo note el programa y permite al programador utilizar un número de variables superior al de registros existentes.
A pesar de todo, por fin podemos describir el error: cuando pierde un registro, el asignador de registros ya conoce el tipo que corresponde al valor y, en Cranelift, garantiza que solo se conserven los bits de ese tipo en concreto. Si un valor tiene 32 bits de anchura, pero lo tratamos como si tuviera 64 bits porque hemos suprimido un operador extend de 32-64 bits superfluo y luego el valor se pierde, este podría ser incorrecto al volver a cargarse. En concreto y, por desgracia, en nuestro caso, el asignador de registros utilizó una carga de extensión de signo para volver a cargar un valor de 32 bits. Esto quiere decir que un entero de 32 bits mayor que 0x8000_0000, al que luego se le aplica el operador zero-extend para pasarse a 64 bits en el programa original, podría convertirse por accidente en negativo a causa de una extensión de signo incorrecta.
Los desplazamientos negativos no son deseables en lo que respecta a los desplazamientos de los montones.
Consecuencias
Esto quiere decir que, en condiciones excepcionales, un módulo Wasm de nuestro sistema podría acceder a la memoria antes de que se inicie su montón, aislado en un entorno seguro. Para poder proporcionar tiempos de arranque y de respuesta muy rápidos, en nuestro sistema se gestionan varias peticiones desde un solo proceso del sistema operativo. Por lo tanto, en teoría la capacidad de leer cualquier memoria al azar podría haber producido una filtración de datos de clientes.
El diseño de nuestro sistema acabó mitigando las consecuencias. La memoria de nuestro demonio de Compute@Edge se había dispuesto colocando montones de cada instancia a una distancia entre sí superior a 4 GiB en el espacio de la memoria virtual e intercalando entre éstas regiones de protección (memoria sin asignar). Por ello, en ningún caso hubo posibilidad de que una instancia Wasm accediera al montón de otra instancia (memoria lineal): debido a que tenía un desplazamiento regresivo máximo de 2 GiB, no habría alcanzado la parte superior del montón de la instancia anterior.
Sin embargo, sí que hubo posibilidad de que un módulo Wasm malicioso utilizara una carga o un almacenamiento construido con mucho esmero, para acceder a algunos datos esenciales que preceden al inicio de este montón, incluidos el stack y las variables globales de la instancia anterior. (Consulta la documentación de Lucet para obtener detalles sobre la disposición). Al constatar que esta posibilidad era viable, nos quedó claro que corrimos un grave peligro. En Lucet —que respalda la ejecución de WebAssembly en nuestros servidores—, esos datos esenciales incluyen estructuras y punteros en los que confía el tiempo de ejecución, y la modificación de esas estructuras podría ser el verdadero germen de una vulnerabilidad de seguridad más complicada.
Por casualidad, descubrimos también un error mucho menos interesante (y que no ponía en riesgo la seguridad) que se combinaba con este error de compilador. Esto se traducía en que cualquier intento de aprovechamiento indebido de la vulnerabilidad necesitaría un desplazamiento estático muy grande en una carga o un almacenamiento, lo cual podríamos detectar con facilidad. Podrás obtener más información sobre esto en el apéndice que encontrarás más adelante.
En resumen, aunque en teoría se podría haber generado una vulnerabilidad de seguridad, esta habría exigido una carga o un almacenamiento con un desplazamiento concreto. Si este desplazamiento no fuera el exacto, este ataque probablemente fracasaría al estar apuntando a la región de protección de otra instancia, quedando los datos protegidos. Si por el contrario no hubiera fracasado, habríamos observado multitud de notificaciones de accesos no autorizados en los registros que nosotros mismos supervisamos. Esto sí que habría sido interesante. No obstante, ya que se había registrado un problema, investigamos los posibles riesgos de exposición que habíamos corrido.
Con tal fin, diseñamos un programa que analizaba todos los módulos Wasm cargados en Compute@Edge mientras el error circulaba por nuestros sistemas, para buscar instrucciones de carga y almacenamiento con desplazamientos en el rango vulnerable. Puesto que valoramos en gran medida la privacidad de los clientes, esta tarea específica no se hizo de forma manual, sino que se ejecutó en la misma canalización aislada de compilaciones que se usó para diseñar módulos destinados a Compute@Edge. Este análisis concluyó que ningún módulo Wasm había sufrido desplazamientos que pudieran haber dado lugar a una vulnerabilidad de la seguridad. Por tanto, demostramos que no habría sido posible acceder a datos de clientes a través de ninguno de los módulos Wasm localizados en nuestro sistema.
Lógicamente, resolvimos el error en Cranelift de inmediato y volvimos a desplegar nuestra infraestructura. Con la combinación del análisis retrospectivo y la solución del error, tenemos la certeza de que los datos de los clientes han estado seguros en todo momento.
Cómo detectamos el problema
La historia de cómo surge este error también es muy interesante.
Una mañana recibimos varias entradas de registro que presentaban anomalías. Uno de los ingenieros observó que el demonio de Compute@Edge se había bloqueado varias veces en un POP y que se habían efectuado varios accesos indebidos a direcciones de memoria. Esto encendió todas las alarmas: cualquier acceso a memoria que no tenga explicación es un problema grave en potencia.
No tardamos en determinar que el módulo Wasm que ocasionaba el bloqueo procedía de Javier Cabrera Arteaga, experto en seguridad del KTH Royal Institute of Technology, con el que habíamos acordado el uso de Compute@Edge con fines de investigación en materia de seguridad. Nos pusimos en contacto con Javier para pedirle una copia del módulo Wasm; queríamos, además, comprender qué ajustes podrían ser necesarios para reproducir el comportamiento anómalo. Javier nos indicó con precisión qué objetivos tenían sus experimentos y nos dio acceso al código fuente del módulo.
Recuperada la versión exacta del módulo Wasm que ocasionaba el bloqueo en los sistemas de Fastly, fuimos capaces de reproducir el bloqueo y enseguida dimos con el problema en un depurador. El error del compilador se hizo evidente tras verificar el desensamblado y, una vez que tuvimos conocimiento del problema, supimos revisar Cranelift y verificar que la seguridad de nuestra infraestructura no se hubiera comprometido.
Sin embargo, eso no nos pareció suficiente: teníamos que entender las consecuencias del error para determinar qué exposición habíamos podido sufrir y qué acciones de respuesta se podrían haber activado. Para ello, analizamos las disposiciones de los montones y los esquemas de las verificaciones de límites; cuantificamos con precisión las consecuencias del error según diferentes configuraciones del compilador y del tiempo de ejecución; y determinamos qué ajustes y casos de uso permitían que el error se manifestara en Cranelift y qué implicaciones tenía para nuestra infraestructura. Llegados a este punto, entendimos qué condiciones eran susceptibles de vulnerabilidades e identificamos aquellos módulos Wasm que presentaban desplazamientos estáticos concretos de carga y almacenamiento, como indicamos antes. Asimismo, el compromiso con la comunidad de código abierto —concretamente, con la Bytecode Alliance— nos impulsó a realizar algunas investigaciones complementarias: determinar las consecuencias que el error tuvo en los tiempos de ejecución de código abierto Wasmtime y Lucet; los resultados se publicaron en nuestra página de divulgación de vulnerabilidades.
Como consecuencia de la investigación, desarrollamos una vulnerabilidad funcional para nuestro demonio de Compute@Edge. Esta experiencia nos dio que pensar, pero al mismo tiempo reforzó nuestros planteamientos: nos permitió entender con exactitud qué se necesitaba para aprovechar la vulnerabilidad de manera proactiva. Descubrimos que las ubicaciones de carga y almacenamiento de montones de Wasm tenían que ser las adecuadas y precisas o, de lo contrario, todo el demonio se bloquearía; además, comprobamos que el aprovechamiento de la vulnerabilidad solo se podía completar con conocimientos internos. La ausencia de bloqueos de ese estilo en otras partes del sistema que mostraban nuestros registros en tiempo real y el análisis que hicimos de nuestros módulos Wasm nos llevaron a concluir que la vulnerabilidad no se había utilizado en nuestro entorno.
Medidas de seguridad a fondo: procesos seguros, supervisión activa y solución proactiva
Algunas de las lecciones aprendidas más significativas guardan relación con los procesos: nuestros procesos funcionaron y detectamos el error tras observar anomalías en los registros; y fuimos capaces de reunir a todas las partes pertinentes —ingenieros de productos, técnicos de seguridad, personal de comunicaciones y miembros del equipo jurídico— y de coordinarlas con eficacia para solventar de inmediato la vulnerabilidad.
Por otro lado, la vulnerabilidad de seguridad ha aumentado nuestra experiencia: desde que presentamos Compute@Edge, esta ha sido la primera que registra Cranelift, lo cual nos ha ayudado a hacer varios cambios en procesos internos para prepararnos mejor de cara al futuro. Además, ha sido la primera ocasión en que nos coordinamos con la Bytecode Alliance para localizar a los usuarios del software afectados y para publicar una advertencia de seguridad. No tenemos la menor duda de que la cooperación con la Bytecode Alliance para garantizar la seguridad del software cobra más sentido que nunca. Asimismo, seguimos utilizando un conjunto variado de técnicas que nos permita detectar y solucionar errores con proactividad antes de que afecten a nuestros clientes.
Aunque los errores de seguridad siempre suponen un contratiempo, forman parte del mundo del software contemporáneo y lo importante es saber cómo actuar frente a ellos. Esto tiene mayor importancia si cabe a medida que el número de clientes que confía en Compute@Edge como plataforma segura y versátil no deja de crecer. Para asegurarnos de que esta tendencia se mantenga, seguiremos trabajando con entusiasmo en tareas de ingeniería de seguridad y aplicando todos los enfoques que hemos descrito aquí.
Apéndice: mecanismo de funcionamiento del error en detalle
Como colofón a esta entrada, nos gustaría hacer una descripción pormenorizada del funcionamiento de este error y mostrar el aspecto que adoptan las compilaciones erróneas del código de circulación libre que ven los ingenieros de sistemas y de compiladores.
Toda reproducción del error reducida a su casi mínima expresión genera un desensamblado como el siguiente:
; function prologue, storing a few register-based arguments
push rbp
mov rbp,rsp
sub rsp,0xe0
mov QWORD PTR [rsp],r12
mov QWORD PTR [rsp+0x8],r13
mov QWORD PTR [rsp+0x10],r14
mov QWORD PTR [rsp+0x18],rbx
mov QWORD PTR [rsp+0x20],r15
mov r12,rdi ; bug-relevant details begin here!
; rdi is the first argument, the WebAssembly "VMContext".
; Lucet sets VMContext to the heap base, with critical structures
; placed in the (4k) page before the heap.
mov r11,rsi ; rsi is the second argument, the first one from user-controlled
; WebAssembly code. call it "heap_offset".
mov rsi,rcx ; rcx is the third argument, a user-controlled i64 - call it "user_qword".
mov QWORD PTR [rsp+0x40],rsi ; spill "user_qword", just a quirk of this PoC .
...
mov QWORD PTR [rsp+0x30],r11 ; spill "heap_offset", again just a quirk.
movsxd rsi,DWORD PTR [rsp+0x30] ; reload "heap_offset".
add esi,edx ; this add helps convince Cranelift to spill in a way it later incorrectly sign extends.
; edx is also an argument, which is set to 0 in our PoC - this add does not change "heap_offset".
mov QWORD PTR [rsp+0x30],rsi ; the spill! we'll revisit this in a moment.
...
movsxd r11,DWORD PTR [rsp+0x30] ; the incorrect sign-extended load of "heap_offset"!
mov rdi,QWORD PTR [rsp+0x40] ; reload "user_qword"
mov QWORD PTR [r12+r11*1+0x0],rdi ; store "user_qword" to "VMContext" + "heap_offset".
; since "heap_offset" was sign-extended r11 might be a number like -4096,
; this store might write "user_qword" over critical structures Lucet relies on.
En esta reproducción, las implicaciones de seguridad son evidentes: si hay estructuras esenciales justo antes de la base del montón, cualquier desplazamiento negativo facilita muchísimo el acceso a estas, y la dificultad pasa por convencer al compilador de que emita este patrón de código con errores. Para simplificar, no vamos a incluir las docenas de variables locales que se almacenan, se suman, se multiplican y se combinan para ejercer la suficiente presión de modo que el compilador pierda el desplazamiento del montón de WebAssembly.
También mencionamos antes que un segundo error venía a complicar posibles intentos de aprovechamiento de la vulnerabilidad, pero que revelaba sin duda si alguien lo había intentado. El analizador sintáctico de configuraciones que empleamos para analizar los ajustes de los montones interpretó un parámetro del tipo «4 GB» como «4 000 000 000 bytes»; es decir, «gigabytes» decimales, en lugar de «gibibytes» binarios. Al haberse configurado el tamaño máximo de montón por debajo de 4 GiB («4 294 967 296»), a los módulos de WebAssembly compilados les quedaba por hacer una verificación de límites respecto a esos últimos 294 967 296 bytes de espacio de montón. Esto se tradujo en que durante la investigación del desensamblado encontramos determinadas instrucciones que no habíamos previsto:
mov edi, 0xee6b27fe ; an entirely unexpected constant: 3,999,999,998
movsxd rax, DWORD PTR [rsp+0x88] ; the incorrect sign-extended load
cmp eax, edi ; compare against the heap bound
jae ff0 <guest_func_4+0x360> ; and branch to a trap site if out of bounds
Tuvimos suerte, porque lo primero que se le hubiera ocurrido a cualquier atacante habría sido utilizar un desplazamiento del montón como 0xfffff000 para retroceder solo un poco y modificar las estructuras esenciales de las que depende Lucet. En ese caso, la verificación de límites fallaría, el programa quedaría interceptado y se notificaría un acceso no autorizado al montón junto con un desplazamiento preocupantemente alto. Dado que el puntero de montón regresivo máximo (más cercano a cero) es 0xee6b27fd, esto vendría a sugerir que los 294 967 297 bytes que preceden inmediatamente a cualquier instancia de WebAssembly afectadas por este error no habrían sido manipulados. Por desgracia, nos dimos cuenta rápidamente de que la historia no era exactamente así.
Las instrucciones [object Object] (carga) y [object Object] (almacenamiento) de WebAssembly incluyen un valor offset
(desplazamiento) inmediato que pretende simplificar el funcionamiento combinado de cargas y almacenamientos con estructuras. La disposición de una estructura en la memoria suele ser la misma para un programa entero. Por ejemplo, el campo st_size
del struct size
siempre está situado en el mismo desplazamiento, sin importar la ubicación del propio «struct size». Cualquier compilador podrá entonces escribir el desplazamiento como valor inmediato, y las operaciones que se realicen de manera recurrente en una estructura pueden simplemente volver a utilizar el puntero de esta. Sin embargo, el desplazamiento se define con WebAssembly; esto quiere decir que cualquier atacante podría evitar la verificación de límites escogiendo un desplazamiento del montón que esté dentro de unos valores que sean bajos y seguros, añadir un desplazamiento grande en una instrucción «load» o «store», y acceder a direcciones de memoria superiores de la zona de direcciones que hay justo por debajo del montón de una instancia.
En ese momento, podríamos generar una prueba de concepto con la que manipular la memoria de una instancia empleando métodos que sabemos que infringen las propiedades de seguridad de los entornos aislados y seguros de Lucet: podríamos leer contenido de la instancia antes de que lo hiciera una instancia maliciosa, o bien sobrescribir punteros y ponernos a los mandos del flujo de control. En resumen, esto sirve para recordarnos que tenemos el deber de tomarnos en serio los problemas de seguridad, incluso cuando en el momento no se nos ocurra que existen resquicios de esos problemas que los atacantes pueden aprovechar.