Martes de parches → Miércoles de exploits: pwning del controlador de funciones auxiliares de Windows para WinSock (afd.sys) en 24 horas

Oficina de privacidad y tecnología responsable ilustración principal que muestra un escudo de privacidad

Autores

Valentina Palmiotti

Head of X-Force Offensive Research (XOR)

IBM

Ruben Boonen

CNE Capability Lead, Adversary Services

IBM X-Force

"Martes de parches, miércoles de exploits" es un viejo dicho hacker que se refiere al uso de vulnerabilidades como armas el día después de que los parches de seguridad mensuales estén disponibles públicamente. A medida que la seguridad mejora y las mitigaciones de exploits se vuelven más sofisticadas, la cantidad de investigación y desarrollo requerida para crear un arma de exploit ha aumentado. Esto es especialmente relevante para las vulnerabilidades de corrupción de memoria.

Captura de pantalla realizada para la entrada en el blog

Figura 1: Cronología de la explotación

Sin embargo, con la incorporación de nuevas características (y código C inseguro para la memoria) en el núcleo de Windows 11, pueden aparecer nuevas superficies de ataque. Al perfeccionar este código recién introducido, demostramos que las vulnerabilidades que pueden convertirse en armas de manera trivial siguen ocurriendo con frecuencia. En esta entrada en el blog, analizamos y explotamos una vulnerabilidad en el controlador de funciones auxiliares de Windows para Winsock, afd.sys, para la escalada de privilegios locales (LPE) en Windows 11. Aunque ninguno de nosotros tenía experiencia con este módulo del kernel, pudimos diagnosticar, reproducir y convertir la vulnerabilidad en un arma en aproximadamente un día. Puede encontrar el código de exploit aquí.

Diferencia de parches y análisis de causa principal

Según los detalles de CVE-2023-21768 publicados por el Microsoft Security Response Center (MSRC), la vulnerabilidad se encuentra en el controlador de funciones auxiliares (AFD), cuyo nombre de archivo binario es afd.sys. El módulo AFD es el punto de entrada del kernel para la API Winsock. Con esta información, analizamos la versión del controlador de diciembre de 2022 y la comparamos con la versión recién lanzada en enero de 2023. Estas muestras pueden obtenerse individualmente desde Winbindex sin el proceso lento de extraer cambios de los parches de Microsoft. Las dos versiones analizadas se muestran a continuación.

  • AFD.sys / Windows 11 22H2 / 10.0.22621.608 (diciembre de 2022)
  • AFD.sys/Windows 11 22H2/10.0.22621.1105 (enero de 2023)

Se utilizó Ghidra para crear exportaciones binarias para ambos archivos para que pudieran compararse en BinDiff. A continuación, se muestra una descripción general de las funciones coincidentes.

Captura de pantalla de la comparación binaria de AFD.sys

Figura 2: Comparación binaria de AFD.sys

Solo una función parecía haber cambiado, afd!AfdNotifyRemoveIoCompletion . Esto aceleró significativamente nuestro análisis de la vulnerabilidad. Luego comparamos ambas funciones. Las capturas de pantalla siguientes muestran el código modificado antes y después del parche al examinar el código descompilado en Binary Ninja.

Antes del parche, afd.sys version 10.0.22621.608 .

Captura de pantalla realizada para la entrada en el blog

Figura 3: afd!AfdNotifyRemoveIoCompletion antes del parche

Después del parche, versión 10.0.22621.1105 de afd.sys.

Captura de pantalla realizada para la entrada en el blog

Figura 4: afd!AfdNotifyRemoveIoCompletion posterior al parche

Este cambio que se muestra arriba es la única actualización de la función identificada. Un análisis rápido mostró que se está realizando una verificación basada en PreviousMode . Si PreviousMode es cero (lo que indica que la llamada se origina en el kernel), se escribe un valor en un puntero especificado por un campo en una estructura desconocida. Si, por el contrario, PreviousMode no es cero, se llama a ProbeForWrite para garantizar que el puntero establecido en el campo es una dirección válida que reside en el modo de usuario.

