prjflt.sys PrjfGetSetStreamContext NULL dereference
A Windows minifilter registers pre- and post-operation callbacks for
the I/O request codes it cares about (create, read, write, set
information, directory control, …). For each request, the Filter
Manager invokes the filter’s callbacks with the FLT_CALLBACK_DATA
for the operation and a pointer to the per-stream context the filter
(or any other filter) has attached to the file stream. ProjFS is a
minifilter of this kind, and almost every pre/post callback it
registers needs its stream context to do anything useful — the context
holds the projection state, the union-context generation used to decide
whether the cached state still matches the file, and the pointers back
to the projection root.
The Filter Manager exposes that state through
FltGetStreamContext(Instance, FileObject, &Context). The function
has three classes of outcome:
STATUS_SUCCESS— a context is attached;*Contextis the attached context object. This is the only success case.STATUS_NOT_FOUND— no context is attached for this (instance, file object) pair.*ContextisNULL. This is the “the file has not been opened through ProjFS yet” case, and the filter has to allocate and attach a context (typically viaFltSetStreamContext).- Any other status — typically
STATUS_FLT_DELETING_OBJECT(the instance or stream context is being torn down),STATUS_FLT_VOLUME_NOT_FOUND,STATUS_FLT_INSTANCE_NOT_FOUND, or the assorted “the object you asked about is going away” codes the Filter Manager returns when the request loses a race with teardown. For all of these, the Filter Manager leaves*Contextat exactly what the caller initialised it to — which, in every ProjFS call site, isNULL.
The third class is the one that bites. The vulnerable code in ProjFS
treats the API as a binary — “I either get STATUS_NOT_FOUND, in
which case there is no context and I must create one, or I get
something else, in which case I have a usable context” — and that
binary is wrong. There is a third outcome, it leaves the out-pointer
NULL, and the very first read of the supposed context dereferences
its Generation field (Context->Generation, /* +0x4 */) with
Context still NULL.
The generation read and the NULL that reaches it
To understand why the first thing the code does with the context is
read its Generation field, you need the union-context pattern.
ProjFS keeps a “generation” counter on each stream context; the
union-context (a shared structure that several related stream
contexts may point at) keeps its own generation. The union context
itself is fetched by copying FileCtx->UnionKey /* +0x60 */ — a
16-byte locator held on the per-file projection context — and handing
that copy to PrjfGetUnionContext, which returns the shared union
context. The pattern is:
StreamCtx // per-stream context (FltGetStreamContext out-pointer)
Flags u32 /* +0x0 */ // non-zero skips the post-set cache purge
Generation u32 /* +0x4 */ // compared against UnionCtx->Generation
UnionCtx // shared union context (PrjfGetUnionContext result)
Generation u32 /* +0x3c */ // the authority
When a callback fetches the stream context, it wants to know “is this
context still the current generation, or has the file been re-projected
under me?”. The check is
UnionCtx->Generation /* +0x3c */ == Context->Generation /* +0x4 */.
The read of Context->Generation happens before any NULL check on
Context, because the author wrote the NULL check a few lines later
(where it guards the “allocate a new context” path) instead of at the
top.
PrjfGetSetStreamContext (vulnerable build, paraphrased):
// PrjfGetSetStreamContext — vulnerable build
PFLT_CONTEXT Context_2 = NULL;
*Out = 0;
...
Status = FltGetStreamContext(Instance, FileObject, &Context_2); // (1) third-class status leaves Context_2 NULL
Context = Context_2;
UnionCtx = PrjfGetUnionContext(Instance, &FileCtx->UnionKey /* +0x60 */);
if (Status != STATUS_NOT_FOUND) { // (2) wrong guard: treats every
// non-NOT_FOUND as "context present"
if (UnionCtx == NULL
|| UnionCtx->Generation /* +0x3c */ == Context->Generation /* +0x4 */) // (3) <-- deref with Context NULL
goto done;
if (Context == NULL) goto create_new; // (4) the NULL check exists,
// but a few lines too late
...
}
The structure is the giveaway. The function has a NULL check on
Context at line (4) — guarding the create_new branch — but the
read at line (3) happens first, with no NULL guard. So when
FltGetStreamContext returns a third-class status and leaves
Context_2 at NULL, the function falls into the Status != STATUS_NOT_FOUND branch (because the third-class status is not
STATUS_NOT_FOUND), then evaluates Context->Generation /* +0x4 */
and reads virtual address 0x4. The same shape — FltGetStreamContext
followed by an if (status != STATUS_NOT_FOUND) that uses the
out-pointer without verifying it is non-NULL — is replicated across ten
more callbacks in the filter. The patch applies the same one-term guard
addition to all of them.
This is a classic filter-manager confusion. The minifilter APIs have a
small handful of “this object is going away” codes for each
context-fetch function, and none of them are STATUS_NOT_FOUND.
STATUS_NOT_FOUND means “the lookup completed and there is no such
context”; the deleting/volume-not-found codes mean “the lookup could
not complete because the underlying object is being torn down”. The
correct positive guard is if (NT_SUCCESS(Status) && Context != NULL);
the negative guard if (Status != STATUS_NOT_FOUND) is correct only if
STATUS_SUCCESS is the only other outcome, which it is not. The reason
the bug survives review is that the third class is uncommon: a callback
running on a normally-open file gets STATUS_SUCCESS almost every
time, and gets STATUS_NOT_FOUND for files that have not been opened
through the filter yet. The third class only shows up when a callback
loses a race with stream or instance teardown — which is exactly when a
defensive check matters most, because that is the window an attacker
(or just unlucky timing from user mode) can deliberately target.
Why it is a bugcheck and not an elevation
The primitive is a kernel NULL-pointer dereference at a fixed virtual
address (0x4). The address is not user-mappable on modern Windows —
page 0 is reserved in the kernel and not user-mappable — so an attacker
cannot turn this into “dereference a pointer to attacker-controlled
content”. The result is a deterministic kernel bugcheck.
That makes the impact local denial of service, not elevation of privilege. An unprivileged user can crash the machine by triggering the race (any I/O on a projected file that lands in one of the eleven callbacks during the right teardown window will do it), but cannot escalate. The bug is still patched as a security issue because reliable local DoS from an unprivileged user is not acceptable on a multi-user or server host.
The post is worth a deep read despite the limited impact because the pattern generalises to filters and middleboxes where the out-pointer is not NULL on a low page — for instance, when the out-parameter is initialised to a non-NULL sentinel, or when the same confused guard protects an out-pointer that the API does set to a valid-looking but stale value on the third-class statuses. The same shape, in a slightly different setting, is an arbitrary-deref.
The patch — short-circuit on NULL before the read
The patch threads one extra term — Context == NULL — into the guard,
so the function bails to cleanup instead of dereferencing:
// PrjfGetSetStreamContext — patched
Status = FltGetStreamContext(Instance, FileObject, &Context);
if (Status != STATUS_NOT_FOUND) {
if (UnionCtx == NULL
|| Context == NULL // (1) short-circuit before the read
|| UnionCtx->Generation /* +0x3c */ == Context->Generation /* +0x4 */)
goto done; // (2) bail to cleanup
...
}
The same one-line conceptual change — || Context == NULL added to the
predicate that previously read through Context — is replicated across
all eleven callbacks. The deeper fix would be to invert the guard to
if (NT_SUCCESS(Status) && Context != NULL), which is robust against
any future status the Filter Manager might add; the incremental
one-term addition matches the existing style and is what the patch
ships.
Attack path
sequenceDiagram
participant U as user I or O on projected file
participant K as Prjf callback FltGetStreamContext
participant C as stream Context stays NULL
U->>K: callback runs while instance or stream teardown races the lookup
K->>C: FltGetStreamContext returns a third-class status, Context stays NULL
K->>C: status is not STATUS_NOT_FOUND, falls into the use branch
K->>C: generation check reads the Generation field with Context NULL
Note over C: page 0 reserved, kernel bugcheck, local DoS
The trigger is any ProjFS I/O that lands in one of the eleven callbacks
while FltGetStreamContext returns a third-class status — most
plausibly while the stream or instance is being torn down (a delete or
unmount racing the query). It is a bugcheck, not an elevation: address
0x4 is not user-mappable on modern Windows (page 0 is reserved), so
the read always faults into the kernel and there is no controlled
pointer to plant.
“Not the success case” is not “the not-found case”. Treating if (status != NOT_FOUND) as “we have a context” is a classic
filter-manager mistake: the minifilter APIs have a handful of status
values for “this object is going away”, each of them leaves
out-pointers untouched, and the correct guard is the positive one —
if (NT_SUCCESS(status) && Context != NULL). The NULL check that
exists but is on the wrong line is a strong audit signal: when you see
if (ptr == NULL) goto X a few lines after a use of ptr, the
author knew NULL was possible and still read it first. And pervasive
single-line fixes mean the broken pattern was the house style — eleven
callbacks with the identical one-term guard addition says the
assumption was copied everywhere a stream context is fetched, so grep
for that same shape in the unfixed neighbours too.