win32kfull.sys TrySmuggleHidData interlisted back-pointer UAF

Each GUI thread carries a THREADINFO block with per-thread input state, including the raw-input HID-data queue. An outstanding HIDDATA request records its owning thread at HIDDATA->OwnerThread /* +0x10 */, and that owner’s THREADINFO in turn holds a “current node” back-pointer at pQueuedHidData /* +0x478 */ plus the per-thread SList head at HidDataSList /* +0x480 */. When a thread exits with an outstanding HID request, TrySmuggleHidData migrates the node onto a destination thread’s SList so the request is not lost.

The vulnerable build relocates the node and then clears pQueuedHidData on the requesting thread (arg1, the caller) instead of on the owning thread (node->OwnerThread). The owner’s back-pointer is the one that actually names this node, so it is the one that has to go; clearing the caller’s field is a no-op against the dangling reference, and the recorded owner is left pointing at a node that has been pushed onto another thread’s SList.

/* TrySmuggleHidData — vulnerable build (smuggle branch) */
OwningThread = node->OwnerThread;                          /* +0x10 */
if (DstThread != OwningThread) {
    if ((node->Flags /* +0x30 */ & 1) == 0 && RequestThread == OwningThread)
        FlushPostedRawInput(RequestThread);
    node = UnlinkHidData(RequestThread, node, NULL);
    if (node == NULL || (node->Flags /* +0x30 */ & 1) == 0)
        MicrosoftTelemetryAssertTriggeredArgsKM("IXPTellMeIf", 0x20000, 0xbc7);
    HMChangeOwnerThread(node, DstThread);
    node->Flags = (node->Flags & ~1) | 2;                  /* +0x30 */
    ExpInterlockedPushEntrySList(&DstThread->HidDataSList, /* +0x480 */
                                 &node->SListEntry);       /* +0x20 */
    if (RequestThread->pQueuedHidData /* +0x478 */ == node)
        RequestThread->pQueuedHidData = NULL;
    /* OwningThread->pQueuedHidData left dangling — the UAF */
}

HMChangeOwnerThread rewrites the node’s OwnerThread to the destination and the interlocked push links it onto the destination’s HidDataSList, but neither action touches the back-pointer that the previous owner still holds at pQueuedHidData /* +0x478 */. That previous owner can later walk its pQueuedHidData, follow the pointer into a node that now lives on another thread’s SList, and either race the destination’s pop or treat the moved node through the wrong state.

The patch

The fix replaces the caller’s clear with a clear of the owning thread’s pQueuedHidData — the thread the node actually records as its owner:

/* TrySmuggleHidData — patched (smuggle branch) */
ExpInterlockedPushEntrySList(&DstThread->HidDataSList, /* +0x480 */
                             &node->SListEntry);        /* +0x20 */
if (OwningThread->pQueuedHidData /* +0x478 */ == node)
    OwningThread->pQueuedHidData = NULL;

The relocation now invalidates the back-pointer that genuinely names the node, so no owner is left following a stale reference into another thread’s SList. The thread that has to be updated is the one the node itself names at OwnerThread; clearing a back-pointer on whichever thread happened to initiate the call leaves the recorded owner pointing at a node it no longer owns.

Attack path

sequenceDiagram
    participant U as OwningThread (user mode)
    participant K as TrySmuggleHidData
    participant H as HIDDATA node
    K->>H: push onto DstThread HidDataSList at plus 0x480, set node OwnerThread to DstThread
    K->>H: clear pQueuedHidData on RequestThread only, leave OwningThread stale at plus 0x478
    U->>H: OwningThread later dereferences its pQueuedHidData back-pointer
    Note over H: pointer names a node now on another thread SList, UAF to EoP

Raw-input HID-data attach across thread exit is reachable from any standard user session. The broader shape is that an interlocked operation on a node is not an interlocked operation on its owners: ExpInterlockedPushEntrySList relocates the node atomically, but every back-pointer elsewhere that names the node is unchanged by the push, and each of those pointers is a stale reference until something else clears it. The owner recorded inside the object is the pointer that has to be cleared; acting on the caller’s field instead is the assumption that leaves the real owner following the node into its new location.