Esta comprobación falta en la versión anterior al parche del controlador. Dado que la función tiene una declaración de cambio específica para PreviousMode , se supone que el desarrollador tenía la intención de agregar esta verificación, pero se olvidó (¡a todos nos falta café a veces ☕!).

A partir de esta actualización, podemos inferir que un atacante puede llegar a esta ruta de código con un valor controlado enfield_0x18 de la estructura desconocida. Si un atacante es capaz de rellenar este campo con una dirección del kernel, entonces es posible crear una primitiva Write-Where del kernel arbitraria. En este punto, no está claro qué valor se está escribiendo, pero cualquier valor podría usarse para una primitiva de escalada de privilegios locales.

El prototipo de función en sí contiene tanto el PreviousMode valor y un puntero a la estructura desconocida como primer y tercer argumento respectivamente.

Captura de pantalla del prototipo de la función afd!AfdNotifyRemoveIoCompletion

Figura 5: prototipo de la función afd!AfdNotifyRemoveIoCompletion

Ingeniería inversa

Ahora sabemos la ubicación de la vulnerabilidad, pero no cómo desencadenar la ejecución de la ruta del código vulnerable. Haremos algo de ingeniería inversa antes de comenzar a trabajar en una prueba de concepto (PoC).

En primer lugar, se hizo una referencia cruzada de la función vulnerable para comprender dónde y cómo se utilizó.

Captura de pantalla de las referencias cruzadas de afd!AfdNotifyRemoveIoCompletion

Figura 6: Referencias cruzadas de afd!AfdNotifyRemoveIoCompletion

Se realiza una única llamada a la función vulnerable en afd!AfdNotifySock .

Repetimos el proceso, buscando referencias cruzadas a AfdNotifySock . No encontramos llamadas directas a la función, pero su dirección aparece encima de una tabla de punteros de función llamada AfdIrpCallDispatch .

Captura de pantalla realizada con afd!AfdIrpCallDispatch

Figura 7: afd!AfdIrpCallDispatch

Esta tabla contiene las rutinas de despacho para el controlador AFD. Las rutinas de envío se utilizan para gestionar las solicitudes de las aplicaciones Win32 mediante la llamada a DeviceIoControl. El código de control para cada función se encuentra en AfdIoctlTable .

Sin embargo, el puntero anterior no está dentro de la AfdIrpCallDispatch  tabla como esperábamos. A partir de las diapositivas de Recon de Vittitoe, descubrimos que en realidad hay dos tablas de despacho para AFD. El segundo es AfdImmediateCallDispatch . Calculando la distancia entre el inicio de esta tabla y el lugar donde el puntero a AfdNotifySock  se almacena, podemos calcular el índice en el AfdIoctlTable  que muestra que el código de control de la función es 0x12127 .

Captura de pantalla de afd!AfdIoctlTable

Figura 8: afd!AfdIoctlTable

Vale la pena señalar que es el último código de control de entrada/salida (IOCTL) de la tabla, lo que indica que AfdNotifySock es probablemente una nueva función de envío que se agregó recientemente al controlador AFD.

En este punto, teníamos un par de opciones. Podríamos hacer ingeniería inversa de la API Winsock correspondiente en un espacio de usuario para entender mejor cómo se llama a la función del kernel subyacente, o hacer ingeniería inversa del código del kernel y llamarlo directamente. En realidad, no sabíamos a qué función de Winsock correspondía a AfdNotifySock , por lo que optamos por hacer lo último.

Nos encontramos con un código publicado por x86matthew que realiza operaciones de socket llamando directamente al controlador AFD, sin usar la biblioteca Winsock. Esto es interesante desde una perspectiva sigilosa, pero para nuestros propósitos, es una buena plantilla para crear un identificador para un socket TCP para realizar solicitudes IOCTL al controlador AFD. Desde ahí, pudimos llegar a la función objetivo, como lo demuestra el hecho de haber alcanzado un punto de interrupción establecido en WinDbg durante la depuración del kernel.

