win32kfull.sys PFFOBJ pPvtDataMatch type confusion
A font file opened into a win32k session is represented by a PFF
(Public Font File) object; an individual face from that file is a PFE
(Public Font Entry). Both can carry a singly linked list of private
data nodes (PvtData) for state that is specific to one owner rather
than global to the font — a cache, a realization, a reference counter, a
context for one transaction. The list head lives on the font object
(pPvtData /* PFF + 0x98 */); each node carries an owner key (thread id
or process id) plus a category-discriminator bit in node->Flags /* +0x8 */ (bit 0x4). There are two categories, and they have different
downstream layouts: a node of one category is interpreted through a
different set of fields than a node of the other.
PFFOBJ::pPvtDataMatch is the lookup helper that walks the list and
returns the node “belonging to this caller.” The invariant it must
uphold is therefore twofold: the node’s owner must be the current
caller, and the node’s category must be the one the caller asked for.
The vulnerable build has no way to express the second half — the
function takes no category argument, the category bit gates only the
thread-id comparison, and the process-id comparison runs against every
node irrespective of category, so whatever matches first is returned:
/* PFFOBJ::pPvtDataMatch -- vulnerable build: no caller-supplied category */
node = this->pPFF->pPvtData; /* PFF + 0x98 */
while (node != NULL) {
/* the node's OWN category bit gates only the thread-id test */
if ((node->Flags /* +0x8 */ & 0x4) != 0) {
/* thread-private node: owner slot holds a thread id */
if (node->Owner /* +0xc */ == PsGetCurrentThreadId()) {
break;
}
}
/* the process-id test runs against EVERY node, whatever its category */
if (node->Owner /* +0xc */ == (PsGetCurrentProcessId() & 0xfffffffc)) {
break;
}
node = node->Next; /* +0x10 */
}
return node; /* first owner match, whatever category it carries */
The caller’s intent — “I want a category-A node” or “I want a category-B node” — never enters the function. Thread ids and process ids are drawn from the same client-id numbering, so the unconditional process-id comparison can latch onto a thread-private node whose owner happens to collide with the current process id, and the symmetric case is only a caller-driven lookup away. Whatever the caller is about to do with the result, the walk hands back the first node whose owner matches, irrespective of its category. A list-writer wrote a category-B node; the list-reader reads it as a category-A node: that is a type confusion in the strictest sense. Field offsets that name one thing in layout A name another in layout B, so the caller’s reads and writes land on whatever those bytes happen to hold — bytes the attacker seeded.
The bug is reachable from any thread in the same session that can install a font with private data. Per-process font installation is the normal GDI path, not a privileged operation, so an attacker running in a standard user session can seed a wrong-category node onto the list and then trigger a lookup that the helper will resolve to that node as the other kind. The node lives in session pool and is fully attacker-shaped, so the wrong-layout read returns a controllable pointer and the wrong- layout write lands on a chosen field — the canonical win32k font LPE primitive.
The patch
The fix threads a selector argument through every caller. The caller now states which category it wants; each owner-key test is gated on both the caller’s requested category and the node’s own category bit, so the walk only succeeds when the two agree:
/* PFFOBJ::pPvtDataMatch -- patched: selector carries the caller's category tag */
if ((selector == NULL) || ((*selector & 0x4) != 0)) {
want_thread = 1; /* accept thread-private (bit 0x4 set) nodes */
} else {
want_thread = 0;
}
if ((selector == NULL) || ((*selector & 0x4) == 0)) {
want_process = 1; /* accept process-private (bit 0x4 clear) nodes */
} else {
want_process = 0;
}
node = this->pPFF->pPvtData; /* PFF + 0x98 */
while (node != NULL) {
/* thread path: caller wants thread-private AND node is thread-private */
if ((want_thread != 0) && ((node->Flags /* +0x8 */ & 0x4) != 0)) {
if (node->Owner /* +0xc */ == PsGetCurrentThreadId()) {
break;
}
}
/* process path: caller wants process-private AND node is process-private */
if ((want_process != 0) && ((node->Flags /* +0x8 */ & 0x4) == 0)) {
if (node->Owner /* +0xc */ == (PsGetCurrentProcessId() & 0xfffffffc)) {
break;
}
}
node = node->Next; /* +0x10 */
}
return node;
A NULL selector means the caller has no preference between the two
categories — both the thread-private and process-private tests stay
armed. But each test is still gated on the node’s own category bit, so a
thread-private node is never matched by the process-id comparison and
vice versa; the cross-category collision stays fixed even when no
preference is expressed. A non-NULL selector carries the same 0x4 bit
the node itself uses, so the caller’s requested kind and the node’s
carried kind must agree before the node is returned. Adding a selector
argument to an existing lookup is the signature of this fix class — when
a patch changes a function’s arity and threads a new “which kind”
parameter through every caller, the old code was conflating kinds.
Attack path
flowchart TD
A["attacker installs a font in the session, seeds a category-B PvtData node"] --> B["caller asks for category-A PvtData, enters PFFOBJ pPvtDataMatch"]
B --> C["node Owner matches, no caller category filter applied, walk returns the wrong kind"]
C --> D["category-B node handed to a category-A caller"]
D --> E["caller dereferences category-A offsets on category-B memory, attacker-shaped reads and writes in session pool to EoP"]
Font install and PvtData manipulation are reachable from any standard user session — the GDI/font surfaces are the historical core of win32k LPE. The shape to remember is that when a list holds items of several kinds, the lookup must filter on kind, not just on the human-facing key: the owner id was the obvious match key and the category bit was treated as a “detail”, but the category determines the layout the caller will use, so an unfiltered match is a type confusion waiting to fire. PvtData, PFF and PFE entries are all keyed by tid or pid and all live in session pool, so every “find the entry for this owner” helper in the font stack is worth auditing for a missing secondary discriminator.