prjflt.sys PrjfDeleteTombstoneIfExists stream context UAF

ProjFS (prjflt.sys, the Projected File System) is a minifilter that virtualises a directory tree: files in the projection exist as “placeholder” entries whose contents are fetched on demand from a user-mode provider. The provider supplies the data when a user first reads the file; the file then sits in the projection as a normal cached file. When the user deletes a file inside the projection, the filter has to remember “this file was deleted by the user, do not re-project it on the next enumeration” — otherwise the user’s delete would appear to silently undo itself the next time the projection was walked. That remembering is what a tombstone is: an in-memory record, keyed by the destination path, that suppresses re-projection.

Tombstones are kept on in-memory lists in the filter, and they outlive any single I/O request. A rename that lands on a tombstoned file, an enumeration that walks past one, a cleanup that drops one — each of these is a different I/O path, on a different thread, possibly minutes apart, all reading the same tombstone structures. Anything the tombstone stores as a pointer to kernel state has to outlive all of those readers.

The other piece of context is the minifilter stream context. The Filter Manager lets each minifilter attach per-stream state to a file stream via FltAllocateContext / FltSetStreamContext. The context is reference-counted: FltAllocateContext returns it with one reference; the Filter Manager holds its own reference while the context is attached to a live stream; FltReferenceContext adds a reference; FltReleaseContext drops one. When the last reference goes away — typically when the Filter Manager’s own reference is dropped as the stream is torn down — the context object is freed. The contract every minifilter has to honour is: if you store a PFLT_CONTEXT pointer anywhere outside the call stack — in a list, in another object, in a tombstone — you must hold your own reference for that storage, taken before you store the pointer, released when you stop using it. A stored context pointer that has no matching FltReferenceContext is a dangling pointer waiting for the Filter Manager to drop its reference.

Tombstone storage without a reference

The bug is exactly that pattern in PrjfDeleteTombstoneIfExists. The function stores a stream-context pointer in an in-memory tombstone, then triggers the very event (FileDispositionInfo delete) that lets the Filter Manager drop its own reference, without ever taking a reference of its own for the tombstone.

/* PrjfDeleteTombstoneIfExists -- vulnerable build (servicing flag off) */
PFLT_CONTEXT Context = NULL;
FILE_STANDARD_INFORMATION CtxStdInfo;      /* out-param from PrjfGetSetFileContext */
FILE_STANDARD_INFORMATION QryStdInfo;      /* out-param from FltQueryInformationFile */
FILE_DISPOSITION_INFORMATION Disposition;
ULONG                       Returned;

Status = PrjfGetSetFileContext(Instance, FileObject,
                               /*Set=*/1, /*OldCtx=*/NULL,
                               &CtxStdInfo, &InfoClassOut, &FlagOut,
                               &Context);                /* (1) Context borrowed from FM */
if (Status < 0) goto cleanup;

Status = FltQueryInformationFile(Instance, FileObject,
                                 &QryStdInfo, sizeof(QryStdInfo), /* 0x18 bytes */
                                 FileStandardInformation, &Returned);
if (Status < 0) goto cleanup;

r8 = FlagByte;                                       /* tombstone flag byte from a stack slot */
Status = PrjfAddInMemoryTombstone(Instance, Path,    /* (2) tombstone copies the Context pointer */
                                  r8, NULL, Sync, &Context); /* no FltReferenceContext for it */
if (Status < 0) goto cleanup;

Disposition.DeletePending = TRUE;
Status = FltSetInformationFile(Instance, FileObject, /* (3) FileDispositionInformation, TRUE */
                               &Disposition, sizeof(UCHAR),
                               FileDispositionInformation);   /* delete may let FM drop its ref */

cleanup:
    if (FileHandle) FltClose(FileHandle);
    if (FileObject) ObfDereferenceObject(FileObject);
    if (Context)    FltReleaseContext(Context);       /* (4) only the function's own ref */
    return Status;

The tombstone from step (2) now owns a pointer to Context, but the function never bumped the reference count for the tombstone’s storage. The function’s own FltReleaseContext in step (4) releases the reference that PrjfGetSetFileContext gave it; that reference was for this function’s use of the context, not for the tombstone. The mistake is easy to miss on a casual read because the function does call FltReleaseContext — but it releases only the reference it acquired for itself. The tombstone is a different consumer, with a different lifetime, and needs its own reference.

The trigger that makes the missing reference actually hurt is step (3). Setting FileDispositionInfo = TRUE marks the file for delete. The actual teardown of the file object and its stream context happens later — when the last user handle closes, the Filter Manager walks its detach list and drops its own reference to the stream context. If that drop brings the refcount to zero, the context object is freed. The tombstone’s pointer is now stale. That asynchronous, deferred-free shape is exactly why this kind of bug survives review: the code “works” because nothing in this function observes the free. The free happens later, on another thread, and the next reader of the tombstone — rename completion, enumeration, cleanup — finds it pointing at freed pool.

