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:
- Rename completion. The rename operation that called
PrjfDeleteTombstoneIfExistscontinues 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. - 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.
- 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.