
Análisis técnico detallado y exploit de prueba de concepto para CVE-2024-30051, un desbordamiento de búfer basado en el montón en la biblioteca principal DWM de Windows que permite la escalada de privilegios local al nivel de Sistema de Integridad.
En esta publicación de blog, explicaré una vulnerabilidad en la biblioteca principal DWM de Microsoft Windows que analicé cuando se desarrollaba el exploit para Core Impact. Permite que un atacante sin privilegios ejecute código como usuario DWM con privilegios de Integridad del Sistema (CVE-2024-30051).
Dado que en ese momento no había suficiente información pública para desarrollar el exploit, tuve que realizar mucha ingeniería inversa, por lo que aquí mostraré cómo aplicar ingeniería inversa al parche KB5037771 para Windows 23H2 usando IDA PRO. Usaré BINDIFF para realizar la comparación binaria entre dwmcore.dll versión 10.0.22621.3447 y la versión 10.0.22621.3593, mostraré cómo se produce el desbordamiento del montón y luego lo explotaré elevando privilegios, y finalmente crearé un PoC funcional.
Índice:
Detalles de la vulnerabilidad: [2]
Diffing para encontrar el error: [3]
Análisis del PoC que explota CVE-2024-30051: [8]
3) Creación de la ventana [16]
4) Creación del dispositivo [16]
5) Creación de la fábrica [22]
6) Creación del contexto del dispositivo [28]
7) Creación del dispositivo de composición [29]
8) Llamada a la función hook3 [31]
9) Creación del objetivo para HWND [32]
10) Creación de la superficie [33]
11) Llamada a BeginDraw, EndDraw y CreateVisual [34]
11) Llamada a Visual SetContent [36]
12) Liberación de objetos [38]
13) Confirmación del dispositivo de composición [38]
17) Realización de Heap Spray [49]
18) Modificación del chunk base antes del envío [51]
19) Depuración del proceso DWM [52]
20) Elevación de privilegios al nivel de Integridad del Sistema [62]
Vulnerabilidad de Elevación de Privilegios en la Biblioteca Principal DWM de Windows CVE-2024-30051
Publicado: 14 de mayo de 2024
CNA asignador: Microsoft CVE-2024-30051
Impacto: Elevación de Privilegios
Severidad máxima: Importante
Debilidad:
CWE-122: Desbordamiento de búfer basado en montón
CVSS: 3.1 7.8 / 7.2
La vulnerabilidad existe debido a un error de cálculo de tamaño en una división entera dentro de la biblioteca principal DWM de Windows llamada dwmcore.dll. Un usuario local puede provocar un desbordamiento de búfer en el montón en el método CCommandBuffer::Initialize en dwmcore.dll y puede ejecutar código arbitrario con el usuario DWM con privilegios de Integridad del Sistema. El exploit realizará un Heap Spray en el proceso DWM para preparar la memoria y finalmente produce un desbordamiento de montón en dwmcore.dll que se activará al liberar ciertas partes del heap spray.
Una vez que el exploit tiene éxito, el proceso DWM cargará nuestra DLL manipulada que ejecuta nuestro código o nuestro ejecutable (en nuestro caso un CMD) como el usuario DWM que tiene privilegios de Integridad del Sistema.

Recorramos esta vulnerabilidad y veamos cómo nos permite ejecutarnos como un usuario DWM con Nivel de Integridad SYSTEM. Tenga en cuenta que, dado que no es un usuario que pertenezca al grupo Administradores, tiene algunas restricciones de privilegios.
El parche para Windows 11 23H2 se puede descargar desde:
https://www.catalog.update.microsoft.com/Search.aspx?q=KB5037771
windows11.0-kb5037771-x64_19a3f100fb8437d059d7ee2b879fe8e48a1bae42.msu
La versión vulnerable de dwmcore.dll es: 10.0.22621.3447
La versión parcheada de dwmcore.dll es: 10.0.22621.3593
Analizando las funciones modificadas, está claro que la versión parcheada de CCommandBuffer::Initialize tiene muchos bloques añadidos, lo que la hace parecer bastante diferente a la versión sin parchear.

