Share
## https://sploitus.com/exploit?id=0136FBFD-5983-5F81-959C-30B22846A9AA
# CVE-2026-50416: a kernel pointer on every desktop, readable from any sandbox

## TL;DR

On Windows 11 Insider build 10.0.28020.2149, the user mode mapping of the win32k desktop heap hands out a raw kernel session pool pointer at offset 0x100. Any process that has a desktop can read it. That includes Low integrity processes, AppContainers, LPAC with zero capabilities, and a Low integrity AppContainer that looks exactly like a browser renderer. From that single leaked QWORD you can recover the kernel desktop heap base, and from there, with help from `gSharedInfo`, compute the exact kernel virtual address of every window, menu and class object on the desktop.

This is a KASLR bypass, an information disclosure. It is not, by itself, code execution. I want to say that up front because overselling a bug is the fastest way to make a writeup unreadable. What it is, is a very clean leak from places it really should not reach.

CVE: [CVE-2026-50416](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-50416). Reported to MSRC. This post documents the bug, the proof of concept, and the part that made me actually sit up: the sandbox boundaries across which the kernel pointer remains exposed.

## The bug in one breath

Every process that joins a desktop gets the desktop heap mapped read only into its address space. The kernel writes window and menu objects into this heap and user mode reads them back, which is normal and necessary. What is not necessary is that offset 0x100 of that mapping contains a live kernel pointer into session pool. Nobody sanitizes it before it becomes visible to user mode. Windows already knows how to sanitize kernel pointers in this heap, it uses a `0x6000000000` sentinel for other fields. This one just got missed.

## A little background, because the trick does not make sense without it

win32k is the kernel component that owns windows, menus, cursors, hooks, the whole graphical object world. A lot of win32k state lives per desktop in a structure called the desktop heap. To make window operations fast, the kernel maps a chunk of this heap into every process that joins the desktop, as a read only shared section. Your process reads a window's text or style straight out of this mapping without a syscall. That mapping is the shared illusion that lets the GUI feel instant.

Finding it from user mode is trivial and documented. The TEB has a `ClientInfo` structure. The desktop heap user mode address sits in `ClientInfo[5]`. In code:

```c
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID* clientInfo = (PVOID*)((BYTE*)teb + 0x800);
BYTE* desktopHeap = (BYTE*)clientInfo[5];
```

That is the whole handle. No vulnerability yet. This part is by design.

The vulnerability is what is sitting at offset 0x100 of that heap.

## The leak

```c
ULONG64 leaked = *(ULONG64*)(desktopHeap + 0x100);
```

On the tested build that QWORD holds something like `0xFFFFA804DDE00040`. That is a canonical kernel address. It is eight byte aligned. It points into session pool, 0x40 bytes into the kernel side of this very same desktop heap.

I went through the usual sanity checks before believing it.

1. Canonical high bits (top 17 bits set). Yes.
2. Eight byte aligned. Yes.
3. Stable across creating and destroying a pile of windows. Yes, identical before, during and after.
4. Identical across unrelated processes on the same desktop. Yes.
5. Different after a reboot. Yes.

So it is a real, persistent, boot session wide kernel address, not garbage and not a stale value. Properties four and five together are the fingerprint of a KASLR randomized address that is constant within one boot. That is exactly the thing KASLR is supposed to hide.

For the curious, the value in my actual test session was `0xFFFFC600DCC00040`. Same shape, same behavior. Yours will differ because KASLR. That is sort of the point.

## From one pointer to the address of everything

A single leaked address is nice. Turning it into the address of any specific object is where it gets useful.

Two facts do the work.

First, the leaked value points 0x40 bytes into the kernel desktop heap. So the kernel desktop heap base is the leaked value minus 0x40.

```
kernel desktop heap base = leaked minus 0x40
```

Second, user32 exports a structure called `gSharedInfo`. Among other things it hands you the global handle table (`aheList`) and the size of one handle table entry (`HeEntrySize`, 0x20 on x64). Every HWND is really an index into that table. Take the low 16 bits of an HWND, walk to that entry, and the first QWORD of the entry is the offset of that window object inside the desktop heap.

Put them together:

```
offset      = gSharedInfo.aheList[HWND & 0xFFFF].offset
kernel addr = kernel desktop heap base plus offset
```

That is the exact kernel virtual address of the window object backing a given HWND. Not a guess. Not a spray. The actual address.

The proof of concept creates six windows of different classes (STATIC, BUTTON, EDIT, LISTBOX, SCROLLBAR, COMBOBOX) and resolves all six. It also scans the heap and finds another half dozen unsanitized kernel pointers beyond 0x100, so 0x100 is just the most reliable one, not the only one.

## The sandbox angle, which is the real headline

Here is the part that bumped this from interesting to genuinely concerning.

I wrote a second program that spawns child processes in progressively nastier contexts and asks each one whether it can read the same pointer. The contexts, in order of how little they are supposed to be able to do:

1. Medium integrity, standard user. The baseline. Leaks.
2. Low integrity. Leaks.
3. AppContainer, zero capabilities. Leaks.
4. LPAC, Less Privileged AppContainer, zero capabilities. Leaks.
5. Low integrity plus AppContainer, zero capabilities. This is the profile of a Chromium renderer. Leaks.
6. A different desktop entirely, created with `CreateDesktop`. Leaks, with a different value because it has its own desktop heap.

All of contexts one through five returned the same kernel address, because they share the default desktop heap. Context six returned a different address because it is a different heap, but the technique worked identically.

The fifth one is the one to stare at. A process running at Low integrity, inside an AppContainer, holding zero capabilities, is about as locked down as a user mode process gets on Windows. That is the sandbox a browser puts its renderer in. That sandbox can read a kernel address.

There is a reason this stings. The entire defense in depth model for browsers assumes that even if an attacker gets code execution inside the renderer through a separate engine bug, the sandbox contains the damage and hides the kernel. KASLR is a big part of that hiding. This leak reaches inside the sandbox and hands the attacker a kernel address with no extra syscall and no privilege escalation. The sandbox did its job perfectly. The information leaked out from under it anyway, through a shared section that the sandbox model treats as benign.

## You do not even need a window

I kept waiting for the catch. Surely you have to create a window first, or call some GUI syscall that the sandbox would notice. No.

On this build the desktop heap mapping is already present in the process address space before `user32.dll` is even loaded. The proof reads `desktop_heap[0x100]` before and after loading user32, and both reads return the same kernel pointer. No `CreateWindow`. No `GetDesktopWindow`. Nothing. Any process with a desktop association, including headless services and background workers, can read it the instant it starts.

The renderer proof leans on this. It launches a child at Low integrity in an AppContainer with no capabilities, has it load user32 and nothing else, and it reads the pointer immediately on start. Verbatim output:

```
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
```

`IL=0x1000` is Low integrity. `AC=1` is inside an AppContainer. `NoWindowCreated` is exactly what it sounds like.

## A bonus that is almost a separate bug

While I was in there I pointed a scanner at the heap to see what else was lying around. It is more than pointers.

Window titles of other processes. The scanner found twenty unique titles from other processes sitting in the heap in UTF16, each one cross checked against `EnumWindows` to confirm the owning PID actually owned a window with that title. Chrome tabs, Discord, Explorer, Spotify, tray windows. The heap is a little bulletin board of what everyone on the desktop is doing, and any sandboxed process can read it without a single IPC.

Process IDs. Over six hundred DWORD values in the heap were verified to be real running PIDs, each confirmed two ways, by `OpenProcess` succeeding and by the PID appearing in the `EnumWindows` list. So a sandboxed process can enumerate who has windows on the desktop, silently.

More kernel pointers. Six to ten per run, varying with desktop activity, not just the one at 0x100.

One thing it does NOT expose, which I checked on purpose: password edit control text. I created an EDIT with `ES_PASSWORD`, set its text to `SecretPassword123`, and searched the heap. Not there. Windows masks password field content in the desktop heap. Good. Small mercy.

So the heap leaks addresses and window titles, but at least it keeps your passwords. I will take the win where I can find it.

## Why a leak this clean is worth caring about

KASLR exists to make kernel exploitation probabilistic. Without address knowledge, a win32k use after free turns into blind pool spraying. You flood the kernel allocator, hope your replacement object lands where the dangling pointer points, and pray. On modern pool implementations that success rate is low and getting lower.