Captura de pantalla del punto de interrupción afd!AfdNotifySock

Figura 9: punto de interrupción de afd!AfdNotifySock

Ahora, consulte el prototipo de función para DeviceIoControl , a través del cual llamamos al controlador AFD desde el espacio del usuario. Uno de los parámetros, lpInBuffer , es un búfer en modo usuario. Como se mencionó en la sección anterior, la vulnerabilidad ocurre porque el usuario puede pasar un puntero no validado al controlador dentro de una estructura de datos desconocida. Esta estructura se pasa directamente desde nuestra aplicación de usuario a través del parámetro lpInBuffer. Se ha pasado a AfdNotifySock  como el cuarto parámetro, y en AfdNotifyRemoveIoCompletion  como tercer parámetro.

En este punto, no sabemos cómo completar los datos en lpInBuffer, que llamaremos AFD_NOTIFYSOCK_STRUCT , con el fin de pasar las comprobaciones necesarias para llegar a la ruta del código vulnerable en AfdNotifyRemoveIoCompletion . El resto de nuestro proceso de ingeniería inversa consistió en seguir el flujo de ejecución y examinar cómo llegar al código vulnerable.

Repasemos cada una de las verificaciones.

La primera comprobación que encontramos es al principio de AfdNotifySock :

Captura de pantalla de afd!AfdNotifySock size check

Figura 10: Comprobación del tamaño de afd!AfdNotifySock

Esta comprobación nos indica que el tamaño de la AFD_NOTIFYSOCK_STRUCT  debe ser igual a 0x30  bytes; de lo contrario, la función falla con STATUS_INFO_LENGTH_MISMATCH .

La siguiente comprobación valida los valores de varios campos de nuestra estructura:

de la validación de la estructura afd!AfdNotifySock

Figura 11: Validación de la estructura afd!AfdNotifySock

En ese momento no sabíamos a qué correspondía ninguno de los campos, por lo que pasamos en una 0x30  matriz de bytes llena de 0x41  bytes (AAAAAAAAA... ).

La siguiente comprobación que encontramos es luego de una llamada a ObReferenceObjectByHandle. Esta función toma el primer campo de nuestra estructura de entrada como su primer argumento.

Captura de pantalla realizada para la entrada en el blog

Figura 12: afd!AfdNotifySock llama a nt!ObReferenceObjectByHandle

La llamada debe devolver un resultado satisfactorio para proceder a la ruta de ejecución de código correcta, lo que significa que debemos pasar un identificador válido a un IoCompletionObject . No existe una forma oficialmente documentada de crear un objeto de ese tipo a través de la API Win32. Sin embargo, tras investigar un poco, encontramos una función NT no documentada, NtCreateIoCompletion, que hizo el trabajo.

Luego, llegamos a un bucle cuyo contador era uno de los valores de nuestra estructura:

Captura de pantalla realizada de afd!AfdNotifySock loop

Figura 13: bucle afd!AfdNotifySock

Este bucle verificaba un campo de nuestra estructura para verificar que contenía un puntero válido en modo usuario y copiaba datos a él. El puntero se incrementa después de cada iteración del bucle. Completamos los punteros con direcciones válidas y establecimos el contador en 1. A partir de aquí, finalmente pudimos llegar a la función vulnerable AfdNotifyRemoveIoCompletion .

Captura de pantalla de la llamada afd!AfdNotifyRemoveIoCompletion

Figura 14: llamada a afd!AfdNotifyRemoveIoCompletion

