win32kfull.sys HDEV_DeleteRfontsAndUnloadDeviceFonts EUDC rfont double free

RFONT (“realised font”) is the session-pool object that holds a font rasterised for one particular device/format combination. Each PDEV (physical device) keeps its inactive realised fonts on a doubly linked list headed at prfntInactive, so an inactive RFONT can be re-used without re-realising the font; End-User-Defined Character (EUDC) fonts attach private realised-font state per session on top of that, and their realised fonts sit on the same inactive list. The list is normally manipulated under the two device-fonts locks (SEMOBJ<17> and SEMOBJ<18> on gpresPDev’s semaphore pool at +0x1308), and HDEV_DeleteRfontsAndUnloadDeviceFonts is the HDEV-invalidation path that drains it when the last reference to a PDEV goes away.

The vulnerable build drains the list with an inline loop that holds neither lock at the walk level:

/* HDEV_DeleteRfontsAndUnloadDeviceFonts — vulnerable build */
eudc           = ctx->eudcState;                 /* ctx   + 0x08 : captured, then zeroed       */
ctx->eudcState = 0;
hdev           = arg4;                           /* the owning PDEV                            */
PushThreadGuardedObject(&guard, vUnreferencePdevWorker);

while (true) {
    head  = PDEVOBJ::prfntInactive(hdev);        /* &hdev->prfntInactive                       */
    entry = head->Flink;                         /* first node — no device-font lock held      */
    if (entry == head)
        break;
    rfont = CONTAINING_RECORD(entry, RFONT, leInactive);  /* entry - 0x2A0 (leInactive @ RFONT+0x2A0) */
    pff   = rfont->pPFF;                                  /* rfont + 0x80                       */
    RFONTOBJ::vDeleteRFONT(rfont, hdev, pff, eudc);       /* hdev != NULL → vDeleteRFONT
                                                           unlinks the node itself            */
}
PopThreadGuardedObject(&guard);

Two properties of that loop do the damage. The walk reads prfntInactive->Flink every iteration with no device-font lock held, so the shared list is mutable underneath it for the whole loop body. And RFONTOBJ::vDeleteRFONT is the internally-locking kind of deleter: it takes SEMOBJ<18> itself, partway through teardown, to unlink the node from leInactive (+0x2A0) and free the RFONT pool allocation. An unlocked head read followed by a per-node deleter that grabs the lock on its own is only safe if nothing else touches the list in between — and something does: the EUDC unload family, reachable from any standard user session through the plain gdi32 exports EnableEUDC, EudcLoadLinkW and EudcUnloadLinkW (backed by win32u’s NtGdiEnableEudc and NtGdiEudcLoadUnloadLink, no privilege needed). Both syscalls funnel into bUnloadEudcFont, which deletes the realised EUDC fonts in two steps, in reconstructed form:

/* bUnloadEudcFont — EUDC teardown of realised EUDC fonts */
{
    SEMOBJ<17> lk17(gpresPDev);              /* the same device-fonts locks    */
    SEMOBJ<18> lk18(gpresPDev);
    prfntDeactivateEudcRFONTs(hdev, &local); /* unlink the EUDC RFONTs from
                                                prfntInactive and splice them
                                                onto a caller-local list       */
}                                            /* both locks released here       */
vKillEudcRFONTS(&local);                     /* free each moved RFONT (per node:
                                                RFONTOBJ::vDeleteRFONT)         */

That is a move, then free: under both locks the EUDC RFONTs leave prfntInactive and land on a list private to the unloading thread; the locks drop; the free happens afterwards on the private copy. Run against the unlocked walk, this shape breaks in two distinct ways.

In the simpler interleaving, the teardown thread reads the head and picks node E, and before it gets into vDeleteRFONT the EUDC thread runs both steps to completion: E leaves prfntInactive, and vKillEudcRFONTS frees it. The teardown thread’s vDeleteRFONT then runs its neighbour check, unlinks, and frees a node that is already session-pool free — every one of those touches lands on freed memory.

The second interleaving is worse, because it survives a sanity check. It needs the timing to land inside the EUDC thread’s locked section:

