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

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:

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