luafv.sys — Missing synchronization on LuafvQueryStoreDirectory stream-handle-context lazy init (CWE-362), fixed behind a staged feature gate
KB5094127
1. Overview
| Item | Value |
|---|---|
| Unpatched binary | luafv_unpatched.sys |
| Patched binary | luafv_patched.sys |
| Overall similarity | 0.9648 |
| Matched functions | 214 |
| Changed functions | 1 |
| Identical functions | 213 |
| Unmatched (unpatched → patched) | 0 / 0 |
Verdict: The only behavioral change in the driver is in LuafvQueryStoreDirectory. The patched build wraps that function's body in a recursive exclusive push lock on the stream-handle context, addressing a missing-synchronization (race) pattern in the lazy initialization of a per-context buffer. The lock is gated behind a WIL feature-staging flag (Feature_3757245754__private_IsEnabledDeviceUsage); when the flag is off, the lock is skipped and the pre-existing unsynchronized path runs unchanged. All other function-level differences between the two builds are global-data address relocations (the data section shifted because the WIL feature-staging code and its globals were linked in), not logic changes.
2. Vulnerability Summary
Finding 1: Missing Synchronization in Stream-Handle-Context Buffer Lazy Init
| Field | Value |
|---|---|
| Severity | Low |
| Class | Race Condition / Missing Synchronization (CWE-362) |
| Function | LuafvQueryStoreDirectory (0x1C0014DB4 unpatched, 0x1C0015F34 patched) |
| Entry point | Pre-operation callback LuafvPreDirectoryControl (0x1C000FD10), registered for IRP_MJ_DIRECTORY_CONTROL |
| Reaching operation | IRP_MJ_DIRECTORY_CONTROL with minor function IRP_MN_QUERY_DIRECTORY (minor == 1) only |
Root cause. LuafvQueryStoreDirectory lazily initializes a per-stream-handle-context buffer. It (1) reads the context's +0x60 pointer, (2) if NULL, allocates a 0x40-byte PagedPool chunk (pool tag 0x16) via LuafvAllocatePool and stores it at ctx+0x60, (3) zeroes it with memset, (4) calls LuafvOpenDirectory twice to open the store and virtual directories into the buffer and the buffer+0x20 region, (5) maps the query MDL from the callback data via MmMapLockedPagesSpecifyCache, and (6) calls LuafvMergeStoreDirectory to merge the store and virtual directory listings. In the unpatched build none of these steps hold any lock.
If two directory-query operations execute concurrently on the same stream-handle context (overlapped/asynchronous IRP_MJ_DIRECTORY_CONTROL requests on the same handle), both threads can observe ctx+0x60 == NULL, both allocate, and one store overwrites the other at ctx+0x60. The loser's 0x40-byte allocation is leaked (never freed), and both threads then run the open/merge pipeline against the same shared buffer concurrently, corrupting the buffer's directory-merge state. No free of the ctx+0x60 buffer occurs inside this function, so no use-after-free is demonstrated by the diff; the demonstrable effects are a pool-buffer leak and concurrent double-initialization / corruption of the shared 0x40 buffer.
Direction of the change. The patched build is the stricter one: it adds synchronization. However, the acquire/release are both guarded by Feature_3757245754__private_IsEnabledDeviceUsage(). When that predicate returns 0, the patched code jumps over the lock (jz) and executes the identical unsynchronized body. This is a staged / feature-gated rollout with the old path retained, not an unconditionally enforced fix.
Attacker-reachable entry point and call chain:
- User-mode directory enumeration (
FindFirstFileW/FindNextFileW,NtQueryDirectoryFile) by a standard (non-elevated) process on a path handled by luafv's virtual store. - FltMgr dispatches the
IRP_MJ_DIRECTORY_CONTROLrequest toLuafvPreDirectoryControl(0x1C000FD10). LuafvPreDirectoryControlchecks the stream-handle context, requires minor functionIRP_MN_QUERY_DIRECTORY(cmp byte [Iopb+5], 1at0x1C000FDB6), then reaches the store-merge path for the relevant file-information classes (cmp edx, 3at0x1C000FDE0plus the class jump table at0x1C0017297), and at0x1C000FE03callsLuafvQueryStoreDirectory.LuafvQueryStoreDirectoryruns the unsynchronized lazy-init/open/merge pipeline onctx+0x60.
No other IRP major function reaches LuafvQueryStoreDirectory; the only other caller is the second LuafvPreDirectoryControl variant in the patched build (0x1C0010E90 → call at 0x1C0010F83), which is likewise a directory-control pre-op.
3. Pseudocode Diff
// === UNPATCHED — LuafvQueryStoreDirectory @ 0x1C0014DB4 (no synchronization) ===
int32_t LuafvQueryStoreDirectory(PFLT_INSTANCE Instance, PFLT_CALLBACK_DATA Data, void* Ctx)
{
void* Iopb = *(Data + 0x10);
void* buf = *(Ctx + 0x60); // read shared pointer, NO LOCK
int32_t len = *(Iopb + 0x18);
if (!buf) {
buf = LuafvAllocatePool(PagedPool, 0x40, 0x16); // alloc, NO LOCK
*(Ctx + 0x60) = buf; // store, NO LOCK (race-decider)
if (!buf) return 0xC000009A; // STATUS_INSUFFICIENT_RESOURCES
memset(buf, 0, 0x40);
buf = *(Ctx + 0x60); // re-read shared pointer
}
status = LuafvOpenDirectory(Instance, Ctx, buf, len); // NO LOCK
if (status < 0) goto fail;
status = LuafvOpenDirectory(Instance, Ctx + 0x30, *(Ctx+0x60)+0x20, len); // NO LOCK
if (status < 0) goto fail;
MappedVa = MmMapLockedPagesSpecifyCache(Iopb->Mdl, 0, MmCached, 0, 0, 0x40000010); // NO LOCK
status = LuafvMergeStoreDirectory(Instance, Ctx, len, ...); // NO LOCK
fail:
if (status < 0) { *(Data+0x20) = 0; *(Data+0x18) = status; }
return status;
}
// === PATCHED — LuafvQueryStoreDirectory @ 0x1C0015F34 (feature-gated lock) ===
int32_t LuafvQueryStoreDirectory(PFLT_INSTANCE Instance, PFLT_CALLBACK_DATA Data, void* Ctx)
{
// ---- ADDED, but only when the WIL feature is enabled ----
if (Feature_3757245754__private_IsEnabledDeviceUsage()) {
PushLock = *(Ctx + 0x20); // stream-handle-context push lock
if (PushLock[1] != KeGetCurrentThread()) { // not already owner
FltAcquirePushLockExclusiveEx(PushLock, 0);
PushLock[1] = KeGetCurrentThread(); // owner
PushLock[2] = 1; // recursion count
} else {
PushLock[2] += 1; // recursive re-entry
}
}
/* ... identical lazy-init / open / merge body as unpatched ... */
if (Feature_3757245754__private_IsEnabledDeviceUsage()) {
PushLock = *(Ctx + 0x20);
if (PushLock[1] != KeGetCurrentThread()) {
FltReleasePushLockEx(PushLock, 0);
} else if (--PushLock[2] == 0) {
PushLock[1] = 0;
FltReleasePushLockEx(PushLock, 0);
}
}
return status;
}
Key observations:
- The body logic is unchanged; only the feature-gated lock acquire/release were added around it.
- The gate Feature_3757245754__private_IsEnabledDeviceUsage is a WIL feature-staging predicate, not a compile-time "always true" flag. When it returns 0, the lock is entirely bypassed and the unsynchronized path is executed.
- The lock is recursive-exclusive: owner thread tracked at push-lock +0x08, recursion count at +0x10.
4. Assembly Analysis
Unpatched — LuafvQueryStoreDirectory @ 0x1C0014DB4
00000001C0014DC8 mov rsi, [rdx+10h] ; rsi = Iopb (Data->Iopb)
00000001C0014DCC mov rdi, r8 ; rdi = StreamHandleContext (arg3)
00000001C0014DCF mov r8, [r8+60h] ; read ctx->buf, NO LOCK
00000001C0014DD9 mov r12d, [rsi+18h] ; len parameter
00000001C0014DDD test r8, r8
00000001C0014DE0 jnz short loc_1C0014E12 ; skip alloc if already set
00000001C0014DE2 mov ebx, 40h ; size = 0x40
00000001C0014DE7 mov r8b, 16h ; pool tag 0x16
00000001C0014DEC lea ecx, [rbx-3Fh] ; PoolType = 1 (PagedPool)
00000001C0014DEF call LuafvAllocatePool ; alloc, NO LOCK
00000001C0014DF4 mov [rdi+60h], rax ; store ctx->buf, NO LOCK
00000001C0014DF8 test rax, rax
00000001C0014DFB jz loc_1C0018DCE ; -> STATUS_INSUFFICIENT_RESOURCES
00000001C0014E09 call memset ; zero the 0x40 buffer
00000001C0014E0E mov r8, [rdi+60h] ; re-read ctx->buf
00000001C0014E1B call LuafvOpenDirectory ; open store dir into buf, NO LOCK
00000001C0014E2A mov r8, [rdi+60h] ; re-read ctx->buf
00000001C0014E32 add r8, 20h
00000001C0014E3C call LuafvOpenDirectory ; open virtual dir into buf+0x20, NO LOCK
00000001C0014E53 mov rcx, [rsi+40h] ; MDL = Iopb+0x40
00000001C0018DFA call cs:__imp_MmMapLockedPagesSpecifyCache ; NO LOCK
00000001C0014EB5 call LuafvMergeStoreDirectory ; merge listings, NO LOCK
00000001C0014ED9 retn
00000001C0018DCE mov ebx, 0C000009Ah ; STATUS_INSUFFICIENT_RESOURCES
Patched — LuafvQueryStoreDirectory @ 0x1C0015F34 (feature-gated lock excerpt)
00000001C0015F59 call Feature_3757245754__private_IsEnabledDeviceUsage
00000001C0015F5E mov r14d, 1
00000001C0015F64 test eax, eax
00000001C0015F66 jz short loc_1C0015FA3 ; feature OFF -> skip lock, run old path
00000001C0015F68 mov rbx, [rdi+20h] ; push lock = ctx+0x20
00000001C0015F6C mov rax, gs:188h ; KeGetCurrentThread()
00000001C0015F75 cmp [rbx+8], rax ; already owner?
00000001C0015F79 jnz short loc_1C0015F81
00000001C0015F7B add [rbx+10h], r14d ; recursive: ++count
00000001C0015F7F jmp short loc_1C0015FA3
00000001C0015F86 call cs:__imp_FltAcquirePushLockExclusiveEx
00000001C0015F9B mov [rbx+8], rax ; owner = current thread
00000001C0015F9F mov [rbx+10h], r14d ; recursion count = 1
00000001C0015FA3 ... [body identical to unpatched, now serialized when feature on] ...
00000001C00160D5 call Feature_3757245754__private_IsEnabledDeviceUsage
00000001C00160DC jz short loc_1C001610A ; feature OFF -> skip release
00000001C00160F1 sub dword ptr [rcx+10h], 1 ; recursive: --count
00000001C00160FE call cs:__imp_FltReleasePushLockEx
Key address anchors:
- 0x1C0014DCF — unsynchronized read of ctx+0x60.
- 0x1C0014DE0 — NULL-check branch two threads can both take.
- 0x1C0014DEF — LuafvAllocatePool(PagedPool, 0x40, tag 0x16).
- 0x1C0014DF4 — unsynchronized store of the new pointer (race-decider).
- 0x1C0018DFA — MmMapLockedPagesSpecifyCache on the query MDL.
- 0x1C0014EB5 — LuafvMergeStoreDirectory on the shared buffer.
- Patched: 0x1C0015F86 (acquire), 0x1C00160FE (release), both gated at 0x1C0015F59 / 0x1C00160D5.
5. Trigger Conditions
- Process context. Run as a standard (non-elevated) user so UAC file virtualization is engaged;
luafvmust be registered and filtering (default on workstations with UAC enabled). - Virtualized directory. Enumerate a directory backed by the luafv virtual store so the stream-handle context and its store/virtual directory merge path are exercised.
- Concurrent query-directory traffic. Issue overlapped/asynchronous
IRP_MJ_DIRECTORY_CONTROL/IRP_MN_QUERY_DIRECTORYrequests (NtQueryDirectoryFile/FindNextFileW) on the same handle so two callbacks enterLuafvQueryStoreDirectoryon the same context. The lazy-init race is widest on the first query, whenctx+0x60is still NULL. - Observable effects (demonstrable):
- A leaked 0x40-byte PagedPool allocation (pool tag
0x16) when both threads allocate and one pointer is overwritten. - Concurrent open/merge over the same shared 0x40 buffer, i.e. corruption of the directory-merge state for that context.
- On patched builds with the feature enabled, the push lock serializes the path and neither effect occurs. With the feature disabled, the patched build behaves like the unpatched one.
No use-after-free, arbitrary read/write, or privilege-escalation primitive is demonstrable from the diff; the buffer at ctx+0x60 is not freed within this function.
6. Impact Assessment
The diff supports a missing-synchronization (race) hardening only. The realistic worst case from the unsynchronized path is:
- Pool-memory leak: the loser thread's 0x40 PagedPool allocation (tag
0x16) is orphaned when the winner overwritesctx+0x60. - Concurrent buffer double-initialization: both threads run
LuafvOpenDirectory(twice each) andLuafvMergeStoreDirectoryagainst the same shared 0x40 buffer, corrupting the per-context directory-merge state, which can lead to incorrect directory enumeration results or a fault while merging.
No free of the shared buffer occurs in LuafvQueryStoreDirectory, so a use-after-free is not established by this diff. There is no evidence in the two builds for an information leak, a controlled kernel read/write, or a privilege-escalation primitive, and none is claimed here. The reachable path is narrow (directory-query only, first-init window) and the delivered lock is feature-gated, further limiting real-world exposure. This is why the finding is rated Low.
7. Debugger Notes (Unpatched Target)
Resolve the relocated base first:
kd> lm m luafv
Breakpoints
bp luafv_unpatched!LuafvQueryStoreDirectory ; 0x1C0014DB4 entry
bp luafv_unpatched!LuafvQueryStoreDirectory+0x1b ; 0x1C0014DCF read ctx+0x60
bp luafv_unpatched!LuafvQueryStoreDirectory+0x3b ; 0x1C0014DEF LuafvAllocatePool
bp luafv_unpatched!LuafvQueryStoreDirectory+0x40 ; 0x1C0014DF4 store ctx+0x60
bp luafv_unpatched!LuafvQueryStoreDirectory+0x76 ; 0x1C0014E2A re-read ctx+0x60
bp luafv_unpatched!LuafvQueryStoreDirectory+0x101 ; 0x1C0014EB5 LuafvMergeStoreDirectory
bp luafv_unpatched!LuafvPreDirectoryControl ; 0x1C000FD10 caller
bp luafv_unpatched!LuafvPreDirectoryControl+0xf3 ; 0x1C000FE03 call LuafvQueryStoreDirectory
Entry registers: rcx = PFLT_INSTANCE, rdx = PFLT_CALLBACK_DATA, r8 = StreamHandleContext.
What to inspect
- At entry:
? poi(@r8+0x60)— NULL on a fresh context (lazy-init path taken);dq @r8 l10to view the context. - At
+0x1b: confirm[r8+0x60]is NULL. Two threads observing NULL here simultaneously is the race condition. - At
+0x3b: verifyecx == 1(PagedPool),edx == 0x40(size),r8b == 0x16(tag). - At
+0x40:raxis the new allocation; if[rdi+0x60]already differs fromrax, the store was raced. - At
+0x76: comparer8here against the allocation from+0x40; a mismatch indicates the shared pointer was replaced by another thread.
Key instructions / offsets
| Offset | Instruction | Meaning |
|---|---|---|
0x1C0014DCF |
mov r8, [r8+60h] |
Unsynchronized read of shared buffer pointer |
0x1C0014DDD–0x1C0014DE0 |
test r8,r8 ; jnz 0x1C0014E12 |
NULL-check both threads can pass |
0x1C0014DEF |
call LuafvAllocatePool |
PagedPool, 0x40 bytes, tag 0x16 |
0x1C0014DF4 |
mov [rdi+60h], rax |
Unsynchronized store (race-decider) |
0x1C0014E2A |
mov r8, [rdi+60h] |
Second LuafvOpenDirectory re-reads shared pointer |
0x1C0018DFA |
call MmMapLockedPagesSpecifyCache |
Maps the query MDL (Iopb+0x40) |
0x1C0014EB5 |
call LuafvMergeStoreDirectory |
Merge over the shared buffer |
0x1C000FE03 |
call LuafvQueryStoreDirectory |
Caller's invocation site |
0x1C0015F86 (patched) |
call FltAcquirePushLockExclusiveEx |
Feature-gated lock acquire |
0x1C00160FE (patched) |
call FltReleasePushLockEx |
Feature-gated lock release |
Struct / offset notes
StreamHandleContext (arg3)
+0x20 push lock (recursive exclusive; used only on the patched feature-on path)
+0x08 owner thread (ETHREAD*, from gs:0x188)
+0x10 recursion count (DWORD)
+0x30 sub-structure used by the second LuafvOpenDirectory call
+0x60 lazily-allocated 0x40-byte PagedPool buffer, pool tag 0x16
FLT_CALLBACK_DATA (arg2)
+0x10 Iopb
+0x18 length/parameter (r12)
+0x40 query MDL (used by MmMapLockedPagesSpecifyCache)
Alloc-failure return: 0xC000009A (STATUS_INSUFFICIENT_RESOURCES)
8. Changed Functions — Full Triage
| Function | Similarity | Change type | Note |
|---|---|---|---|
LuafvQueryStoreDirectory (0x1C0014DB4 → 0x1C0015F34) |
0.7348 | Security-relevant (gated) | Only behavioral change. Adds a recursive FltAcquirePushLockExclusiveEx/FltReleasePushLockEx pair on the stream-handle-context push lock at ctx+0x20, guarded by Feature_3757245754__private_IsEnabledDeviceUsage(). When the feature is off the lock is skipped and the original unsynchronized body runs. Body logic otherwise unchanged. |
An independent content-level diff of both builds (matching by function content across relocations, not by reused address) confirms this is the sole logic change. Every other matched function differs only in global-data addresses (e.g. qword_1C0009AB8 → qword_1C000A498) because the data section shifted when the WIL feature-staging code and its globals were linked into the patched build — no instruction-level logic changes. No other changed function is a security fix.
9. Unmatched / Added Functions
The patched build additionally links in the WIL feature-staging framework used by the gate: Feature_3757245754__private_IsEnabledDeviceUsage, Feature_3757245754__private_IsEnabledFallback, and a set of wil_details_* / wil_*StagingConfig* / wil_details_FeatureReporting_* helper routines. These are staging/telemetry plumbing that implement and evaluate the feature flag; they contain no independent luafv security logic. No sanitizer or security check was removed from the unpatched build.
10. Confidence & Caveats
Confidence: High for the nature and scope of the change; the diff is unambiguous and reproducible.
Rationale.
- Exactly one function changed behaviorally (LuafvQueryStoreDirectory); every other delta is data relocation from linking in the WIL staging code.
- The added synchronization and its feature gate are directly visible in the patched disassembly (0x1C0015F59 gate, 0x1C0015F86 acquire, 0x1C00160FE release).
- The entry point is LuafvPreDirectoryControl for IRP_MJ_DIRECTORY_CONTROL / IRP_MN_QUERY_DIRECTORY only; the caller's guards (cmp byte [Iopb+5], 1, then the file-information-class dispatch) confine reachability to directory-query.
Why the severity is Low.
- The delivered lock is gated behind a WIL feature flag with the old unsynchronized path retained (staged rollout, not an unconditional fix).
- The reachable window is narrow (concurrent directory-query on the same context, first-init only).
- The demonstrable impact is a pool leak and concurrent buffer double-initialization; no use-after-free, information leak, or read/write primitive is established by the diff (the ctx+0x60 buffer is not freed within this function).
Assumptions.
- LuafvAllocatePool allocates PagedPool based on the register setup at the call site (ecx = 1, edx = 0x40, r8b = 0x16).
- The MDL passed to MmMapLockedPagesSpecifyCache comes from Iopb+0x40 (mov rcx, [rsi+40h] immediately before the call).
- The runtime state of Feature_3757245754__private_IsEnabledDeviceUsage on any given system depends on staging configuration and is not determinable from the binary alone.