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:

  1. User-mode directory enumeration (FindFirstFileW/FindNextFileW, NtQueryDirectoryFile) by a standard (non-elevated) process on a path handled by luafv's virtual store.
  2. FltMgr dispatches the IRP_MJ_DIRECTORY_CONTROL request to LuafvPreDirectoryControl (0x1C000FD10).
  3. LuafvPreDirectoryControl checks the stream-handle context, requires minor function IRP_MN_QUERY_DIRECTORY (cmp byte [Iopb+5], 1 at 0x1C000FDB6), then reaches the store-merge path for the relevant file-information classes (cmp edx, 3 at 0x1C000FDE0 plus the class jump table at 0x1C0017297), and at 0x1C000FE03 calls LuafvQueryStoreDirectory.
  4. LuafvQueryStoreDirectory runs the unsynchronized lazy-init/open/merge pipeline on ctx+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. - 0x1C0014DEFLuafvAllocatePool(PagedPool, 0x40, tag 0x16). - 0x1C0014DF4 — unsynchronized store of the new pointer (race-decider). - 0x1C0018DFAMmMapLockedPagesSpecifyCache on the query MDL. - 0x1C0014EB5LuafvMergeStoreDirectory on the shared buffer. - Patched: 0x1C0015F86 (acquire), 0x1C00160FE (release), both gated at 0x1C0015F59 / 0x1C00160D5.


5. Trigger Conditions

  1. Process context. Run as a standard (non-elevated) user so UAC file virtualization is engaged; luafv must be registered and filtering (default on workstations with UAC enabled).
  2. 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.
  3. Concurrent query-directory traffic. Issue overlapped/asynchronous IRP_MJ_DIRECTORY_CONTROL / IRP_MN_QUERY_DIRECTORY requests (NtQueryDirectoryFile / FindNextFileW) on the same handle so two callbacks enter LuafvQueryStoreDirectory on the same context. The lazy-init race is widest on the first query, when ctx+0x60 is still NULL.
  4. Observable effects (demonstrable):
  5. A leaked 0x40-byte PagedPool allocation (pool tag 0x16) when both threads allocate and one pointer is overwritten.
  6. Concurrent open/merge over the same shared 0x40 buffer, i.e. corruption of the directory-merge state for that context.
  7. 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 overwrites ctx+0x60.
  • Concurrent buffer double-initialization: both threads run LuafvOpenDirectory (twice each) and LuafvMergeStoreDirectory against 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 l10 to view the context.
  • At +0x1b: confirm [r8+0x60] is NULL. Two threads observing NULL here simultaneously is the race condition.
  • At +0x3b: verify ecx == 1 (PagedPool), edx == 0x40 (size), r8b == 0x16 (tag).
  • At +0x40: rax is the new allocation; if [rdi+0x60] already differs from rax, the store was raced.
  • At +0x76: compare r8 here 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
0x1C0014DDD0x1C0014DE0 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 (0x1C0014DB40x1C0015F34) 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_1C0009AB8qword_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.