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.