Una vez dentro AfdNotifyRemoveIoCompletion , la primera comprobación se realiza en otro campo de nuestra estructura. Debe ser distinto de cero. Luego se multiplica por 0x20 y se pasa a ProbeForWrite  junto con otro campo en nuestra estructura como parámetro de puntero. Desde aquí podemos completar la estructura aún más con un puntero de modo de usuario válido (pData2 ) y el campo dwLen = 1 (de modo que el tamaño total pasó a ProbeForWrite  es igual a 0x20), y las comprobaciones pasan.

Captura de pantalla realizada en afd! Afd! AfdNotifyRemoveIoCompletion campo de verificación

Figura 15: afd! Comprobación del campo Afd!AfdNotifyRemoveIoCompletion

Por último, la última comprobación que hay que pasar antes de llegar al código de destino es una llamada a IoRemoveCompletion que debe devolver 0 (STATUS_SUCCESS ).

Esta función se bloqueará hasta que:

  • Un registro de finalización está disponible para el IoCompletionObject parámetro
  • El tiempo de espera caduca, que se pasa como un parámetro de la función

Controlamos el valor del tiempo de espera a través de nuestra estructura, pero simplemente establecer un tiempo de espera de 0 no es suficiente para que la función devuelva un resultado satisfactorio. Para que esta función se ejecute sin errores, debe haber al menos un registro de finalización disponible. Después de realizar una investigación, encontramos la función no documentada NtSetIoCompletion, que incrementa manualmente el contador de operaciones de E/S pendientes en un IoCompletionObject . Llamar a esta función en el IoCompletionObject que creamos anteriormente asegura que la llamada a IoRemoveCompletion devuelve STATUS_SUCCESS .

Captura de pantalla realizada para la entrada en el blog

Figura 16: afd!AfdNotifyRemoveIoCompletion check return nt!IoRemoveIoCompletion

Activación arbitraria de write-where

Ahora que podemos llegar al código vulnerable, podemos completar el campo apropiado en nuestra estructura con una dirección arbitraria para escribir. El valor que escribimos en la dirección proviene de un número entero cuyo puntero se pasa a la llamada a IoRemoveIoCompletionIoRemoveIoCompletion  establece el valor de este entero en el valor de retorno de una llamada a KeRemoveQueueEx .

Captura de pantalla realizada para la entrada en el blog

Figura 17: valor de retorno de nt!KeRemoveQueueEx

Captura de pantalla realizada para la entrada en el blog

Figura 18: uso de retorno de nt!KeRemoveQueueEx

En nuestra prueba de concepto, este valor de escritura siempre es igual a 0x1 . Especulamos que el valor de retorno de KeRemoveQueueEx es el número de elementos eliminados de la cola, pero no investigó más a fondo. En este punto, teníamos la primitiva que necesitábamos y pasamos a terminar la cadena de exploit. Más tarde confirmamos que esta conjetura era correcta y que el valor de escritura puede incrementarse arbitrariamente mediante llamadas adicionales a NtSetIoCompletion en el IoCompletionObject .

LPE con IORING

Con la capacidad de escribir un valor fijo (0x1) en una dirección arbitraria del kernel, procedimos a convertir esto en un kernel arbitrario completo de lectura/escritura. Debido a que esta vulnerabilidad afecta a las últimas versiones de Windows 11(22H2), decidimos aprovechar la corrupción de objetos de entrada y salida de Windows de anillos para crear nuestra primitiva. Yarden Shafir ha escrito varios artículos excelentes sobre los anillos de E/S de Windows y también ha desarrollado y divulgado la primitiva que hemos aprovechado en nuestra cadena de explotación. Hasta donde sabemos, esta es la primera instancia en la que esta primitiva se ha utilizado en una explotación pública.

Cuando un usuario inicializa un anillo de E/S, se crean dos estructuras separadas, una en el espacio del usuario y otra en el espacio del kernel. Estas estructuras se muestran a continuación.

El objeto del núcleo se asigna a nt!_IORING_OBJECT  y se muestra a continuación.

Captura de pantalla realizada para la entrada en el blog

Figura 19: inicialización de nt!_IORING_OBJECT