Después de aplicar ingeniería inversa estática a esa función, hay dos llamadas a CD2DSharedBuffer::GetBufferSize.
La primera llamada obtiene el tamaño para asignar en el new y la segunda llamada obtiene el mismo tamaño para el memcpy.

Todo parece correcto inicialmente. Sin embargo, antes de la asignación, realiza algunas operaciones con el tamaño.

Obtiene buffer_size y buffer_size2 llamando a la misma función CD2DSharedBuffer::GetBufferSize, devolviendo ambas el mismo valor. Pero en el new realiza una preoperación, una división entera de buffer_size por 0x90 y luego multiplicando por 0x90, mientras que en el memcpy usa el buffer_size2 devuelto sin operar sobre él.
Con estas operaciones, descubrí que el tamaño finalmente usado en el new y en el memcpy puede ser diferente.
buffer_size = buffer_size2 (tamaños devueltos)
size_new= buffer_size/0x90 x 0x90
size_memcpy=buffer_size2
Por ejemplo, si buffer_size es 0x91
buffer_size = buffer_size2=0x91
size_new= buffer_size/0x90 x 0x90 =0x90
size_memcpy= buffer_size2= 0x91
Este ejemplo demuestra que hay un desbordamiento de montón. Se están copiando más bytes de los asignados, y el tamaño es controlable.
Por ejemplo, si buffer_size es 0x23f como se usó en el POC.
buffer_size = buffer_size2=0x23F
size_new= buffer_size/0x90 x 0x90 =0x1b0
size_memcpy== buffer_size2=0x23f
Con la función vulnerable analizada, quería ver cómo llegar a la función vulnerable CCommandBuffer::Initialize. Aquí es donde las cosas comienzan a complicarse.
Observando las referencias a esta función, parece que se llega a ella desde métodos de la clase CPrimitiveGroup:

Dichos métodos se pueden acceder desde la vftable de objetos CPrimitiveGroup:
Tiene su constructor:

Y se llega de esta manera:

Como inicialmente pasé por este proceso, me tomé el tiempo de leer el PDF "The Lost World of DirectComposition: Hunting Windows Desktop Window Manager Bugs" y me sumergí en el mundo de Direct Composition. Esto me ayudó a crear mi primer PoC.
Además, necesitaba aplicar ingeniería inversa a win32ksys e intenté enviar paquetes a través de las funciones:
NtDCompositionCreateChannel
NtDCompositionProcessChannelBatchBuffer
NtDCompositionCommitChannel

Mi primer PoC llegó al constructor de CPrimitiveGroup. Sin embargo, después de mucha ingeniería inversa no encontré una manera de manejar las llamadas a los métodos de la vftable para llegar a la función vulnerable directamente a través de llamadas ALPC usando estas funciones.
Pasé mucho tiempo haciendo ingeniería inversa complicada. Durante este proceso, encontré la muestra del malware que explotaba la vulnerabilidad, lo que fue inmensamente útil porque el método de explotación es mucho más complejo de lo que pensé inicialmente. También incluye varios hookings a API del sistema y utiliza métodos que quizás son un poco cuestionables. Pero todo vale en la guerra y los exploits, así que comencé a analizar el malware y a partir de ese análisis creé mi PoC final que finalmente explota la vulnerabilidad, lo cual explicaré a continuación.
Primero, quiero aclarar que el malware no solo explota la vulnerabilidad CVE-2024-30051 que eleva nuestro proceso al Nivel de Integridad del Sistema, sino que también realiza una segunda parte que desde allí termina elevando un usuario SYSTEM con todos los privilegios, lo cual ya excede el CVE explicado.
Además, es importante tener en cuenta que el malware es mucho más complejo que mi PoC que intenta minimizar el código. El malware realiza muchas más comprobaciones para garantizar la fiabilidad y por eso funciona al primer intento. Descarté todas esas comprobaciones para simplificar y me dediqué a la explotación pura, incluso quizás teniendo que ejecutar el PoC dos o tres veces para lograr la explotación.
El enlace al PoC ejecutable es https://github.com/fortra/CVE-2024-30051
Primero, el PoC llama a GetVersion para obtener la versión del sistema operativo donde se está ejecutando y según eso realiza diferentes inicializaciones de algunas variables globales. Mi PoC fue probado en Windows 11 23H2 y Windows 11 22h2. Otros sistemas también son vulnerables y se agregaron los valores para explotarlos.
Engancha cuatro funciones del sistema y sin engancharlas no puede lograr la explotación. Estas funciones son: RtlAllocateHeap, RtlCreateHeap, NtDCompositionCreateChannel y NtDCompositionCommitChannel.

