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.