- Affected binary:
win32kfull.sys10.0.19041.7417 → 10.0.19041.7548 - CVE: CVE-2026-49798
- Patch date: 2026-07-14
- CVSS: 9.3
- Class: Use-after-free
- Patch KB: KB5099444 and related
TL;DR
win32kfull.sys translates keyboard accelerators against a window’s menu inside xxxTA_AccelerateMenu. Before dispatching the accelerator it must notify the owner window with WM_INITMENU and WM_INITMENUPOPUP, and those notifications re-enter user mode via xxxSendTransformableMessageTimeout. Pre-patch, the function computed a pointer into the menu’s item array (menu->rgItems + 0x60*index) and cached the menu object before those callouts, then dereferenced both after them. A malicious window procedure can DestroyMenu during the callback, freeing the tagMENU and its item array, so the post-callout dereferences operate on freed session-pool memory — a classic UAF. The July patch, gated by Feature_3330796859__private_IsEnabledDeviceUsage(), re-reads the item-array base from the re-validated menu after each callout and routes MNLookUpItem through the live SmartObjStackRef<tagMENU> accessor instead of a stale raw pointer. The result is a local elevation of privilege to SYSTEM.
Background
Windows menus live in the kernel as tagMENU objects allocated from the win32k session pool. Each tagMENU carries an item array — a flat array of tagITEM records, 0x60 bytes each — reachable through the menu’s rgItems pointer at offset 0x58, with the current item count stored near offset 0x2c of the structure at +0x28. When a user presses an accelerator key, the input path calls xxxTranslateAccelerator, which eventually reaches xxxTA_AccelerateMenu to resolve the keystroke against the top-level menu bar and any relevant popup.
The critical detail is that menu processing is interactive: before a menu or submenu is used, Win32k gives the owning application a chance to configure it. That is what WM_INITMENU (0x116) and WM_INITMENUPOPUP (0x117) are for. xxxTA_AccelerateMenu sends both through xxxSendTransformableMessageTimeout, which crosses back into user mode to run the target window’s WndProc. Because the target window is owned by whatever process created it, the message handler is attacker-controlled code running synchronously in the middle of the kernel routine.
Newer win32k code wraps menu objects in SmartObjStackRef<tagMENU>, a stack-scoped smart reference that holds a counted reference to the menu and exposes an operator tagMENU* accessor that re-resolves the live object. The whole point of that wrapper is to survive re-entrancy: a reference taken through the smart pointer keeps the object valid (or lets the code re-derive it) across a callout. The bug is that the pre-patch code took the smart reference but still cached raw derived pointers around the dangerous window messages.
Root Cause
Walk the pre-patch flow. After UT_FindTopLevelMenuIndex yields a valid top-level index uVar3, the function sends WM_INITMENU:
xxxSendTransformableMessageTimeout(param_1,0x116,*puVar9,0,0,0,0,1,1);
if (uVar3 < *(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c)) {
plVar8 = (longlong *)
((longlong)(int)uVar3 * 0x60 + *(longlong *)(**(longlong **)param_2 + 0x58));
puVar9 = (undefined8 *)plVar8[2];
Here plVar8 is captured once: menu->rgItems + 0x60*uVar3, the address of the selected tagITEM. The bound check against the item count (+0x2c) is done, correctly, after the WM_INITMENU callout — so far so good. But then plVar8 is frozen. The code reads the submenu handle from it (plVar8[2], i.e. item offset +0x10) and sends the second, more dangerous notification:
pHVar1 = (HMENU__ *)*puVar9;
*param_4 = pHVar1;
xxxSendTransformableMessageTimeout(param_1,0x117,pHVar1,uVar3,0,0,0,1,1);
if (*(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c) <= uVar3)
goto LAB_0;
uVar3 = *(uint *)(*plVar8 + 4) & 3;
WM_INITMENUPOPUP runs the owner’s WndProc. If that handler destroys or replaces the menu, the tagMENU and its item array are freed back to the session pool. The count re-check after the callout guards uVar3 against a shrunken menu, but it does not re-derive plVar8 — the very next line dereferences the stale item pointer: *(uint *)(*plVar8 + 4) & 3 reads the item flags out of freed memory. plVar8 still points at the old rgItems base plus 0x60*uVar3, which no longer belongs to a live menu.
The damage continues into the lookup. The code then computes the menu pointer for MNLookUpItem from the raw fields cached before the callouts:
lVar5 = *(longlong *)(param_2 + 0x10);
if (lVar5 == 0) {
lVar5 = **(longlong **)param_2;
}
plVar8 = (longlong *)MNLookUpItem(lVar5,param_3,0,&local_38);
lVar5 is a bare tagMENU* pulled out of param_2’s cached slot — not re-resolved through the smart reference’s accessor. MNLookUpItem then walks that potentially-freed menu, and its returned item pointer is dereferenced again (*plVar8 + 4) to compute the final return code. So there are two distinct stale dereferences after the user-mode transition: the frozen item pointer and the raw menu pointer handed to MNLookUpItem.
The Patch
The fix restructures everything that touches menu memory after a callout so that nothing captured before a user-mode transition is reused. It is gated by Feature_3330796859__private_IsEnabledDeviceUsage(), which per the CFR killswitch convention returns non-zero (enabled) by default — so the safe path is the one that actually runs in the wild.
First, the item pointer is no longer frozen before WM_INITMENUPOPUP. The submenu handle is now fetched by re-reading rgItems fresh and indexing inline, rather than through a long-lived plVar8:
lVar8 = *(longlong *)(**(longlong **)param_2 + 0x58);
puVar9 = *(undefined8 **)((longlong)(int)uVar3 * 0x60 + 0x10 + lVar8);
After the WM_INITMENUPOPUP callout and the count re-check, the item-array base is re-read from the (revalidated) menu before the flags are pulled:
xxxSendTransformableMessageTimeout(param_1,0x117,pHVar1,uVar3,0,0,0,1,1);
if (*(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c) <= uVar3)
goto LAB_0;
iVar4 = Feature_3330796859__private_IsEnabledDeviceUsage();
if (iVar4 != 0) {
lVar8 = *(longlong *)(**(longlong **)param_2 + 0x58); // re-read rgItems base
}
uVar3 = *(uint *)(*(longlong *)((longlong)(int)uVar3 * 0x60 + lVar8) + 4) & 3;
With the feature enabled, lVar8 is refreshed from menu->rgItems (offset 0x58 off the object at **param_2) after the callback, so the flags read at +0x60*uVar3 + 4 come from the live item array. The bound check at +0x2c guarantees uVar3 is still in range for the current item count. The frozen-plVar8 dereference of the pre-patch code is gone entirely.
Second, the MNLookUpItem call is routed through the smart reference’s live accessor instead of a raw cached pointer:
else { // Feature enabled
ptVar5 = SmartObjStackRef<tagMENU>::operator_struct_tagMENU____ptr64(param_2);
plVar6 = (longlong *)MNLookUpItem(ptVar5,param_3,0,0);
}
SmartObjStackRef<tagMENU>::operator tagMENU* re-derives the live menu from the counted reference param_2 at the moment of use, rather than trusting *(param_2 + 0x10) cached earlier. Note also the fourth argument to MNLookUpItem is now 0 (NULL) rather than &local_38: the stale submenu out-reference that the old code populated (local_38/local_20, then fed back into the SmartObjStackRef via operator=) is dropped. The disabled branch preserves the legacy behavior — it still initializes the local SmartObjStackRef, resolves both operands through the accessor, and passes &local_48 — so a rollback of the flag reverts to the old logic path, but the default (enabled) path never carries a pre-callout pointer forward.
The net structural change: every menu memory access after a xxxSendTransformableMessageTimeout now either re-reads the base from the object graph rooted at param_2 or re-resolves the object through the smart reference. There is no longer a window where a pointer computed before a user-mode transition is dereferenced after it.
Exploitability
The attack surface is fully reachable by any process that can create a window — no special privileges (PR:N in the CVSS 9.3 vector). The attacker registers a window class with its own WndProc, creates a window that owns a menu with at least one popup item, and arranges for xxxTranslateAccelerator → xxxTA_AccelerateMenu to run against it (an accelerator table plus the matching keystroke, or synthesized input to the owned window). Because xxxSendTransformableMessageTimeout dispatches WM_INITMENUPOPUP synchronously back into the attacker’s WndProc, the adversary gets a controlled execution point inside the kernel routine, exactly where the MSRC FAQ’s “while a menu is being processed” language points.
Inside that handler the attacker calls DestroyMenu (or reassigns the window’s menu, freeing the old one) to release the tagMENU and its 0x60-byte-per-item array back into the session desktop heap / pool. Control returns to xxxTA_AccelerateMenu, which then reads item flags through the stale plVar8 and walks the freed menu in MNLookUpItem. Both are dereferences of freed session-pool memory whose contents the attacker controls: classic Win32k UAF grooming applies — reclaim the freed allocation with a same-sized object whose fields you shape (menu items, window text, or other predictable session-pool sprays), and the read of *(item + 4) & 3 plus the MNLookUpItem traversal become primitives you steer. From a controlled tagMENU/tagITEM reuse, the well-worn path is to convert the type-confused read/traversal into an arbitrary kernel read/write and overwrite a token pointer or a _EPROCESS privilege field for SYSTEM.
The “high complexity” flavor is only the timing: the free must happen during the WM_INITMENUPOPUP callout and the reclaim must land before the post-callout dereference. But since the callout is synchronous and single-threaded with respect to this code path, the attacker owns the exact instruction boundary — this is a deterministic re-entrancy bug, not a probabilistic race, which is why it rates 9.3 despite the complexity of the surrounding menu machinery.
Pre patch functions
Full decompilation of xxxTA_AccelerateMenu before the patch:
/* unsigned int __cdecl xxxTA_AccelerateMenu(struct tagWND * __ptr64 const,class
SmartObjStackRef<struct tagMENU> const & __ptr64,unsigned int,struct HMENU__ * __ptr64 * __ptr64
const) */
uint __cdecl
xxxTA_AccelerateMenu
(tagWND *param_1,SmartObjStackRef<tagMENU> *param_2,uint param_3,HMENU__ **param_4)
{
HMENU__ *pHVar1;
bool bVar2;
uint uVar3;
SmartObjStackRef<tagMENU> *pSVar4;
longlong lVar5;
int iVar6;
uint uVar7;
longlong *plVar8;
undefined8 *puVar9;
tagMENU *local_38;
undefined8 *local_30 [2];
tagMENU *local_20;
iVar6 = 0;
SmartObjStackRefBase<tagMENU>::Init((SmartObjStackRefBase<tagMENU> *)local_30,(tagMENU *)0x0);
local_20 = (tagMENU *)0x0;
local_38 = (tagMENU *)0x0;
pSVar4 = param_2;
bVar2 = SmartObjStackRef<tagMENU>::operator==(param_2,iVar6);
if (!bVar2) {
uVar3 = UT_FindTopLevelMenuIndex(pSVar4,param_3);
if (uVar3 != 0xffffffff) {
puVar9 = *(undefined8 **)(param_2 + 0x10);
uVar7 = 2;
if (puVar9 == (undefined8 *)0x0) {
puVar9 = (undefined8 *)**(longlong **)param_2;
}
LOCK();
glSendMessage = glSendMessage + 1;
UNLOCK();
xxxSendTransformableMessageTimeout(param_1,0x116,*puVar9,0,0,0,0,1,1);
if (uVar3 < *(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c)) {
plVar8 = (longlong *)
((longlong)(int)uVar3 * 0x60 + *(longlong *)(**(longlong **)param_2 + 0x58));
puVar9 = (undefined8 *)plVar8[2];
if (puVar9 == (undefined8 *)0x0) {
uVar3 = 0;
}
else {
pHVar1 = (HMENU__ *)*puVar9;
*param_4 = pHVar1;
LOCK();
glSendMessage = glSendMessage + 1;
UNLOCK();
xxxSendTransformableMessageTimeout(param_1,0x117,pHVar1,uVar3,0,0,0,1,1);
if (*(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c) <= uVar3)
goto LAB_0;
uVar3 = *(uint *)(*plVar8 + 4) & 3;
}
local_38 = local_20;
if (local_20 == (tagMENU *)0x0) {
local_38 = (tagMENU *)*local_30[0];
}
lVar5 = *(longlong *)(param_2 + 0x10);
if (lVar5 == 0) {
lVar5 = **(longlong **)param_2;
}
plVar8 = (longlong *)MNLookUpItem(lVar5,param_3,0,&local_38);
SmartObjStackRefBase<tagMENU>::operator=((SmartObjStackRefBase<tagMENU> *)local_30,local_38)
;
if (plVar8 != (longlong *)0x0) {
if (((*(uint *)(*plVar8 + 4) & 3) != 0) || (uVar3 != 0)) {
uVar7 = 3;
}
goto LAB_1;
}
}
}
}
LAB_0:
*param_4 = (HMENU__ *)0x0;
uVar7 = 0;
LAB_1:
SmartObjStackRefBase<tagMENU>::~SmartObjStackRefBase<tagMENU>
((SmartObjStackRefBase<tagMENU> *)local_30);
return uVar7;
}
Post patch functions
Full decompilation of xxxTA_AccelerateMenu after the patch:
/* unsigned int __cdecl xxxTA_AccelerateMenu(struct tagWND * __ptr64 const,class
SmartObjStackRef<struct tagMENU> const & __ptr64,unsigned int,struct HMENU__ * __ptr64 * __ptr64
const) */
uint __cdecl
xxxTA_AccelerateMenu
(tagWND *param_1,SmartObjStackRef<tagMENU> *param_2,uint param_3,HMENU__ **param_4)
{
HMENU__ *pHVar1;
bool bVar2;
uint uVar3;
int iVar4;
tagMENU *ptVar5;
longlong *plVar6;
SmartObjStackRef<tagMENU> *pSVar7;
longlong lVar8;
undefined8 *puVar9;
tagMENU *local_48;
SmartObjStackRefBase<tagMENU> local_40 [16];
undefined8 local_30;
pSVar7 = param_2;
uVar3 = param_3;
bVar2 = SmartObjStackRef<tagMENU>::operator==(param_2,(int)param_2);
if ((!bVar2) && (uVar3 = UT_FindTopLevelMenuIndex(pSVar7,uVar3), uVar3 != 0xffffffff)) {
puVar9 = *(undefined8 **)(param_2 + 0x10);
if (puVar9 == (undefined8 *)0x0) {
puVar9 = (undefined8 *)**(longlong **)param_2;
}
LOCK();
glSendMessage = glSendMessage + 1;
UNLOCK();
xxxSendTransformableMessageTimeout(param_1,0x116,*puVar9,0,0,0,0,1,1);
if (uVar3 < *(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c)) {
lVar8 = *(longlong *)(**(longlong **)param_2 + 0x58);
puVar9 = *(undefined8 **)((longlong)(int)uVar3 * 0x60 + 0x10 + lVar8);
if (puVar9 == (undefined8 *)0x0) {
uVar3 = 0;
}
else {
pHVar1 = (HMENU__ *)*puVar9;
*param_4 = pHVar1;
LOCK();
glSendMessage = glSendMessage + 1;
UNLOCK();
xxxSendTransformableMessageTimeout(param_1,0x117,pHVar1,uVar3,0,0,0,1,1);
if (*(uint *)(*(longlong *)(**(longlong **)param_2 + 0x28) + 0x2c) <= uVar3)
goto LAB_0;
iVar4 = Feature_3330796859__private_IsEnabledDeviceUsage();
if (iVar4 != 0) {
lVar8 = *(longlong *)(**(longlong **)param_2 + 0x58);
}
uVar3 = *(uint *)(*(longlong *)((longlong)(int)uVar3 * 0x60 + lVar8) + 4) & 3;
}
iVar4 = Feature_3330796859__private_IsEnabledDeviceUsage();
if (iVar4 == 0) {
SmartObjStackRefBase<tagMENU>::Init(local_40,(tagMENU *)0x0);
local_30 = 0;
local_48 = SmartObjStackRef<tagMENU>::operator_struct_tagMENU____ptr64
((SmartObjStackRef<tagMENU> *)local_40);
ptVar5 = SmartObjStackRef<tagMENU>::operator_struct_tagMENU____ptr64(param_2);
plVar6 = (longlong *)MNLookUpItem(ptVar5,param_3,0,&local_48);
local_30 = 0;
SmartObjStackRefBase<tagMENU>::operator=(local_40,local_48);
SmartObjStackRefBase<tagMENU>::~SmartObjStackRefBase<tagMENU>(local_40);
}
else {
ptVar5 = SmartObjStackRef<tagMENU>::operator_struct_tagMENU____ptr64(param_2);
plVar6 = (longlong *)MNLookUpItem(ptVar5,param_3,0,0);
}
if (plVar6 != (longlong *)0x0) {
if (((*(uint *)(*plVar6 + 4) & 3) == 0) && (uVar3 == 0)) {
return 2;
}
return 3;
}
}
}
LAB_0:
*param_4 = (HMENU__ *)0x0;
return 0;
}