En estas funciones parcheará los primeros 5 bytes para hacer que salte a su propio código. Por supuesto, el código no puede estar muy lejos ya que un salto de 5 bytes no cubre toda la memoria y debe estar cerca.
Para hacerlo, el malware utiliza un código muy largo, analizando el mapa de memoria para decidir dónde puede realizar la asignación de su propio código. Como el código es complicado, me centré en hacerlo dos líneas simples:
base_ntdll = GetModuleHandleW(L"ntdll.dll");
global4_ = (char *)VirtualAlloc((LPVOID)(base_ntdll-0x2000), 0x1000uLL, 0x3000u, 0x40u);
Resté de la base de ntdll, 0x2000 y pasé esa dirección a VirtualAlloc para asignar allí. Las DLL de 64 bits se asignan bastante separadas en la memoria entre sí con espacios vacíos entre ellas. Veamos cómo funcionan los hooks:

Llama a una función hooking, que es la que realizará el enganche de la API RtlAllocateHeap, que tiene tres argumentos, el primero es la dirección de la API a parchear, llamada sym_RtlAllocateHeap. Antes de parchear, apunta al inicio de la API:

Aquí está la función RtlAllocateHeap:

El segundo argumento es la rutina llamada hook que se ejecutará cuando la API esté completamente parcheada:


La función hook llama a my_RtlAllocateHeap.
La función hooking parcheará los primeros 5 bytes de la API para que salte a hook.
Llamará al código en el área asignada donde ejecutará la primera instrucción de la API que fue sobrescrita con los 5 bytes y luego saltará a RtlAllocateHeap+5 justo después de los bytes parcheados:

Así es como se verá la API después del hook. Los primeros 5 bytes cambiaron para que salte a hook. Llamará a my_RtlAllocateHeap el código que está justo arriba, que regresará al área marcada en púrpura para continuar la ejecución de la API:

Cuando la API termina de ejecutarse, regresa a hook. Desde allí comparará la variable global heap_base (que inicialmente es cero) con el primer argumento pasado a RtlAllocateHeap:

Después de eso, el código espera una cierta asignación especial, que tiene un HeapHandle específico. Al principio esta variable es cero y mientras sea cero, saltará y funcionará como un RtlAllocateheap normal:

El parámetro HeapHandle se obtiene dentro de RtlCreateHeap que, casualmente, es la segunda API enganchada.
Buscando referencias a la variable global heap_base, solo cambia su valor en la función hook2, que es la que se ejecuta después de enganchar RtlCreateHeap:


Entonces, la idea es capturar un HeapHandle específico y guardarlo en heap_base. Dado que ahora es diferente de cero, la función hook comenzará a comparar cada asignación. Por lo tanto, el PoC guardará la dirección de memoria que tiene el mismo HeapHandle que el almacenado previamente.
Cuando este es el caso, guardará la dirección de la asignación en la variable llamada base:
Estos dos primeros hooks ahora están encadenados. Cuando hook2 guarda el valor esperado de HeapHandle, activa la función hook que guardará la dirección de asignación que usa el mismo HeapHandle.
El tercer hook se aplica a NtDCompositionCreateChannel. La primera vez que se llama, guardará el MappedAddress, que es el contenido del tercer argumento. Desde allí cambiará hooked_flag a 1 para que a partir de entonces ya no guarde más y funcione normalmente.


La dirección guardada en la variable base se leerá tres veces más tarde. Dos de ellas ocurrirán en el último hook, llamado hook4:

La función hook4 para NtDCompositionCommitChannel se analizará más adelante porque es bastante compleja y muy importante.
Después de que los cuatro hooks están completos, regresa a la función principal para comenzar a crear una ventana. Esto se hace llamando a RegisterClassExW. Sin embargo, para registrar una clase de ventana para su uso posterior, debe llamarse con la función CreateWindowExW.