Tenga en cuenta que el objeto kernel tiene dos campos, RegBuffersCount  y RegBuffers , que se ponen a cero en la inicialización. El recuento indica cuántas operaciones de E/S pueden ponerse en cola para el anillo de E/S. El otro parámetro es un puntero a una lista de las operaciones actualmente en cola.

En el espacio de usuario, al llamar a kernelbase!CreateIoRing, se obtiene un identificador de anillo de E/S si la operación se realiza correctamente. Este identificador es un puntero a una estructura no documentada (HIORING). Nuestra definición de esta estructura se obtuvo de la investigación realizada por Yarden Shafir.

typedef struct _HIORING {

    HANDLE handle;

    NT_IORING_INFO Info;

    ULONG IoRingKernelAcceptedVersion;

    PVOID RegBufferArray;

    ULONG BufferArraySize;

    PVOID Unknown;

    ULONG FileHandlesCount;

    ULONG SubQueueHead;

    ULONG SubQueueTail;

};

Si una vulnerabilidad, como la que se trata en esta entrada en el blog, le permite actualizar los RegBuffersCount  y RegBuffers  entonces es posible usar API de anillo de E/S estándar para leer y escribir en la memoria del kernel.

Como vimos anteriormente, podemos usar la vulnerabilidad para escribir 0x1 en cualquier dirección del kernel que nos guste. Para configurar la primitiva del anillo de entrada/salida podemos simplemente activar la vulnerabilidad dos veces.

En el primer activador configuramos el RegBufferCount  a 0x1 .

Captura de pantalla realizada de nt!_IORING_OBJECT la primera vez que se activó el error

Figura 20: nt!_IORING_OBJECT activa el error por primera vez

Y en el segundo activar asignamos RegBuffers a una dirección que podemos asignar en el espacio de usuario (como 0x0000000100000000).

Captura de pantalla de nt!_IORING_OBJECT que activa el error por segunda vez

Figura 21: nt!_IORING_OBJECT activa el error por segunda vez

Todo lo que queda es poner en cola las operaciones de E/S escribiendo punteros ant!_IOP_MC_BUFFER_ENTRY  estructuras falsificadas en la dirección del espacio de usuario (0x100000000 ). El número de entradas debe ser igual a RegBuffersCount . Este proceso se destaca en el diagrama a continuación.

Diagrama elaborado para la entrada en el blog

Figura 22. Configuración del espacio de usuario para la primitiva R/W del kernel del anillo de E/S

Uno de esos nt!_IOP_MC_BUFFER_ENTRY  se muestra en la siguiente captura de pantalla. Tenga en cuenta que el destino de la operación es una dirección del kernel (0xfffff8052831da20 ) y que el tamaño de la operación, en este caso, es 0x8  bytes. No es posible determinar a partir de la estructura si se trata de una operación de lectura o de escritura. La dirección de la operación depende de qué API se empleó para poner en cola la solicitud de E/S. El uso de kernelbase!BuildIoRingReadFile da como resultado una escritura arbitraria en el kernel y kernelbase!BuildIoRingWriteFile  una lectura arbitraria del kernel.

Captura de pantalla realizada para la entrada en el blog

Figura 23: ejemplo de operación de anillo de E/S falsificada

Para realizar una escritura arbitraria, una operación de E/S tiene la tarea de leer datos de un controlador de archivo y escribir esos datos en una dirección del kernel.

Diagrama elaborado para la entrada en el blog

Figura 24: escritura arbitraria en el anillo de E/S

Por el contrario, para realizar una lectura arbitraria, se asigna una operación de E/S para leer datos en una dirección del núcleo y escribir esos datos en un identificador de archivo.

Diagrama elaborado a partir de la lectura arbitraria del anillo de E/S

Figura 25: lectura arbitraria del anillo de E/S

Demostración

Con la configuración primitiva todo lo que queda es usar algunas técnicas estándar posteriores a la explotación del kernel para filtrar el token de un proceso elevado como System (PID 4) y sobrescribir el token de un proceso diferente.