There are at least three reachable readers of the stale tombstone in the same rename path:

  1. Rename completion. The rename operation that called PrjfDeleteTombstoneIfExists continues after this function returns, and the completion routine walks the tombstones it recorded to finalise the rename. If the delete has torn the context down between the call and the completion, the completion dereferences a freed pointer.
  2. Enumeration. A directory enumeration that hits the same path reads the tombstone to suppress re-projection. The enumeration can happen on a different thread, after the rename has finished but while the tombstone is still on the in-memory list.
  3. Cleanup. The tombstone cleanup path itself, which walks the list when a projection is detached, follows the stored context pointer.

Why this is elevation-grade

The primitive is a use-after-free in kernel pool. The freed object is a Filter Manager stream context, a reasonably sized allocation with vtable-like and pointer fields the reader follows; the reader is ProjFS code running in kernel mode, so a stale dereference is a kernel pool access to attacker-influenced content. The classic local elevation shape applies: drive the free, shape what the allocator reuses the slot for (the replacement object’s content is influenced by what the attacker does between the free and the read), then trigger the read so the reader follows a planted pointer. Whether this is winnable for a full arbitrary-read / arbitrary-write primitive depends on allocator timing and on how controllable the replacement content is; the existence of the bug is not in doubt, and tombstones with stale context pointers are not something a vendor can leave unfixed.

Reachability is local and unprivileged. ProjFS is an optional Windows component (the Client-ProjFS feature), but once it is on, any unprivileged user can create or use a projection (developer worktrees, container virtualisation roots, third-party providers) and rename files inside it. Renaming onto a tombstoned file is the only trigger required; the rest of the UAF is the kernel following the pointer on a later, ordinary I/O.

The patch — take the reference before the store, hand it to the caller

The patch adds a FltReferenceContext before the tombstone store, and hands that extra reference to the rename caller through a new out-parameter so the caller can release it once the rename is safely done with the tombstone:

/* PrjfDeleteTombstoneIfExists -- patched (servicing flag on, paraphrased) */
*OutContext = NULL;                                       /* clear the caller's slot up front */

PrjfGetSetFileContext(Instance, FileObject, ...,&Context); /* (1) Context refcount = N */
FltQueryInformationFile(...);

if (Context != NULL && OutContext != NULL) {
    FltReferenceContext(Context);                          /* (2) +1 for the tombstone/caller */
    Context2 = Context;
}
r8 = FlagByte;
PrjfAddInMemoryTombstone(Instance, Path,                  /* (3) tombstone stores the Context */
                         r8, NULL, Sync, &Context);       /*     backed by our extra ref now */

*OutContext = Context2;                                   /* (4) hand the ref to the rename caller */
Context2     = NULL;                                      /* (5) ownership transferred */

Disposition.DeletePending = TRUE;
FltSetInformationFile(Instance, FileObject,               /* (6) delete may drop FM's own ref */
                      &Disposition, sizeof(UCHAR),
                      FileDispositionInformation);
...
cleanup:
    if (Context)  FltReleaseContext(Context);              /* (7) our own ref from step (1)    */
    if (Context2) FltReleaseContext(Context2);             /* (8) only if step (5) never ran   */

The caller (PrjfPopulateDestinationForRenameOrLink) threads that reference through its own rename context (an OutHeldContext field /* +0x58 */) so the rename completion path can release it once it is safely done with the tombstone.

The same fix closes the matching hole in the four other rename/tombstone helpers that participate in the same delete-then-still-use sequence: PrjfPrepareDestinationForRenameOrLink, PrjfPopulateDestinationForRenameOrLink, PrjfReleaseRenameOrLinkCompletionContext, and PrjfCleanUpRenameLinkTargetTombstone. Each of them either stored a context pointer without reference, or relied on a caller’s reference that the caller was not guaranteed to hold for the tombstone’s lifetime; all of them now either take the reference before the store or explicitly transfer ownership along the rename chain.

Attack path

sequenceDiagram
    participant U as Attacker rename in projection
    participant F as prjflt PrjfDeleteTombstoneIfExists
    participant C as stream Context
    U->>F: rename onto tombstoned file
    F->>C: store Context in tombstone, no FltReferenceContext, then FltReleaseContext own ref
    F->>C: FileDispositionInfo TRUE, later last handle closes, Filter Manager drops its ref, refcount 0, freed
    U->>F: later rename completion, enumeration, or cleanup reads tombstone
    F->>C: dereference stale Context pointer into freed pool
    Note over C: use-after-free on the stream context

ProjFS projections are reachable from an unprivileged user once the Client-ProjFS optional feature is on (developer scenarios, worktrees, the Windows virtualization root), and rename is an ordinary file operation. The trigger is a crafted sequence of renames inside a projection, not a privileged call.

“I just caused an object to be destroyed, but I handed its pointer to a structure that outlives this function” is the whole bug. The Filter Manager owns the context lifetime through reference counting; a minifilter that stores a context pointer anywhere — a tombstone, a context of its own, a list — must take its own FltReferenceContext before the store and release it after the last reader. FileDispositionInfo is just the event that makes the missing reference hurt; the bug was already there when the store happened, and the deferred-free shape is exactly why it survives a casual read. The mental flag for deferred-destroy primitives is: after I trigger this, what still points at the object, and is each of those pointers reference-backed? Any filter that keeps an in-memory index keyed by something a delete can invalidate — file id, stream, section, stream context — has the same exposure. Audit every “store this pointer for later” against every “this can destroy it”; the store must take its own reference, full stop.