Esto inicializa la biblioteca COM llamando a CoInitializeEx para que sea utilizada por el hilo que llama:

Calcula el tamaño requerido del rectángulo de la ventana, basado en el tamaño deseado:

Se llama a la función CreateWindowExW para crear una ventana que se dibujará:

Desde ahí, llama a D3D11CreateDevice para crear un dispositivo o dispositivo DirectX que represente el adaptador de pantalla:


En mi PoC ppDevice se llama d3dDevice y ppInmediateContext se llama d3dContext:

El argumento flags debe establecerse en 0x20:

Luego llama a AddRef:

Esto incrementa el contador de referencia para un puntero de interfaz a un objeto COM:


El valor 0x10 se resta a THIS:

En el desplazamiento 0xf8 de ID3D11Device-0x10 hay un puntero a TComObject:



Este será el nuevo THIS y termina saltando a TComObject::AddRef:

Y termina sumando uno al contador de objetos que está en el desplazamiento 8 de TComObject:

Luego, AddRef incrementará el contador del otro tipo de objeto creado en D3D11CreateDevice, que es de tipo ID3D11DeviceContext:

En este caso, para encontrar el nuevo THIS, resta 0x108:


Salta aquí donde en el desplazamiento 0x98 está el nuevo THIS:

Este es el contador. En este ejemplo, es un QWORD:

El PoC llama a D2D1CreateFactory para usar Direct2D, y para crear la interfaz ID2D1Factory que se usa para crear otros recursos de Direct2D que se pueden usar para dibujar o describir formas:

El argumento riid es el sugerido por la página de Microsoft:
https://learn.microsoft.com/en-us/windows/win32/api/d2d1/nf-d2d1-d2d1createfactory

Estos son los que usa el malware:

El correcto para ID2D1Factory se puede encontrar aquí**:**
https://github.com/apitrace/dxsdk/blob/master/Include/d2d1_1.h

Como no soy un experto en Direct Composition, luego usé los mismos pasos que el malware:
La nueva fábrica que regresa no proporciona ningún tipo detallado. Dice void *, lo que significa que no está documentado oficialmente:

Como no conozco un tipo de objeto como en este caso, desarrollé un ejecutable que lo usa para verlo en memoria fácilmente:

Agregue puntos de interrupción en las cuatro funciones hook. En este caso, un punto de interrupción en hook2 mostrará cuándo captura el HeapHandle:

El hook debe detenerse cuando se captura el chunk deseado:

Coloque puntos de interrupción en los otros dos hooks:

Luego continúa llamando a QueryInterface:

https://help.solidworks.com/2020/english/api/sldworksapi/queryinterface_example_cplusplus_com.htm
https://github.com/tpn/winsdk-10/blob/master/Include/10.0.16299.0/shared/dxgi.idl

Intenta realizar una especie de conversión dinámica. Si el objeto de tipo ID3D11Device puede aceptar la interfaz (usar los métodos, etc.) de IDXGIDevice, crea una copia del objeto original que acepta el nuevo tipo, después de eso devuelve el puntero a él. En este caso la variable d3dContext1 será de tipo IDXGIDevice:

Ambos objetos heredan de CLayeredObject<Cdevice>
El ID3D11Device original es**:**

Como el que devuelve el puntero.

Luego crea un objeto ID2D1Device con la función CreateDevice:

En value2 devuelve un objeto de tipo ID2D1Device.
https://learn.microsoft.com/en-us/windows/win32/api/d2d1_1/nf-d2d1_1-id2d1device-createdevicecontext

Aquí está implementado en el PoC:


Luego llama a DCompositionCreateDevice
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-dcompositioncreatedevice


El IID pertenece a _IDCompositionDevice


En el mismo momento en que se rastrea la función DCompositionCreateDevice, se detiene en hook3, cuando llama a NtDCompositionCreateChannel:

De esta manera captura la MappedAddress que el sistema usa internamente cuando se llamó a DCompositionCreateDevice:
Esta es la pila de llamadas hasta aquí:

Este es el punto donde el módulo dcomp llama a la función NtDCompositionCreateChannel:

Después de regresar del paso anterior, guarda la MappedAddress. Usando ALPC, se conectará al proceso DWM y luego llamará a CreateTargetForHwnd

