Seguridad#
Una de las principales diferencias entre sistemas como VMS y los modernos, como Linux, es el enfoque en seguridad. Aquí hay algunas medidas que se fueron tomando con el tiempo.
Buffer Overflow - Bit NX#
Una de las primeras protecciones fue contra los buffer overflows.
Estos consistían en meter código malicioso (shellcode) en un array en el stack y luego sobrescribir la dirección de retorno de la función para que apuntara a ese mismo array. La CPU saltaba al stack, ejecutaba el shellcode y el sistema estaba comprometido.
Por ejemplo, un código vulnerable sería:
| |
Si el input del usuario supera los 100 caracteres que caben en el buffer, se escribiría en las direcciones siguientes, posiblemente sobrescribiendo variables y, con un shellcode bien hecho, consiguiendo ejecución de instrucciones.
Para parar esto, los desarrolladores del hardware crearon el NX Bit (No-eXecute), que hacía que la CPU no ejecutase el código que estuviese en una página con dicho bit puesto. Si ese bit se ponía en todas las páginas del stack, los buffer overflows estaban solucionados.
Return-Oriented Programming#
Como los atacantes se quedaron sin poder meter código nuevo en el sistema, empezaron a intentar usar el código que ya estaba en el sistema.
El ROP (Return-Oriented Programming) es el acto de piratear el flujo de un programa usando trozos pequeños de código legítimo que ya existe en la memoria de dicho programa.
Cualquier programa en Linux carga en memoria librerías enormes como glibc. Esa librería está llena de código legítimo y, sobre todo, ejecutable (porque tiene funciones reales, como printf(), malloc(), etc.).
En un programa compilado, que son millones de instrucciones en assembly, es inevitable que por casualidad existan secuencias de instrucciones como:
| |
Este fragmento de código se conoce como Gadget. Estos son secuencias cortas de instrucciones encargadas de hacer algo específico, pero con la idea de que estas instrucciones ya están en memoria (en el código de alguna librería o del programa en sí), no las ha cargado el atacante.
Este gadget específico simplemente mete un dato de la pila en RDI, y salta a otra dirección. No hace nada malo por sí solo, la idea es encadenarlo.
ROP Attack Chain#
Un ejemplo podría ser el siguiente:
- Queremos ejecutar
system("/bin/sh"); - Esta llamada necesita que
RDIcontenga la dirección del string"/bin/sh".
Para esto podemos aprovechar que hay funciones de la librería glibc que contienen el string "/bin/sh" porque lo usan a veces.
De momento supongamos que hemos hecho un buffer overflow simple: Hemos llenado de basura la memoria de la función vulnerable hasta llegar a su dirección de retorno original, y la hemos sobrescrito con nuestro valor arbitrario.
El valor arbitrario que hemos metido en la @ de retorno de la función es la dirección del gadget anterior:
| |
Inmediatamente después de ese valor, hemos metido (en este orden) la dirección de "/bin/sh", y la dirección de la función system(). Esto no estaba en el programa, lo hemos metido nosotros en el stack.
El stack manipulado completo sería algo así:
| |
Entonces, el flujo queda así:
- Termina la función vulnerable: Se llega al
retde la función vulnerable, y como hemos sobrescrito la pila, RSP apunta al valor que hemos metido. La CPU saca el valor de RSP y la CPU salta al gadget. - La CPU ha saltado al gadget (que puede estar en una librería o cualquier otro sitio). Ahí hace lo siguiente:
- Saca del stack (
pop rdi) el valor que habíamos metido justo después de la dirección al gadget, y lo mete enrdi. Este valor es la dir. de"/bin/sh". - Salta a la dirección que habíamos metido 2 huecos después de la dirección al gadget. Esta dirección es la función
system().
- Saca del stack (
Como system() toma como parámetro la dirección del string con el nombre del programa a ejecutar en el registro RDI, y hemos puesto la dirección de "/bin/sh", efectivamente estaremos ejecutando system("/bin/sh").
Solución: ASLR#
Para que una cadena ROP funcione, el atacante necesita saber exactamente la dirección de memoria de cada gadget. Si se equivoca en un solo número, el ataque fallará y posiblemente el programa crashee.
Aquí es donde entra ASLR (Address Space Layout Randomization). Cada vez que se ejecuta el programa, el SO desordena el mapa de la memoria. En una ejecución, glibc puede estar ubicada en 0x7fff1234, mientras que en otra puede estar en 0x7ffe819a.
Esta aleatorización de las direcciones se hace tanto para librerías como para código, stack y heap del programa.
Esto puede verse en sistemas modernos al ejecutar varias veces el mismo programa, como este:
| |
Al ejecutarlo:
| |
De hecho, el mecanismo de ASLR no es solo útil para la zona de usuario, sino que también se ha implementado en la zona del kernel, con el nombre de KASLR (o Kernel ASLR).
Ejecución Especulativa#
Los procesadores actuales son tan rápidos que a menudo se ven en un cuello de botella esperando a que les lleguen los datos. Para poder acelerar todo un poco, suelen tirar de ejecución especulativa.
Esto consiste en ir haciendo algo aunque nadie se lo haya pedido todavía, con la esperanza de que, la mayoría de veces, luego el programa efectivamente pida a la CPU hacer eso que esta ya había hecho.
Algunos ejemplos son estos:
- Fetch/Decode: Va trayendo instrucciones muy por delante del punto de ejecución lógico (hasta cientos de instrucciones por delante)
- Ejecución fuera de orden: Si una instrucción está esperando un dato, la CPU busca instrucciones posteriores que no dependan de ese dato y las ejecuta ya.
- Predictor de saltos: Cuando llega a un
ifo salto condicional, la CPU no sabe qué rama se va a tomar, así que predice la más probable usando un historial de ejecuciones anteriores y empieza a ejecutar sus instrucciones. - Buffer de reordenamiento (ROB): Los resultados de las instrucciones especulativas no se hacen visibles al estado arquitectónico de la máquina hasta que se confirma que dicha especulación era correcta. Si la predicción falla, se descartan y la CPU vuelve hacia atrás. Para llevar esto a cabo se mantiene una estructura denominada Reorder Buffer (ROB) que mantiene las instrucciones con el mismo orden con el que llegan al pipeline de la CPU.
Y el punto importante es el último: si la especulación falla, se revierten los cambios y, por tanto, no debería haber ningún efecto observable.
Bien, pues esa promesa resultó ser falsa, porque aunque los registros volvían a su estado anterior, la ejecución especulativa dejaba huellas en estructuras microarquitectónicas: Caché L1/L2 y TLB, entre otros.
Dos de las vulnerabilidades principales que derivaron de esto (en 2018) fueron Meltdown y Spectre.

Ataques de canal lateral#
Los ataques de canal lateral son un tipo de ataques que aprovechan información adicional que el sistema emite de forma indirecta durante su funcionamiento para poder inferir datos del mismo. En este caso, esa “información adicional” será el tiempo de acceso a un dato.
FLUSH+RELOAD#
FLUSH+RELOAD es una técnica de canal lateral basada en la memoria caché que permitía saber si una dirección concreta de memoria había sido accedida recientemente. Los pasos son estos:
- FLUSH: El atacante expulsa una línea de caché mediante
clflush.
| |
Nota: clflush
clflushclflush (Cache Line Flush) es una instrucción de la arquitectura x86 que expulsa de la memoria caché la línea de memoria que contiene una dirección determinada.
P.ej, esta línea de abajo hace que la línea de caché donde está array[i] deje de estar en caché (L1,L2,L3). Si la CPU más tarde intenta acceder a esa dirección tendrá que traer el dato de un nivel de memoria más lento.
| |
- ESPERAR: Se espera a que la víctima ejecute su código.
| |
- RELOAD: El atacante vuelve a leer el dato y mide cuánto tarda en hacerse el acceso.
| |
Si el acceso es rápido, el dato estaba en caché y por tanto sabe que la víctima ha accedido al dato. Si es lento, no estaba en caché y la víctima no había accedido.
FLUSH+RELOAD de este modo solo da la infomación de “Se ha accedido a esta dirección o no?”, pero no obtiene el contenido de dicha dirección.
Lectura de un byte#
Tanto Meltdown como Spectre usaban la misma técnica para “leer” el resultado de la especulación, porque el resultado real en sí no se podía leer directamente (la gracia era leer algo que no tenías permiso para leer). La técnica estaba basada en Flush+Reload, el atacante hacía esto:
- Preparaba un array de 256 entradas (uno por cada valor posible de un byte) separadas por saltos de páginas (p.ej, cada entrada a una distancia de 4kB) para que cada una cayese en una entrada de caché distinta.
- Usaba la instrucción
clflushpara sacar de caché las 256 entradas del array. - Luego hacía que la CPU accediese de forma especulativa a
array[valor], convalorel byte que se quería robar. Esto cargaba esa única línea de caché correspondiente al valorsecreto. - Aunque luego la instrucción especulativa se descartaba, la línea de caché quedaba cargada porque el hardware no la revertía.
- Finalmente, el atacante recorría las 256 posiciones del array midiendo el tiempo que tardaban los accesos a cada una. La que tardase mucho menos, porque estaba en caché, era a la que se había accedido, y por tanto su índice (0-255) el valor del byte.
Meltdown, KPTI#
Meltdown explotaba un “fallo” en las CPUs de Intel y algunas ARM, pero no afectaba a las de AMD. Este fallo consistía en que la comprobación de permisos de la MMU se hacía en paralelo con la ejecución especulativa, y no antes.
| |
Aunque el proceso de usuario no tenga permiso para leer kernel_address, la CPU la ejecuta especulativamente la primera instrucción antes de que la MMU termine de comprobar si el proceso tiene o no permiso para acceder. Luego (todavía ejecutando instrucciones de forma especulativa) usa ese valor para calcular una dirección y acceder, metiéndola en caché.
Al terminar de comprobar permisos, se detecta la violación y se descartan todos los resultados, menos esa caché resultado de la especulación.
Al final, el proceso (de algún modo, p.ej capturando la excepción) aplica FLUSH+RELOAD sobre el array para ver qué línea había sido cacheada, así recuperará el byte del kernel.
Repitiendo esto byte a byte, un proceso no privilegiado puede conseguir acceder a toda la memoria del kernel mapeada en su espacio de direcciones, que, antes de parchear estos ataques y como habíamos visto antes, es prácticamente toda la RAM física entera.
SOLUCIÓN: Kernel Page Table Isolation#
La solución a Meltdown fue clara: Si el kernel simplemente no está mapeado en el espacio de direcciones de un proceso, la CPU no puede especular sobre sus datos, porque ni siquiera puede acceder a esa memoria. Esta solución fue Kernel Page Table Isolation (KPTI).
Ahora, cada proceso ya no tiene una única tabla de paginación, tiene dos:
- Tabla del kernel (Kernel PGD): Exactamente igual a la original. Tiene todo el espacio de usuario y todo el espacio del kernel mapeados, y se usa únicamente cuando la CPU ejecuta código del kernel.
- Tabla del usuario (User PGD): Solamente el espacio de usuario está mapeado, pero el espacio del kernel está casi vacío.
Antes habíamos visto que tener mapeado el kernel era una ventaja en cuanto a rendimiento, porque no había que flushear el TLB. Cuando ejecutabas una syscall, como fork(), la CPU cambiaba a modo kernel y el SO tomaba el control y saltaba a su zona kernel mapeada directamente, ejecutaba la syscall y volvía.
Ahora ni esa syscall ni ninguna otra están mapeadas directamente, pero la CPU sigue necesitando poder acceder al código del kernel para ejecutar la función, por ello se dejó una pequeña parte del kernel mapeada en la tabla del usuario, conocida como Trampolín.
El trampolín#
Ese trampolín es un trozo de código del kernel muy pequeño que contiene lo estrictamente necesario para gestionar interrupts y la entrada y salida de las syscalls, pero no tiene ni datos sensibles, ni caché del disco (Page Cache).
Funciona así:
- El proceso hace una syscall
- La CPU cambia a modo kernel y salta a la dirección del trampolín
- El código del trampolín cambia el registro
CR3(el que indicaba la dirección base de la tabla de paginación) de la CPU para que ahora apunte a la tabla del kernel. - Ahora todo el espacio del kernel está mapeado en el espacio de direccionamiento. El trampolín salta a la syscall.
- Se ejecuta la syscall y el kernel vuelve al trampolín, que de nuevo cambia el registro
CR3para que ahora use la tabla de usuario. - Se baja a modo user y se devuelve el control al proceso.
Aunque el trampolín es muy útil en cuanto a seguridad, es un desastre en cuanto a rendimiento. Cada vez que el trampolín cambia el registro CR3, la CPU (por hardware) automáticamente flushea el TLB, lo que hace que hacer syscalls sea extremadamente costoso.
No fue hasta tiempo después que Intel y AMD introdujeron una mejora de hardware llamada PCID (o como la habíamos llamado antes, ASID).
Nota: ASID vs Flushear TLB
En la parte de paginación (pt3) mencionábamos que, en cambios de contexto (como cuando se cambia entre procesos o se pasa de modo user a kernel y viceversa), había dos formas de gestionar qué se hacía con el TLB, porque el caché de paginación de usuario no necesariamente tenía que servir para el kernel.
Uno de esos métodos era flushear el TLB, otro era mantener un ASID (Identificador de Espacio de Direcciones) que indicase a qué proceso pertenecía cada traducción.
Aunque se planteaba el ASID como la mejor opción (por la lentitud de flushear el TLB), de ahí en adelante (hasta ahora) cada vez que había un cambio de contexto se menciona como desventaja que había que flushear el TLB, como si no se hubiesen visto los ASID.
El problema es que, aunque conceptualmente los habíamos visto, en la época en la que se implementaron el resto de medidas y técnicas vistas o no existían los ASID, o no se usaban:
- Desde los años 80 hasta 2010, la arquitectura x86 no tenía ASIDs.
- Si entonces se modificaba el registro
cr3, la CPU automáticamente borraba todo el TLB, por eso en mucho contenido (Libros & Internet) se indica que un cambio de contexto implica flushear el TLB. - Para sobrevivir al bajo rendimiento que implicaba borrarlo cada vez, x86 usaba un Bit Global: Las entradas de la tabla de paginación del kernel se marcaban con el bit global, y al flushear el TLB, todo lo que tuviese dicho bit puesto no se borraba.
- Si entonces se modificaba el registro
- A partir de 2010, Intel introdujo los ASID, pero los SO los ignoraron completamente por la complejidad de implementarlos y porque en su momento no hacían falta, los bit globales iban bien.
- En 2018, con Meltdown, los SO se vieron obligados a cambiar y a implementar los ASID, porque con KPTI el rendimiento de casi todos los dispositivos cayó a prácticamente la mitad de un día para otro.
Spectre#
Spectre es más general que Meltdown y más difícil de solucionar del todo. Afecta a prácticamente todas las CPUs modernas con branch prediction (Intel, AMD, ARM).
A diferencia de Meltdown, no explota un fallo de permisos, sino que abusa de la lógica de predicción en sí, que no es incorrecta per se pero filtra información por el canal lateral, el caché.
El ejemplo que pone el paper de Spectre es el siguiente.
Supongamos que en el kernel, o en una aplicación víctima, existe una función normal que hace algo así:
| |
Esta función es completamente segura, tiene un control de límites para la variable x que garantiza que no se salga del tamaño del array.
Hay que tener en cuenta que, en sí, la función input_user() está en la zona del proceso víctima, y el atacante solo controla la variable x a través de, por ejemplo, una API o syscall.
Ataque#
El branch predictor de la CPU, de entre todas las cosas que hace, se encarga de especular si un condicional if específico va a ser verdadero o falso antes de evaluarlo, para poder ir trabajando.
El atacante aprovecha esto y hace lo siguiente:
- Llama a la función
input_user(x)muchas veces seguidas usando valores dexválidos , dentro de rango (x < array1_size). - El predictor de saltos entonces detecta el patrón y dice “el condicional de este
ifsiempre es verdadero”. A partir de aquí empieza a predecir “verdadero” sin esperar al resultado de la comparación. - El atacante borra
array1_sizede la caché de la CPU. Esto obligará a la CPU a buscar el valor a RAM, dándole más margen al atacante. - Ahora el atacante llama a la función, pero esta vez pasa un valor de
xmucho mayor quearray1_size, específicamente, un valor dexque, usado como índice, apunte a la dirección de memoria donde está ubicado el valor al que se quiere acceder. P.ej, una contraseña de un navegador. - Como el Branch Predictor está entrenado para asumir que el
ifes verdadero, leerá especulativamentearray1[x]y luego usará ese byte para indexararray2, cargando una línea específica en caché, como en Meltdown. - Cuando termina la comparación, la CPU detecta que la predicción ha sido incorrecta y borra lo hecho, pero igualmente se mantiene el valor en caché.
- El atacante hace FLUSH+RELOAD sobre
array2y obtiene el byte oculto.
Esta es, según el paper, la Variante 1, “EXPLOITING CONDITIONAL BRANCH MISPREDICTION”. Aún hay otra más que se basa en manipular el predictor de saltos de forma similar, puede verse en el paper.
Solución?#
Meltdown se solucionaba bien al aplicar KPTI. Sin posibilidad de acceder al espacio del kernel, ni siquiera con permisos este podía leerse. Hoy en día está prácticamente solucionado.
Sin embargo, Spectre es diferente porque no es un fallo en sí. El código vulnerable puede ser código completamente correcto y legítimo que simplemente sigue un patrón como el anterior.
Aunque hay formas de mitigar Spectre en ciertos casos, dichas correcciones son bastante más costosas y no cubren todos los casos. De hecho, hoy en día la vulnerabilidad sigue viva y siguen apareciendo variantes nuevas (ZombieLoad, Spectre-NG, Spectre v6, etc.). Cada parche arregla un síntoma, y poco después aparece otro.
De todas formas, como indicaba TheRegister hace un año, no suelen verse ataques Spectre fuera de entornos de pruebas precisamente por lo complejos que son de ejecutar.