Explotación en la naturaleza

Después del lanzamiento público de nuestro código de exploit, Xiaoliang Liu (@flame36987044) de 360 Icesword Lab reveló públicamente por primera vez que descubrió una muestra que explota esta vulnerabilidad en la naturaleza (ITW) a principios de este año. La técnica utilizada por la muestra de ITW difería de la nuestra. El atacante activa la vulnerabilidad utilizando la función API Winsock correspondiente, ProcessSocketNotifications , en lugar de llamar al afd.sys controlador directamente, como en nuestro exploit.

La declaración oficial de 360 Icesword Lab es la siguiente:

“360 IceSword Lab se centra en la detección y defensa de APT. Con base en nuestro sistema de radar de vulnerabilidad de día 0, descubrimos una muestra de exploit de CVE-2023-21768 en enero de este año, que difiere de los exploits anunciados por @chompie1337 y @FuzzySec en que se explota a través de mecanismos del sistema y características de vulnerabilidad. El exploit está relacionado con NtSetIoCompletion y ProcessSocketNotifications , ProcessSocketNotifications obtiene el número de veces NtSetIoCompletion se llama, por lo que lo utilizamos para cambiar el recuento de privilegios”.

Conclusión y reflexiones finales

Puede notar que en algunas partes de la ingeniería inversa nuestro análisis es superficial. A veces resulta útil limitarse a observar algunos cambios de estado relevantes y tratar partes del programa como una caja negra, para evitar perderse en detalles irrelevantes. Esto nos permitió revertir un exploit rápidamente, aunque maximizar la velocidad de finalización no era nuestro objetivo.

Además, realizamos una revisión de diferencias de parches de todas las vulnerabilidades reportadas enafd.sys indicado como "Explotación más probable". Nuestra revisión reveló que todas las vulnerabilidades, excepto dos, fueron resultado de una validación incorrecta de los punteros pasados desde el modo de usuario. Esto demuestra que tener un conocimiento histórico de vulnerabilidades pasadas, particularmente dentro de un objetivo específico, puede ser fructífero para encontrar nuevas vulnerabilidades. Cuando se amplía la base de código, es probable que se repitan los mismos errores. Recuerde, nuevo código C == nuevos errores 😀. Como lo demuestra el descubrimiento de la vulnerabilidad mencionada anteriormente, que se está explotando en la red, se puede afirmar con certeza que los atacantes también están vigilando de cerca las nuevas incorporaciones al código base.

La falta de soporte para Supervisor Mode Access Protection (SMAP) en el kernel de Windows nos deja con abundantes opciones para construir nuevas primitivas de exploit de solo datos. Estas primitivas no son viables en otros sistemas operativos compatibles con SMAP. Por ejemplo, considere CVE-2021-41073, una vulnerabilidad en la implementación de Linux de búferes prerregistrados de anillos de E/S (la misma característica de la que abusamos en Windows para una primitiva R/W). Esta vulnerabilidad puede permitir sobrescribir un puntero del kernel para un búfer registrado, pero no se puede utilizar para construir una primitiva R/W arbitraria porque si el puntero se reemplaza por un puntero de usuario y el kernel intenta leer o escribir allí, el sistema se bloqueará.

A pesar de los esfuerzos de Microsoft por eliminar la posibilidad de explotar las primitivas, es inevitable que aparezcan nuevas primitivas que ocupen su lugar. Pudimos explotar la última versión de Windows 11 22H2 sin encontrar ninguna mitigación ni restricción por parte de las características de seguridad basadas en virtualización como HVCI.

Mixture of Experts | 12 de diciembre, episodio 85

Decodificación de la IA: Resumen semanal de noticias

Únase a nuestro panel de ingenieros, investigadores, responsables de producto y otros profesionales de talla mundial que se abren paso entre el revuelo de la IA para ofrecerle las últimas noticias e insights al respecto.