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.