Now hand the attacker this leak. They know the exact kernel address of the freed window object. They can craft a replacement allocation at exactly that address and control precisely what the dangling pointer references. Blind corruption becomes deterministic. That is the real value of a clean KASLR bypass, it is the reliability multiplier on an unrelated memory corruption bug, not a bug of its own.

The mitigations this leak erodes, concretely:

* KASLR for the win32k session pool. Without the leak, the attacker guesses roughly 44 bits of entropy. With it, zero guessing.
* Pool randomization. The attacker reads `gSharedInfo` to learn exact offsets of every window object, then computes kernel addresses directly.
* Object isolation. The attacker knows which kernel address maps to which HWND, so they can target a chosen object for corruption instead of spraying and hoping.

Objects whose kernel address falls out of this single read: window objects (tagWND), menu objects (tagMENU), class objects (tagCLS), and anything else on the desktop heap reachable through the handle table. One read, whole desktop mapped.

## How to reproduce

The PoC directory has a `compile.bat` that finds Visual Studio and builds whichever target you pick.

```
compile.bat        then choose a number from the menu
```

The targets, in order of how much they show you:

* `kaslr_bypass_poc.exe`. The main one. Steps through the leak, computes the kernel base, resolves live window addresses, scans for extra pointers, and prints the full chain. Run this first.
* `kaslr_sandbox_proof.exe`. Spawns children at Low IL, in an AppContainer, in an LPAC, at Low IL plus AppContainer, and on an alternate desktop, then reports whether each leaked and whether they all agree.
* `supporting_proof_no_window.exe`. Proves the leak works before any window exists.
* `supporting_proof_no_caps_lpac.exe`. Proves it from AppContainer and LPAC with zero capabilities.
* `supporting_proof_sensitive_data.exe`. Proves the cross process data exposure, titles and PIDs, and that password text is not exposed.
* `supporting_proof_exploitability.exe`. Resolves kernel addresses for six window classes and shows the mitigation defeat.
* `supporting_proof_remote_trigger.exe`. The renderer equivalent context.

Three quick checks to convince yourself it is real:

1. Run the main PoC twice in the same boot. Same leaked pointer both times.
2. Run it from two terminals, two different processes. Same pointer.
3. Reboot. Run it again. Different pointer.

One and two confirm it is a stable, shared, real address. Three confirms it is KASLR randomized. Together they are the bug.

## Expected versus actual

**Expected.** The user mode desktop heap mapping should never expose raw kernel pointers. Any kernel pointer in desktop heap metadata should be sanitized before user mode can see it.

**Actual.** Offset 0x100 exposes a kernel session pool pointer. Every process with a desktop reads it, including Low integrity, AppContainer, and LPAC processes. In the tested session every context returned `0xFFFFC600DCC00040`.

## What a fix looks like

The cheapest fix matches what Windows already does elsewhere in this heap. Sanitize kernel pointers in the desktop heap header before they reach the user mode mapping, the same `0x6000000000` sentinel treatment used for other fields.

The stronger fix, if user mode does not actually need the header page, is to stop exposing that page through the user mode mapping at all. I do not have a strong opinion on which Microsoft picks. I have a strong opinion that a raw kernel pointer should not be one `__readgsqword` and one pointer dereference away from a zero capability renderer.

## Disclosure

Reported to MSRC, CVE-2026-50416 assigned. Target: `win32kfull.sys`, desktop heap handling. Tested on Windows 11 Insider build 10.0.28020.2149, standard user, medium integrity, with confirmed reach from Low IL, AppContainer, and LPAC contexts.

## Closing

I keep coming back to context five. A Low integrity AppContainer with no capabilities is supposed to be the box nothing interesting escapes from. The desktop heap mapping is the kind of shared resource the sandbox model long ago decided was safe to leave mapped, because it is read only and because reading window text is harmless. Both of those things are still true. What changed is that one header field turned that harmless read only window into a peephole straight into session pool.

The sandbox did not fail. The assumption underneath the sandbox did. Those are different failures, and the second one is harder to see coming, which is probably why nobody caught it. Until you read offset 0x100.