sequenceDiagram
    participant T1 as HDEV_DeleteRfontsAndUnloadDeviceFonts
    participant T2 as EUDC unload (EnableEUDC / EudcUnloadLinkW)
    participant L as prfntInactive (sem17/18)
    participant E as EUDC RFONT node
    T1->>L: read head->Flink, pick E — no lock held
    T2->>L: take sem17+sem18
    T2->>E: prfntDeactivateEudcRFONTs moves E onto T2's local list
    T1->>T1: blocks acquiring sem18 inside vDeleteRFONT
    T2->>L: release both locks (E alive on the local list)
    T1->>E: wakes with sem18: neighbour check reads E's links on the local list — self-consistent, passes
    T1->>E: unlink E from the wrong list, free E
    T2->>E: vKillEudcRFONTS frees E again
    Note over E: double free on the RFONT allocation

The teardown thread reads E, then blocks acquiring SEMOBJ<18> inside vDeleteRFONT because the EUDC thread holds it mid-move. The EUDC thread finishes the move and releases the lock — E is alive, but it now sits on the EUDC thread’s local list. The teardown thread wakes holding sem18 and runs vDeleteRFONT’s neighbour mutual-link check, which asks whether the node’s Flink/Blink point back at it. That check reads E’s current links — and E’s current links are its links on the EUDC local list, which are perfectly self-consistent. The check passes. vDeleteRFONT unlinks E from the wrong list and frees it, and when the EUDC thread’s vKillEudcRFONTS reaches E it deletes the node a second time: the same session-pool allocation is freed twice. A neighbour mutual-link check proves local self-consistency, not that the node sits in the container the caller expects.

The second deleter path is not hypothetical — a breakpoint at win32kfull!RFONTOBJ::vDeleteRFONT+0x11e, right on the neighbour back-link compare (cmp qword ptr [rdx+8], rax), catches it with the EUDC-side stack in flight, straight from a user-process EnableEUDC call (condensed to the interesting frames):

eudc_unique_pdev.exe
  win32u!NtGdiEnableEudc
  win32kfull!NtGdiEnableEudc+0x9
  win32kfull!GreEnableEUDC+0xa7
  win32kfull!bDeleteAllFLEntry+0x1da
  win32kfull!bUnloadEudcFont+0x135
  win32kfull!vKillEudcRFONTS+0x73
  win32kfull!RFONTOBJ::vDeleteRFONT+0x11e      ← neighbour-link compare

WinDbg stopped at win32kfull!RFONTOBJ::vDeleteRFONT+0x11e on the neighbour mutual-link compare; the stack shows the EUDC-side delete path from user-mode EnableEUDC through vKillEudcRFONTS down to vDeleteRFONT.

The patch

The fix lands in 10.0.26100.9168 (KB5121003, released 2026-08-11; CVE not yet identified); 10.0.26100.8875 (July’s KB5101650) and earlier carry the same old code, and the offsets quoted here are the amd64 ones. It is collect-then-delete. The patched HDEV_DeleteRfontsAndUnloadDeviceFonts gates the inline loop off and delegates to DeleteInactiveRFONTsCollectAndDelete(&hdev, eudc), which takes both device-fonts locks, detaches the whole prfntInactive list (and each node’s per-PFF link) under those locks, releases them, and only then tears each node down lock-free on the private copy:

/* HDEV_DeleteRfontsAndUnloadDeviceFonts — patched build */
eudc           = ctx->eudcState;                 /* ctx   + 0x08 : captured, then zeroed       */
ctx->eudcState = 0;
hdev           = arg4;
PushThreadGuardedObject(&guard, vUnreferencePdevWorker);

DeleteInactiveRFONTsCollectAndDelete(&hdev, eudc);   /* replaces the inline loop */
PopThreadGuardedObject(&guard);