Usa el handle HWND de la ventana creada. Está relacionado con el dispositivo que acabo de crear, que es el THIS de este método:

Luego llama a CreateSurface
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-idcompositiondevice-createsurface

Luego llama a BeginDraw, EndDraw y llega a CreateVisual.

Llama a BeginDraw
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-idcompositionsurface-begindraw
Esto usa el IID _IDXGISurface:


Luego usa EndDraw:
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-idcompositionsurface-enddraw


Finalmente, llama a CreateVisual:
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-idcompositiondevice-createvisual

A continuación, llama a IDCompositionVisual::SetContent:
https://learn.microsoft.com/en-us/windows/win32/api/dcomp/nf-dcomp-idcompositionvisual-setcontent


Y llama a SetRoot:

El updateObject que se recibe en BeginDraw no especifica qué tipo es en la documentación.

A continuación, libera los objetos creados anteriormente:

Y ahora usando el mismo objeto dcompDevice de tipo IDCompositionDevice, llama al método Commit:


Llamar a ese método Commit se detiene en hook2 que captura el HeapHandle deseado:

Esta es la pila de llamadas ahora:

Antes de regresar a main, también crea un chunk usando RtlAllocateHeap. Luego es capturado y almacenado en la variable base dentro de la función hook:


Las llamadas a Create y Allocate se realizan una tras otra:

Ambos (Allocate y Create) son llamados desde DirectComposition::Cdevice::Commit:

Después de eso, cuando se llama a NtDCompositionCommitChannel, se detiene en hook4:

NtDCompositionCommitChannel es llamado desde aquí:

También es llamado desde DirectComposition::Cdevice::Commit

Vale la pena mencionar que el sistema ya ha agrupado los comandos para enviar por ALPC a DWM. Después de eso envía comandos usando NtDCompositionCommitChannel.
La función hook4 intercepta las llamadas a NtDCompositionCommitChannel y en este punto se agregarán más comandos al lote.
Veamos qué hace hook4:
Se realiza un bucle a través del chunk apuntado por base.
Sale del bucle cuando encuentra el valor 0x120 dentro del chunk:

Almacena la dirección y el desplazamiento donde se ubicaba el valor 0x120:

Sobrescribe el valor 0x120 con value4, que es igual a 0x1b0 + 0x8f = 0x23f. Este es el tamaño que usará en memcpy cuando ocurra el desbordamiento:



Añade 0xbc + 0x90 al puntero de dirección donde se encontraba 0x120:


Recuerde que en el desplazamiento 0x48 desde base estaba el tamaño 0x120. Eso fue sobrescrito por 0x23f, por lo tanto el chunk original debe ser de tamaño 0x120:
El origen es la dirección del puntero de 0x23f + 0x2c:

Inicialmente añadió 0x90 pero ahora resta 0x90 de nuevo.
El destino será la dirección del puntero a 0x120 + 0xbc:

Va a escribir en esto:

Todas las escrituras estarán dentro del chunk:

Va a repetir el bucle 3 veces, que es el resultado de la división entera de 0x1b0/0x90:

Después de eso, como el canal ArgChannelHandle es el mismo que se usó cuando se capturó la MappedAddress, el PoC agregará comandos al lote usando NtDCompositionProcessChannelBatchBuffer. Estos serán procesados junto con los que el sistema había agregado. El lote los recoge y luego los comandos se envían todos juntos usando NtDCompositionCommitChannel:


El comando enviado tiene el valor 8, que corresponde a SetResourceIntegerProperty para 4 rastreadores diferentes (1, 2, 3 y 4).
Cuando el PoC regresa a la función main, crea un canal diferente para realizar el HeapSpray.
Agrupa 0x10000 comandos, que se envían con _NtDCompositionCommitChannel:

Esto usa el valor CreateResource=1 y el tipo que corresponde a CHolographicInteropTextureMarshaler = 0x50:

Las asignaciones se realizan en el código siguiente. El tamaño de los objetos creados para hacer el spray es 0x1b0:

Luego realiza un bucle para liberar los objetos creados en el paso anterior y ahora hace agujeros en la distribución de memoria.
La variable counter2 comienza en 0x3000 y suma pasos de 0x20 mientras sea menor que 0x7000:

