win32kfull.sys xxxDrawMenuItemText stale item text pointer UAF

xxxDrawMenuItemText draws the text of a single menu item. Its arguments are the menu (wrapped in a SmartObjStackRef<tagMENU>), the tagITEM* being rendered, the device context, layout coordinates, the item’s text pointer (wchar_t* pszText, handed in by the caller), the character count, and a flags word. The helper snapshots one piece of item state at entry — the qword at item /* +0x58 */ — and then runs a two-stage render in which the string is touched through two different pointers.

The first stage is size-dependent. For cch < 0xFF the stripped copy lands in a stack buffer; for longer text the helper allocates a kernel copy up front (Win32AllocPoolZInit((cch+1)*2, 'Usrt'), locked into the thread’s lock list). Either way GetPrefixCount(pszText, cch, copy, cch) reads the original caller pointer — that is read number one — and produces the accelerator-stripped copy that everything downstream renders from. The interesting turn comes next: CALL_LPK(PtiCurrent()). When the desktop’s complex-script state says the LPK shaping stack is active, the helper hands the copy to xxxClientExtTextOutW, which packs it into a callback message (UNICODE_STRING-style length at +0x28, string data from +0x30, the DC at +0x38) and drops into user mode via KeUserModeCallback(0x50). The xxx prefix marks functions that leave the win32k critical region; while the kernel thread is over in user mode, the same thread can touch the very menu it is rendering. Note what the callback consumes: the kernel-made copy, captured before the leave — not pszText.

After the callback the vulnerable build re-validates before touching the item again:

/* xxxDrawMenuItemText — vulnerable build, post-callback guard chain */
xxxClientExtTextOutW(hdc, x, y, 0, NULL, copy, cchRemaining);

tagMENU *menu = menuRef->pMenu;                             /* +0x10 */
if (menu == NULL)
    menu = *menuRef->pMenuRoot;                             /* +0x0 */

if (MNIspItemValid(menu, item) != 0
    && capturedField == item->__offset(0x58))               /* entry snapshot */
    goto draw;                                              /* proceed */
/* else: straight to cleanup — no render, no retry */

draw:
if (CALL_LPK(PtiCurrent()) != 0)
    xxxPSMTextOut(hdc, x, y, pszText, cch, 0x200000);       /* the ORIGINAL pointer */

The hole the old checks left

Both conjuncts read item fields, and neither field tracks the text buffer. That is not a guess — on a pre-patch build, with a live draw suspended inside the 0x50 callback, replacing the item’s text through either ModifyMenu or SetMenuItemInfo(MIIM_STRING) leaves both checks green: the item still answers to MNIspItemValid, and the +0x58 snapshot still reads back equal. The item’s text pointer itself lives at a different offset (+0x18 is what the caller loads and passes as pszText), and the swap that frees the old buffer never disturbs the field the guard compares. MNIspItemValid answers “is this item still in this menu”; the snapshot answers “did this one qword change”. The question that matters — “is the pointer I am about to dereference still the item’s live string?” — is asked by neither.

What the resumed path does with pszText depends on the session’s render configuration. The chain between the guard and the render reads the global session state (+0x10650 bit 0x20000, +0x10654 bit 0x20 and its sign, GetAppCompatFlags2(0x400) bit 1) before committing to the direct xxxPSMTextOut(pszText, ...); the callback render that already happened used the pre-captured copy, so this second render is the one that dereferences the original pointer after the re-entry window. That is read number two, and it is the read the August fix exists to protect. During the callback the user-mode side can replace the item’s text — anything that reallocates the item’s text buffer — and by the time xxxPSMTextOut walks the string, pszText names pool that the replace freed and another allocation of that size class may already own.

Getting there

Reaching the LPK branch of this helper on a modern build has real preconditions, each of which bites quietly when missed. TrackPopupMenuEx popups do not reach it at all: MNIsOwnerDrawItem classifies them as UAH menus (a session-wide capability gate plus a 0x800 menu flag), which routes item rendering through xxxSendMenuDrawItemMessage instead of the legacy draw chain. A window’s menu bar does reach it, via WM_NCPAINT → xxxMenuBarDraw → xxxMenuDraw → xxxDrawMenuItem → xxxDrawState → xxxRealDrawMenuItem → xxxDrawMenuItemText. The CALL_LPK gate reads the process’s LPK state through the thread, and in practice it is armed by activating a complex-script keyboard layout — merely rendering Arabic text does not arm it, and the state does not survive a session restart. And because an item renders once and then blits from a cached bitmap (MNIsCachedBmpOnly), the text path only re-runs when something changes the item; a static menu never comes back through here. The caller xxxRealDrawMenuItem makes the same bet twice — it loads the text pointer it will pass as pszText, runs its own text-measure callbacks in between, and re-checks the item with the same two field-reading tests before committing.

The patch

The fix adds one conjunct that asks the missing question. The post-callback guard becomes:

/* xxxDrawMenuItemText — patched build */
if (MNIspItemValid(menu, item) != 0
    && MNIspItemStringUnchanged(item, pszText, capturedField) != 0)
    goto draw;                                              /* pszText still live */
/* else: fall through to cleanup, do not render */

MNIspItemStringUnchanged(item, pszText, capturedField) takes the item, the caller’s text pointer, and the entry snapshot, and answers whether pszText is genuinely still the item’s live string — the pointer that is about to be dereferenced, compared against the item’s current text, not a field that happens to have been sitting next to it. When the text was freed or replaced during the callback it returns zero, the guard fails, and the helper bails out instead of rendering through a dangling pointer.

Attack path

sequenceDiagram
    participant U as Attacker window messages
    participant K as xxxDrawMenuItemText
    participant S as ITEM text buffer
    K->>S: GetPrefixCount reads pszText (read 1, live)
    K->>S: kernel copies the text for the callback
    K->>U: xxxClientExtTextOutW leaves the critical region
    U->>S: replace frees the item's text buffer
    K->>S: item still valid, +0x58 unchanged, guards pass
    K->>S: xxxPSMTextOut dereferences stale pszText (read 2)
    Note over S: UAF on freed item text pool

The dangling pointer names freed session pool that any process in the session can re-shape through other win32k allocations, so the read turns into a controlled disclosure. The shape to remember is a win32k-specific one: the xxx prefix is the rule, and a field-reading re-check after the callback is only as good as the field it reads. When the value being consumed downstream — here the caller’s pszText — is not the same thing as the field being re-checked, the re-validation guards the wrong thing, and the stale pointer sails straight through it. The fix does not add strength to the existing checks; it points one of them at the pointer that actually gets dereferenced.