win32kfull.sys xxxDDETrackPostHook DDE handle UAF
Dynamic Data Exchange (DDE) is the legacy IPC mechanism layered on the
WM_DDE_* family of window messages. A DDE conversation between two
windows is tracked in the kernel as a DDECONV object — one per
(client-window, server-window) pair — recording the conversation state,
the handles involved, and the tracking hooks fired on each WM_DDE_*
dispatch. The DDECONV object is exposed through the win32k handle table:
every user-facing kernel object (window TYPE_WINDOW, DC, font, menu,
DDECONV TYPE_DDECONV = 0xa) is denoted by an HWIDGET, and the table
entry (HANDLEENTRY) records the type, the actual kernel object
pointer, the owning thread/process, and a flag byte at
HE->bFlags /* +0x19 */. Bit 0x01 of that byte is HANDLEF_DESTROY
— “this entry is being torn down” — and once the destroy finalises the
table can recycle the slot.
The fifteen DDE-track callbacks all run under the xxx prefix, which in
win32k means the function can leave the critical region and call back
into user mode — dispatch a window message, run a hook, fire a
notification. That callback window is the load-bearing detail. The
vulnerable build uses a raw bit-peek on the handle entry as the
freshness check before dispatching through the DDECONV pointer:
// xxxDDETrackPostHook and 14 siblings — vulnerable build
// conv was resolved by FindDdeConv, thread-locked, its pending freelist flushed
HANDLEENTRY *he = _HMPheFromObject(conv); // reverse-lookup the HE by address
bool alive = (he->bFlags /* +0x19 */ & HANDLEF_DESTROY /* 0x1 */) == 0;
if (!alive) {
result = 1; // destroy pending — skip dispatch
} else if (msg == WM_DDE_EXECUTE /* 0x3e1 */ ||
(conv->Flags /* +0x50 */ & 0xe) == 0) {
struct DDE_CALLBACK *cb = conv->pCallback /* +0x38 */; // dispatch through conv
if (cb != NULL)
result = cb->pfnPost /* +0x20 */(msg, data, conv);
else if ((conv->Flags /* +0x50 */ & 0x1) == 0)
result = xxxUnexpectedClientPost(msg, data, conv);
else
result = xxxUnexpectedServerPost(msg, data, conv);
}
The bit only describes the entry’s state at the instant it was read — and the
entry it reads is not even fixed. _HMPheFromObject walks the table by object
address, so once the pool slot behind conv has been recycled for a new
object the lookup returns that new occupant’s entry, whose destroy bit is
clear because the new object is alive. The check passes against the wrong
entry. The user-mode side drives that recycle: close the conversation, let
the destroy finalise, let the slot be re-handed to a brand-new object,
possibly of a different type. The kernel-side pointer the helper is still
holding now names memory that no longer belongs to its conversation, and
dispatch runs through it anyway. Two flavours of damage fall out: a pure
UAF when the slot has not been reused yet and conv points at freed pool,
or the stronger slot-reuse / type-confusion primitive when the slot has
been re-handed to a new object whose bytes dispatch then interprets as
DDECONV fields.
WM_DDE_* traffic is drivable from any user session — DDE IPC needs no
elevation, just two windows the attacker controls — and the handle slot
is per-process, so the attacker has direct control over what allocation
wins the recycled slot. UAF-via-handle-recycle in win32k is the
canonical elevation primitive. Fifteen callbacks share the identical
bad guard, which is the module telling you the shape was copied
everywhere rather than slipping in once.
The patch
The fix replaces the bit-peek with an authoritative re-resolution. The handle is walked fresh through the table, type-checked, and the result is compared to the pointer the callback is holding:
// xxxDDETrackPostHook and 14 siblings — patched
// only the freshness check changes; the dispatch body is identical
bool alive = HMValidateHandleNoSecure(conv->hHead /* +0x0 */,
TYPE_DDECONV /* 0xa */) == conv;
if (!alive) {
result = 1; // handle no longer resolves to conv
} else if (msg == WM_DDE_EXECUTE /* 0x3e1 */ ||
(conv->Flags /* +0x50 */ & 0xe) == 0) {
struct DDE_CALLBACK *cb = conv->pCallback /* +0x38 */; // dispatch body unchanged
if (cb != NULL)
result = cb->pfnPost /* +0x20 */(msg, data, conv);
else if ((conv->Flags /* +0x50 */ & 0x1) == 0)
result = xxxUnexpectedClientPost(msg, data, conv);
else
result = xxxUnexpectedServerPost(msg, data, conv);
}
HMValidateHandleNoSecure walks the handle table at the moment of the
call and returns the object the handle currently denotes, type-checked
against TYPE_DDECONV. If the handle was freed and the slot reused, the
returned pointer differs from conv; if the slot was re-handed to a
non-DDECONV, the type check fails and the function returns NULL.
Either way dispatch is skipped. The same re-resolution landed in all
fifteen callbacks at once.
Attack path
sequenceDiagram
participant U as Attacker WM_DDE traffic
participant K as win32k DDE callback
participant H as DDECONV object
K->>H: _HMPheFromObject(conv) reads HE destroy bit, clear, holds conv
U->>H: close DDECONV handle, free conv, slot recycled by a new object
K->>H: re-lookup still returns an alive HE, dispatch through stale conv
Note over H: UAF if pool idle, type-confusion if slot reused
The shape to remember is that a raw handle-table bit is not a freshness
check: reading the destroy flag (or any cached flag) off an entry tells
you what was true when you looked, and nothing about the gap between the
check and the use. The authoritative operation is to re-resolve the
handle to an object and compare it to the pointer you are holding —
if they differ, the handle no longer names your object. xxx callbacks
in win32k must re-validate handles after every window of re-entry,
because any object whose handle the user can close during that callback
is a UAF candidate, and a one-line fix repeated across double-digit
functions is the module telling you the bad guard was copied everywhere
— grep the module’s other xxx* callbacks for the same raw-bit shape
before the next patch lands.