bfs.sys BfsCloseStorage AVL cursor UAF
bfs.sys is the Boot File System driver — the simple file system Windows uses
during the boot and servicing paths, behind the blob-based boot configuration
store that the bootloader and bcdedit.exe-style tooling manipulate, and that
the running OS reaches when the servicing stack needs to touch boot-managed
state. From an already-booted, admin-less session its surface is narrower than
a general-purpose file system, but anything that lets a user point bfs.sys
at a store they crafted — boot and servicing tooling, image deployment,
virtual-machine setup, recovery media preparation — is an entry point, and
every on-disk structure the driver parses is attacker-controlled at that
point.
Internally bfs.sys represents a store as a tree of AVL tables. The storage
object owns a root DirectoryTable /* +0x50 */; each node in that table
embeds its own ChildTable /* +0x28 */ and a Refcount /* +0x98 */ that
gates when the node’s memory is returned to pool. The load-bearing detail is
in RtlEnumerateGenericTableAvl, the runtime helper that walks an AVL table
by cursor: the cursor state lives inside the table struct, and the table
struct is itself embedded in a node. Free the node that the cursor lives
inside and you free the cursor’s storage — the next call to
RtlEnumerateGenericTableAvl runs through dangling pool.
BfsCloseStorage walks that tree depth-first: at each step it pulls the
leftmost entry out of the current table, descends into that entry’s
ChildTable /* +0x28 */ if it has children, or calls
BfsDereferenceTableEntry if it is a leaf. BfsDereferenceTableEntry walks
node->ParentDirStorage /* +0x90 */ and frees every ancestor whose
Refcount /* +0x98 */ collapses to zero after the leaf is gone. The loop
cursor Table is an RTL_AVL_TABLE * that always aliases a table embedded
in some node — and that node can be one of the ancestors the cascade just
freed:
/* BfsCloseStorage - vulnerable build */
struct BFS_AVL_TABLE *Table = &Storage->DirectoryTable; /* +0x50 */
while (true) {
struct BFS_TABLE_NODE *node = RtlEnumerateGenericTableAvl(Table, 1);
if (node == NULL) { /* cursor drained the table it sits in */
if (Table == &Storage->DirectoryTable)
return ExFreePoolWithTag(Storage, 0);
/* recover the node whose ChildTable the cursor was inside and step
the cursor up to that node's parent's table — still embedded in
an ancestor */
node = (struct BFS_TABLE_NODE *)((char *)Table - 0x28);
Table = &node->ParentDirStorage /* +0x90 */->Table; /* parent's table at +0x20 of it */
}
if (RtlIsGenericTableEmptyAvl(&node->ChildTable /* +0x28 */) == 0) {
/* node still has children: descend so the cursor lives inside node */
Table = &node->ChildTable;
continue;
}
/* leaf: BfsDereferenceTableEntry walks node->ParentDirStorage and frees
every ancestor whose Refcount also collapses to zero. The cursor was
sitting inside one of those ancestors, so the next call to
RtlEnumerateGenericTableAvl dereferences freed pool. */
BfsDereferenceTableEntry(node);
}
The vulnerable build does nothing to refresh Table after
BfsDereferenceTableEntry, so when the cascade reaches the node whose
ChildTable the cursor was inside, the next iteration’s
RtlEnumerateGenericTableAvl chases AVL links stored in pool that has
already gone back to the allocator. The trigger is a close on a store whose
tree shape lets a leaf deref collapse an ancestor that still holds the
cursor, which the attacker controls by choosing what is in the store — no
race required. The freed slot sits in a predictable pool bucket; an attacker
who can mount a heap-feng-shui sequence alongside the close can reclaim the
slot with a controlled object before the next iteration, and the AVL walk
then reads attacker-controlled link fields into a read/write primitive. The
floor is a deterministic bugcheck; the ceiling is the standard UAF-to-EoP
ladder if the reclaim lands.
The patch
The patch re-targets Table at the storage’s DirectoryTable after every
deref, so the next enumeration always starts from a stable root that is not
freed by BfsDereferenceTableEntry:
/* BfsCloseStorage - patched */
BfsDereferenceTableEntry(node);
Table = &Storage->DirectoryTable; /* +0x50 — reset cursor to the outer root */
When a cursor depends on the lifetime of the object it points into, every operation that may free that object must invalidate the cursor — and the cheapest invalidation is “reset to the root the caller owns.”
Attack path
sequenceDiagram
participant U as attacker-controlled bfs store
participant K as BfsCloseStorage
participant N as ancestor node holding the cursor
K->>N: cursor Table aliases inside N ChildTable, walk toward a leaf
K->>N: leaf deref cascades up to N, N Refcount hits zero, N freed
K->>N: next iteration follows AVL links in freed N memory
Note over N: UAF read or write into reclaimed kernel pool
An enumeration cursor that points into a node you are about to free is a
use-after-free, regardless of which AVL or splay helper is doing the walking.
“Walk a container, deref the current entry, continue the walk” only works
when the cursor is independent of the freed object; the fix shape is always
the same — after any operation that may free the current node, reset the
cursor to a stable root before re-entering the walk. RtlEnumerateGenericTableAvl
cursors live inside the node, which makes them especially easy to get wrong
when the same loop also dereferences the node.