/* DeleteInactiveRFONTsCollectAndDelete — the collect-then-delete helper */
{
    SEMOBJ<17> lk17(gpresPDev);                  /* gpresPDev + 0x1308 : device-fonts lock 17  */
    SEMOBJ<18> lk18(gpresPDev);                  /*                       device-fonts lock 18  */
    head = PDEVOBJ::prfntInactive(hdev);
    if (head->Flink != head) {
        local.Flink = head->Flink;               /* splice the whole list onto a local head    */
        local.Blink = head->Blink;
        local.Flink->Blink = &local;
        local.Blink->Flink = &local;
        head->Flink = head;                      /* PDEV list now empty                        */
        head->Blink = head;
        for (node = local.Flink; node != &local; node = node->Flink)
            RemoveEntryList(&node->lePff);       /* per-PFF RFONT link, lePff @ RFONT+0x1E8    */
        PDEVOBJ::cInactive(hdev, 0);             /* zero the inactive-RFONT count              */
    }
    lk18.vUnlock();
    lk17.vUnlock();
}
/* walk the LOCAL copy lock-free; vDeleteRFONT gets NULL hdev so its own
   prfntInactive-unlink branch is dead code, and the PFF ref is dropped by hand */
for (node = local.Flink; node != &local; node = next) {
    next  = node->Flink;
    rfont = CONTAINING_RECORD(node, RFONT, leInactive);  /* node - 0x2A0 (leInactive @ RFONT+0x2A0) */
    pff   = rfont->pPFF;                                 /* rfont + 0x80                       */
    PushThreadGuardedObject(&rguard, vRestartDeleteInactiveRFONTs);
    RFONTOBJ::vDeleteRFONT(rfont, NULL, NULL, eudc);     /* NULL hdev, NULL pff               */
    PopThreadGuardedObject(&rguard);
    PFFOBJ::vDeleteRFONTRef(pff);
}

The destructive destructor (vDeleteRFONT, including the unlocked window before it takes lock 18) still runs — the patch does not try to narrow that window. Instead it makes the window harmless: by the time the destructor runs, the node has already been detached from every list a racer can reach, so no concurrent thread can move it or enlist it. Passing NULL for the HDEV on top of that turns vDeleteRFONT’s own prfntInactive-unlink into dead code, so the destructor cannot re-touch the shared list even by accident.

One wrinkle: in 26100.9168 the new path sits behind the servicing feature Feature_Servicing_FixEudcRfontRace and is disabled by default at runtime. The check is a small per-feature function called ahead of the loop; it reads a cached feature-state dword and tests two bits — bit 4 says the state is resolved, bit 0 is the enabled flag the gate returns. The observed state 0x56 decodes as resolved but disabled, so the gate returns zero and the old unlocked walk runs as the literal fall-through of the feature check: out of the box, the patched binary still executes the vulnerable code.

Reproducing it

Reaching the teardown path at all has one quiet precondition: a default CreateDC reuses a cached PDEV, so the reference count never drops to zero and vUnreferencePdevWorker — the guarded-object worker that reaches HDEV_DeleteRfontsAndUnloadDeviceFonts — never runs. The way in is printer DCs with a unique DEVMODE per creation: pick a printer (EnumPrintersW, falling back to “Microsoft Print to PDF”), seed one DEVMODE through DocumentPropertiesW, and vary paper length/width, y-resolution, print quality, scale and orientation per iteration, so every CreateDCW allocates a fresh PDEV and the matching DeleteDC drops the last reference and runs the guarded teardown.

To give the two racers something to fight over, the DC thread realises fonts and parks them on the inactive list before deleting the DC: select a font, TextOutW private-use codepoints (U+E000-range, the EUDC range) plus CJK text, then deselect the font so the realised fonts move to prfntInactive rather than dying with the DC. The EUDC side hammers the unload family from three threads — EnableEUDC(TRUE), EudcLoadLinkW(NULL/typeface, arial.ttf), EudcUnloadLinkW, and every other iteration EnableEUDC(FALSE) — against four DC threads looping create/realise/delete. The harness banner confirms the entry points resolved, the printer picked, and the seeded DEVMODE in play:

Console output of the eudc_unique_pdev harness: the startup banner with the resolved gdi32 EUDC entry points (EnableEUDC, EudcLoadLinkW, EudcUnloadLinkW), the selected printer, the seed DEVMODE, and the running progress counters.

Full source: poc.c (10 KB, plain C, no dependencies beyond gdi32/user32/winspool).