Escribe 0x41 desde la dirección del chunk que estaba en base + 0x48 + 44 + 0x1b0
Es decir, está escribiendo valores que se usarán más tarde, cuando desborde el chunk adyacente:

Ese pvalue7 está ubicado en la dirección 0x224 desde base:

Luego va a la función “escribe”:

Escribe la pKernelCallbacktable más 0x388, la dirección de LoadLibraryA y la ruta de la DLL que se cargará. En este caso, la llamé s11.dll.

Ahora, se necesita un depurador de kernel para detenerse en la función vulnerable cuando ocurre el desbordamiento del heap. Esto se debe a que el proceso DWM no puede depurarse con un depurador en modo usuario.
Usando IDA PRO para depurar el objetivo de forma remota, establezca un punto de interrupción condicional para que se detenga cuando el tamaño sea igual a 0x1b0:
print ("VALUE1 %x" % ((cpu.rax)))
return cpu.rax==0x1b0.
Dado que se está depurando un programa en modo usuario desde el kernel, es necesario cambiar al contexto del proceso DWM para poner el punto de interrupción. Recargue los símbolos de usuario con:
. reload /user
Recargue los del kernel con:
. reload /f

Se detendrá cuando se ejecute paso a paso ShowWindow:

Asigna con tamaño 0x1b0 y copia con tamaño 0x23f, produciendo el desbordamiento del heap:

En este punto la pila de llamadas se ve así:

Para crear el desbordamiento, el DWM recibe valores en el siguiente código:
Los valores manipulados en base enviados desde mi PoC se leen usando MapViewofFile desde el proceso DWM en el módulo dwmcore.dll:

La función anterior se llama desde:


Cuando se envía usando ALPC desde hook4 usando destination_copy (NtDCompositionCommitChannel) se detiene:

Recuerde que en los comandos de hook4, se agregaron comandos al lote. Sin embargo, el sistema también ya había agregado algunos comandos al lote, incluyendo base y los datos manipulados:



En este caso comparte un área de memoria que comienza en 000001cd'178d0000. Cuando se usa como origen para realizar el memcpy, estará 0x794 bytes más tarde en esa misma área de memoria.
El tamaño del área de memoria compartida es 0x4000:

Se detendrá cuando el tamaño a asignar sea 0x1b0 y alcance el memcpy para copiar 0x23f bytes:

Más allá de 0x1b0 en la memoria está el código que desbordará sobrescribiendo el bloque adyacente:

Cuando los chunks se liberan desde el PoC, termina saltando a LoadLibraryA, que carga la biblioteca manipulada:

Eso viene de aquí:


El Heap spray se hizo con objetos de tamaño 0x1b0 de tipo CHolographicInteropTexture.
Dado que había hecho agujeros en la distribución de memoria, esto libera algunos objetos. Como el bloque que va a desbordar también tiene tamaño 0x1b0, tiene una alta probabilidad de estar ubicado en los agujeros del heap spray.
En el destino del memcpy, los bloques están ubicados cada 0x1b0 bytes:

El puntero a una vftable es sobrescrito por el puntero a LoadLibrary:
Antes de sobrescribir:

Después de sobrescribir:

Recuerde que terminó saltando a [R11+50], que es el puntero a LoadLibraryA.
Ejecutando el PoC, copie la DLL en la misma ruta que figura en el PoC:

Después de ejecutar el PoC, se ejecuta un proceso CMD con privilegios de nivel de integridad del sistema de usuario DWM:

Referencias:
PoC en Fortra GitHub: https://github.com/fortra/CVE-2024-30051
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-30051
https://msrc.microsoft.com/update-guide/en-US/advisory/CVE-2024-30051
Esto completa el PoC. Recuerde que si lo ejecuta muchas veces, el heap permanecerá en un estado inestable, por lo que puede ser necesario reiniciar la máquina para que funcione nuevamente. Además, aunque no siempre funcione en el primer intento, normalmente funcionará correctamente en un segundo o tercer intento. Como puede ver, la ingeniería inversa puede ser difícil, así que si tiene alguna pregunta, puede consultarme.
Mail: [email protected]
X: @ricnar456