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:

  1. STATUS_SUCCESS — a context is attached; *Context is the attached context object. This is the only success case.
  2. STATUS_NOT_FOUND — no context is attached for this (instance, file object) pair. *Context is NULL. This is the “the file has not been opened through ProjFS yet” case, and the filter has to allocate and attach a context (typically via FltSetStreamContext).
  3. 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 *Context at exactly what the caller initialised it to — which, in every ProjFS call site, is NULL.

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.