
Explotación remota del kernel a través de IPv6
Explotando el Kernel de Forma Remota a Través de IPv6
CVE-2024-38063 - Explotando el Kernel de Forma Remota a Través de IPv6 Marcus Hutchins
Desde que el parche más reciente de Windows se lanzó el 13 de agosto, he estado inmerso en las profundidades de tcpip.sys (el controlador del kernel responsable de manejar los paquetes TCP/IP). Una vulnerabilidad con una puntuación CVSS de 9.8 en la parte más accesible del kernel de Windows era algo que simplemente no podía dejar pasar. Nunca antes había examinado IPv6 (ni los controladores encargados de analizarlo), así que sabía que intentar aplicar ingeniería inversa a esta vulnerabilidad sería extremadamente desafiante, pero una buena experiencia de aprendizaje.
En gran medida, tcpip.sys está en gran parte sin documentar. Pude encontrar un par de escritos de explotación para errores antiguos: aquí, aquí y aquí, pero poco más. Cuando el primer resultado de búsqueda de mi Google en inglés está escrito en chino, inmediatamente sé que estoy fuera de mi alcance y que me espera un mal rato, pero debemos aprender. A pesar de que Google Translate hace un trabajo mediocre, la publicación proporcionó una visión increíblemente detallada de cómo funciona la fragmentación de IPv6 y me dio una buena ventaja inicial.
Más tarde, mientras buscaba en Google algunos nombres de funciones, me topé con otro análisis de la misma vulnerabilidad de 2021, escrito por Axel Souchet (alias 0vercl0k), que profundizaba aún más en los internos de tcpip.sys y me dio suficiente información para definir varias estructuras no documentadas. El análisis de parche más fácil de la historia
Por lo general, incluso solo aplicar ingeniería inversa al parche para averiguar qué cambio de código corresponde a la vulnerabilidad puede llevar días o incluso semanas, pero en este caso fue instantáneo. De hecho, fue tan fácil que varias personas en las redes sociales me dijeron que me equivocaba y que el error estaba en otro lugar. ¿Les hice caso y perdí un día entero invirtiendo el controlador equivocado? Quizás nunca lo sepamos.
Hubo exactamente un cambio realizado en todo el archivo del controlador, que resultó ser el error después de todo.
Una vista general de bindiff de tcpip.sys antes y después de instalar el parche.
Solo una sola función en todo el controlador ha sido modificada. Normalmente, podría pasar un día entero revisando más de 20 cambios de funciones diferentes solo para averiguar cuál es la que debería estar examinando, pero esta vez no.
Ipv6pProcessOptions() antes del parche.
Ipv6pProcessOptions() después del parche.
No solo fue una sola función la que se cambió, sino una sola línea de código.
La función de nombre extremadamente largo Feature_2660322619__private_IsEnabledDeviceUsage_3() es algo que Microsoft a veces agrega para permitir reversiones parciales de parches. La llamada verifica la presencia de una bandera global o configuración de registro que, si está configurada, hará que la función devuelva false, lo que resultará en la ejecución del código original en lugar de la versión parcheada.
La razón por la que Microsoft hace esto es porque los parches de seguridad a veces rompen cosas sin querer, por lo que esta configuración permite a un administrador desparchear una sola vulnerabilidad, sin desinstalar todo el paquete de parches mensual y debilitar drásticamente la seguridad del sistema.
Teniendo esto en cuenta, está claro que todo lo que hace este parche es reemplazar una llamada a IppSendErrorList() con IppSendError(), dándonos una pista de que el problema está con algún tipo de lista. La diferencia de parche más fácil de la historia (o eso pensaba). Vulnerabilidades opcionales, explotación obligatoria
Aplicar ingeniería inversa al parche para encontrar el código alterado es solo la mitad del desafío (o en este caso, menos del 0.1%). El resto del proceso consiste en aplicar suficiente ingeniería inversa a la base de código para entender qué está pasando, averiguar qué tipo de vulnerabilidad fue parcheada, cómo elaborar una solicitud para alcanzar el código objetivo y qué estado resulta en una condición explotable.
La primera parte es bastante fácil. El cambio está en Ipv6pProcessOptions(), lo que nos dice que es IPv6 e implica el procesamiento de opciones. Entonces, una rápida llamada a la RFC nos dice exactamente qué es una opción de IPv6 y dónde podemos encontrar una.
El diseño del encabezado de opciones de destino de Wikipedia.
Bien, genial. Lo que estamos buscando parece ser el encabezado de opciones de destino, que se encuentra justo después del encabezado principal de IPv6. Usemos la biblioteca de Python 'scapy' para construir un paquete IPv6 de prueba.
Nota: Para mitigar ataques DDoS usando direcciones IP falsificadas, Windows restringe la capacidad de construir paquetes IP sin procesar. Por esta razón, opté por usar Linux para desarrollar mi prueba de concepto. Si bien Linux permite a los usuarios construir y enviar paquetes de capa 2 y capa 3 sin procesar, requiere que el script de Python se ejecute como root.
import sys
import struct
from scapy.all import *
def send_ipv6_option_packet(dest_ip):
ethernet_header = Ether()
ip_header = IPv6(dst=dest_ip)
options_header = IPv6ExtHdrDestOpt()
sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2:
print('Use: python3 script.py <target_ipv6_address>')
exit(-1)
send_ipv6_option_packet(sys.argv[1])
Después de establecer un punto de interrupción en tcpip!Ipv6pProcessOptions, luego ejecutar el script, quedó claro que todo lo necesario para llegar a la función vulnerable era enviar un paquete IPv6 con una estructura de opciones vacía. Luego intenté agregar algunas opciones inválidas a la estructura para ver si podía alcanzar la llamada a IppSendErrorList().
Una breve revisión del código indicó que casi cualquier formato de opción inválido podría desencadenar la llamada a IppSendErrorList. Así que decidí usar la opción de paquete Jumbo con una longitud inválida (menos de 65535 bytes).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
Entonces, ¿qué hace realmente IppSendErrorList()? Bueno, el código es bastante simple.
La función completa de IppSendErrorList.
El código itera una lista enlazada y llama a IppSendError() en cada elemento de la lista. Nuevamente, los astros se han alineado y las cosas han sido fáciles hasta ahora. Si IppSendErrorList simplemente llama a IppSendError para cada elemento de una lista, y el parche reemplaza la llamada a IppSendErrorList con IppSendError, entonces el problema ocurre cuando se llama a IppSendError en un elemento de la lista que no sea el primero.
Entonces, ¿qué es esta lista y cómo hacemos una? Está haciendo una lista, la está revisando…52,567 veces
Aquí es donde las cosas pasaron de obvias a anormalmente difíciles, aunque creo que gran parte de esto se debió a que una de mis dos células cerebrales disponibles estaba ocupada luchando contra una mala infección de covid. Perdí un par de días entendiendo partes del código, quedándome dormido y luego olvidando lo que había descubierto. Todo el proceso requirió más de una semana de ingeniería inversa de partes de tcpip.sys para averiguar qué estaba pasando. Pero la publicación del blog de Axel fue extremadamente útil.
Al observar las funciones y estructuras que Axel invirtió, y a qué otras funciones se pasan, está claro que el único argumento pasado a Ipv6pProcessOptions() es la misma estructura packet_t definida en el artículo. Esencialmente, el puntero pasado a Ipv6pProcessOptions, e iterado por IppSendErrorList, es una lista enlazada de paquetes.
Entonces, establecí un punto de interrupción en Ipv6pProcessOptions() e inspeccioné la lista.
La entrada list->Next es NULL.
Cada vez que se alcanzaba mi punto de interrupción, la lista contenía solo un paquete. Pasé mucho más tiempo del que me gustaría admitir tratando de averiguar por qué y cómo hacer que mi lista fuera realmente una lista. Mi primer pensamiento fue la fragmentación de IPv6: IPv6 permite a los remitentes dividir paquetes grandes en paquetes más pequeños separados, lo que tendría sentido mantener juntos en una lista.
Después de una extensa ingeniería inversa, confirmé que mis suposiciones eran correctas, aunque la lista de fragmentos no está relacionada con la que estamos tratando aquí.
En realidad, terminé encontrando la respuesta completamente por accidente. Ocasionalmente, la lista se llenaba, pero la razón no estaba clara. Después de dar muchas vueltas, me di cuenta de que cuando se activa mi punto de interrupción del kernel, pausa todo el kernel, lo que hace que el adaptador de red acumule paquetes. Cuando el kernel se reanuda, estos paquetes se pasan por la pila a tcpip.sys en una lista ordenada. Esto solo ocurría si los paquetes se enviaban mientras el kernel estaba en pausa, pero no se procesaban antes de que se alcanzara el siguiente punto de interrupción.
Este comportamiento probablemente sea una optimización de rendimiento, donde a baja velocidad de procesamiento, el kernel procesa los paquetes individualmente, pero a volúmenes más altos, los paquetes se organizan en listas y se procesan en lotes. Lo más probable es que las listas se separen según factores como el protocolo y la dirección de origen para acelerar el procesamiento, por lo que nuestra lista debería contener solo paquetes IPv6 que enviamos. Oye, amigo, escuché que te gusta el DoS
Ahora que sabemos que los paquetes se fusionan en listas durante el alto rendimiento, está claro cuál sería la opción más fácil. Nuestra PoC de DoS irónicamente va a tener que usar DoS para desencadenar la condición de DoS. Si inundamos el sistema con ráfagas de paquetes IPv6, deberíamos poder obtener una bonita lista grande que se pase a IppSendErrorList().
Al principio, sin importar cuántos paquetes enviara, aún solo podía obtener la lista con n > 1 si pausaba el kernel. Pero… ya que estamos usando Python (dolorosamente lento), en una VM (doblemente dolorosamente lento), probablemente necesitaremos ajustar algunas configuraciones. Para contrarrestar la VM-cepción que ocurre en mi sistema de ataque, decidí simplemente reconfigurar la VM objetivo para que use solo un núcleo de CPU.
¡Genial! ¡La lista de paquetes ahora es una lista que contiene muchas entradas!
Entonces, resulta que una VM dentro de una VM no es la mejor opción para DoS, ¿quién lo habría pensado? Pero lo logramos al final. Ahora, solo necesitamos averiguar qué hace IppSendError() y en qué parte radica el problema. Más ingeniería inversa… otra vez… para siempre…
Después de una extensa ingeniería inversa, quedó mucho más claro qué hace IppSendError. En circunstancias normales, simplemente deshabilita el paquete estableciendo net_buffer_list->Status en 0xC000021B (STATUS_DATA_NOT_ACCEPTED). Luego, transmite un ICMP de error que contiene información sobre el paquete erróneo de vuelta al remitente.
Dos partes relevantes de IppSendError.
Mi primer paso fue ver si había alguna función en tcpip.sys que ignore el valor de net_buffer_list->Status. Esto resultaría en que el controlador procese paquetes que están en estados no definidos o inesperados, con suerte llevando a una condición de explotación.
El bucle principal responsable de procesar paquetes.
Dado que el bucle responsable de llamar a todas las funciones de análisis está envuelto en una verificación de error (lo que significa que no podemos ir a ninguna parte una vez que se establece el código de error), pensé que este era el camino equivocado a seguir. En cambio, decidí volver a IppSendError y ver si hay caminos de código que modifiquen el estado del paquete antes de establecer el código de error, lo que podría llevar a una condición de carrera.
Después de mucha más ingeniería inversa, encontré el siguiente código cerca de la parte inferior de IppSendError.
Un camino de código en IppSendError que establece packet_size a cero.
Cuando se llama a IppSendErrorList, y por lo tanto a IppSendError, con el argumento always_send_icmp establecido en true, parece que intenta enviar el ICMP de error a cada paquete en la lista.
Luego, por razones que probablemente solo Dios conoce, alcanza un bloque de código donde el campo packet->packet_size se establece a cero.
Para establecer always_send_icmp en true, todo lo que necesitamos hacer es causar un error específico en el procesamiento del encabezado de opciones estableciendo el valor de 'Option Type' en cualquier número mayor que 0x80.
def build_malicious_option(next_header, header_length, option_type, option_length):
dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0)
sendp(packet)
Pero, ¿establecer packet_size a cero no debería romper el analizador?
Un fragmento del bucle principal responsable de procesar paquetes.
El manejador de paquetes simplemente llama a una función de la VTable basada en el valor de packet->next_header, que permanece sin cambios desde que se estableció durante el pre-análisis. Esto permite que el procesamiento de paquetes continúe e incluso nos da control sobre qué procesamiento ocurre.
Debido a que el valor de packet->next_header se obtiene del campo 'Next Header' del paquete IPv6, podemos establecerlo en cualquier valor de encabezado IPv6 válido, y el bucle llamará al analizador correspondiente. Esto nos da una gran superficie de ataque potencial.
El formato del paquete IPv6.
Todo lo que queda es encontrar una parte alcanzable del analizador IPv6 que haga algo descabellado con el campo packet_size. De vuelta a la fragmentación
El primer lugar que decidí mirar fue el analizador de fragmentos IPv6, porque allí estaba la antigua vulnerabilidad CVE-2021-24086, por lo que parecía un buen lugar para encontrar más código extraño.
Eh… está tan cerca, pero también tan lejos.
Aquí sí tenemos una vulnerabilidad, pero no es un RCE.
Esencialmente, en la mayoría de las CPU, los registros son circulares. Si incrementas un registro más allá de su valor máximo posible, vuelve a cero. De manera similar, si lo decrementas por debajo de su valor mínimo posible, vuelve al valor máximo posible. Estos se conocen como desbordamientos de enteros (integer overflow) y subdesbordamientos de enteros (integer underflow) respectivamente. Este comportamiento es ligeramente diferente para enteros con signo, pero no estamos tratando con esos aquí.
La primera línea, fragment_size = LOWORD(packet->packet_size) - 0x30, consiste en el siguiente código ASM:
El código ASM que calcula el tamaño del fragmento.
AX son los 16 bits bajos del registro EAX. Aunque el registro EAX es de 32 bits, AX opera como si fuera su propio registro de 16 bits, por lo tanto, cualquier desbordamiento o subdesbordamiento se limita a AX y no afectará al resto del registro EAX. Esto es increíblemente conveniente porque un subdesbordamiento en el registro EAX resultaría en un valor de 4 mil millones, lo que llevaría a un intento de asignar 4 GB de memoria, que probablemente fallaría.
Dado que el valor de packet->packet_size es cero, este código establece ax en cero, luego resta 0x30 de él.
En condiciones normales, el encabezado del paquete tiene 0x30 bytes, por lo que packet_size - 0x30 es el tamaño de los datos del fragmento.
En nuestro caso, packet->packet_size es 0, por lo que restar incluso 1 de él hará que el registro vuelva al valor máximo posible de entero de 16 bits (0xFFFF). Ya que estamos restando 0x30, el valor de AX se subdesbordará y se convertirá en MAX_VALUE - 0x2F, o 0xFFD0, que es 65,488.
Desafortunadamente, dado que el mismo cálculo se usa tanto para la asignación de memoria como para copiar datos, no obtenemos un desbordamiento de búfer. Creo que RtlCopyMdlToBuffer() también realiza una verificación de límites en el búfer de origen, por lo que ni siquiera obtenemos una lectura fuera de límites. Sin embargo, no nos vamos con las manos vacías.
Debido a que ExAllocatePoolWithTagPriority() no pone a cero la memoria asignada, y RtlCopyMdlToBuffer() solo copia la cantidad real de datos disponibles, obtenemos alrededor de 65 kb de memoria del kernel sin inicializar. Dado que las direcciones de memoria se reciclan después de la desasignación, es probable que el búfer se llene con lo que sea que estuviera almacenado anteriormente en esa dirección antes de la reasignación. Si podemos usar la fragmentación para construir un paquete que se nos devuelva, como una solicitud de Eco ICMP, podríamos filtrar memoria aleatoria del kernel, lo que llevaría a una bypass de ASLR.
Además de eso, el código también establece reassembly->fragment_size en el entero de 16 bits subdesbordado (65,488), por lo que ahora tenemos dos variables separadas que podríamos usar potencialmente para causar un desbordamiento de búfer. Vencido, pero no derrotado
Desafortunadamente (o afortunadamente, ya que probablemente me ahorró mucho tiempo), alguien me ganó de mano. Antes de que pudiera encontrar un lugar para usar uno de los enteros subdesbordados para desencadenar un desbordamiento de búfer, @ynwarcs encontró la respuesta y publicó una PoC. Esto resuelve la última pieza de mi rompecabezas.
La solución (o al menos una de ellas) es Ipv6pReassemblyTimeout(). Si bien no podemos causar un desbordamiento en el manejo del fragmento inicial, aparentemente podemos hacerlo durante la limpieza.
Los fragmentos de IPv6 permanecerán en la memoria hasta que ocurra una de tres condiciones:
Arruinamos nuestra fragmentación lo suficientemente mal como para que el sistema nos diga que es hora de parar.
Enviamos un fragmento con el campo 'More' establecido en 0, lo que indica que este es el último fragmento, y el sistema comenzará el reensamblaje.
No enviamos el último fragmento antes de que expire el período de tiempo de espera (60 segundos), y el sistema elimina los fragmentos.
Ipv6pReassemblyTimeout() se llama bajo la condición 3, así que examinemos cómo se puede explotar esto.
¡Esto es exactamente lo que necesitamos!
Anteriormente, nuestro problema era que el código usaba exactamente el mismo cálculo tanto para la asignación de memoria como para la operación de copia. Este código, por otro lado, no lo hace. Echemos un vistazo más profundo al ASM para ver cómo es explotable.
El código ensamblador responsable de calcular el tamaño de asignación.
Como se puede ver aquí, la primera parte del cálculo (fragment_list->net_buffer_length + reassembly->packet_length + 8) se realiza usando el registro DX de 16 bits.
Si recuerdas de antes, subdesbordamos reassembly->packet_length a 0xFFD0. Por lo tanto, el registro DX, después de agregar los 8 bytes, es 0xFFD8. Si fragment_list->net_buffer_length es mayor que 0x27 (39 bytes), DX se desbordará y se reiniciará a cero.
fragment_list->net_buffer_length debería ser alrededor de 0x38 bytes, por lo que resultará en desbordar el registro DX a 8. Después de agregar los 0x28 bytes, obtendremos una asignación de memoria de solo 48 bytes.
Dado que las llamadas posteriores a memmove() simplemente usan el valor sin modificar de reassembly->packet_length para el tamaño, resultará en que se copien 65,488 bytes desde reassembly->payload a un búfer de 30 bytes. Un gran beneficio adicional es que gran parte de los datos copiados provienen de la carga útil del fragmento, que controlamos, y pueden ser datos arbitrarios de cualquier formato, por lo que obtenemos un bonito desbordamiento de búfer basado en el pool del kernel, bastante controlable.
Para tener la posibilidad de desencadenar la vulnerabilidad, necesitamos tener uno o más paquetes de fragmento ubicados después del paquete de opción malformado en la lista enlazada en el momento en que se llama a IppSendErrorList. Sin embargo, según mis pruebas, esto no parece garantizar la explotación. Creo que también hay otras condiciones que deben cumplirse. Sospecho, pero no he confirmado, que el código de sincronización en IppSendError significa que también tenemos que ganar una condición de carrera.