1. Overview

Field Value
Unpatched binary ndis_unpatched.sys
Patched binary ndis_patched.sys
Overall similarity 0.9921
Matched functions 3469
Changed functions (code) 1 (ndisDispatchRequest)
Removed helper functions 2 (Feature_3003324730__private_IsEnabledDeviceUsage, Feature_3003324730__private_IsEnabledFallback)
Identical functions 3468

Verdict: The single code change is the retirement of a WIL feature-staging gate (Feature_3003324730__private_IsEnabledDeviceUsage) inside ndisDispatchRequest, the IRP dispatcher for the \Device\Ndis control device. In the unpatched build the gate selected where the 0x38-byte per-handle context pool (tag 'NDoC') is freed: with the feature disabled it is freed in IRP_MJ_CLEANUP, with the feature enabled it is freed in IRP_MJ_CLOSE. The patched build removes the gate and its two private helper functions and commits unconditionally to freeing the pool in IRP_MJ_CLOSE, never in IRP_MJ_CLEANUP. This is the graduation of a staged object-lifetime change (deferring the free to the later teardown callback). No reachable use-after-free is demonstrable in either build: the pool pointer is held only in FileObject->FsContext, which is set to NULL when freed; the miniport reference taken during the IRP_MJ_CLEANUP teardown is released in the same code block; and the only other user-reachable major function (IRP_MJ_DEVICE_CONTROL) does not dereference FsContext. The change is a lifetime refactor delivered through WIL feature staging, not a security fix.


2. Change Summary

Item 1 — WIL feature gate retired in ndisDispatchRequest

  • Severity: None (no security-relevant change)
  • Class: WIL feature-staging graduation / object-lifetime refactor
  • Function: ndisDispatchRequest @ 0x1C0002B08 (the \Device\Ndis IRP dispatch body)
  • Entry point: CreateFile("\\.\NDIS")IRP_MJ_CREATE; CloseHandle()IRP_MJ_CLEANUP then IRP_MJ_CLOSE

Background. During IRP_MJ_CREATE (major function code 0), ndisDispatchRequest allocates a 0x38-byte pool object via ExAllocatePool2(POOL_FLAG_NON_PAGED, 0x38, 0x636F444E) (tag 'NDoC') and stores the pointer in CurrentStackLocation->FileObject->FsContext (FileObject+0x18). The structure holds a LIST_ENTRY at +0x8/+0x10, counters at +0x18/+0x1c, a miniport-block pointer at +0x20, and a compartment handle at +0x30. An allocation counter (dword_1C00F5C28 unpatched / dword_1C00F5C20 patched) is incremented on allocation and decremented on free.

What the gate did (unpatched). Both the IRP_MJ_CLEANUP (MajorFunction == 0x12) and IRP_MJ_CLOSE (MajorFunction == 2) paths call Feature_3003324730__private_IsEnabledDeviceUsage (@ 0x1C00373E8) and branch on its result:

  • IRP_MJ_CLEANUP frees the pool when the feature returns 0 (disabled); if the feature is enabled it skips the free and completes the IRP.
  • IRP_MJ_CLOSE frees the pool when the feature returns non-zero (enabled); if disabled it skips the free and completes the IRP.

So the two paths are complementary, not contradictory: exactly one of them frees the pool depending on the feature state. Feature disabled → free in CLEANUP. Feature enabled → free in CLOSE. Feature_3003324730__private_IsEnabledDeviceUsage is a standard WIL feature-enablement check: it reads Feature_3003324730__private_featureState; if the cached bit (0x10) is set it returns the enabled bit (0x1), otherwise it falls through to Feature_3003324730__private_IsEnabledFallbackwil_details_IsEnabledFallback on Feature_3003324730__private_descriptor.

What the patch does. It deletes both Feature_3003324730__private_IsEnabledDeviceUsage calls, removes the pool free from IRP_MJ_CLEANUP entirely, and makes the IRP_MJ_CLOSE path free the pool unconditionally (guarded only by a NULL check). The two now-unused Feature_3003324730 helper functions are dropped from the binary. The result is that the pool is always freed in IRP_MJ_CLOSE — the feature-enabled behavior baked in permanently.

Why this is not a demonstrable use-after-free. After the unpatched IRP_MJ_CLEANUP free, the only stored copy of the pointer (FileObject->FsContext) is set to NULL in the same instruction sequence. The miniport at +0x20 is referenced (ndisReferenceMiniportByHandle) and released (ndisDereferenceMiniport) within the CLEANUP block, and the LIST_ENTRY at +0x8/+0x10 is unlinked from the miniport list under the AoAc spinlock before the free. In the feature-disabled configuration the subsequent IRP_MJ_CLOSE re-checks the feature, gets 0, and does nothing — so there is no double free and no dangling access. The only other user-reachable major function, IRP_MJ_DEVICE_CONTROL (0xE), routes to ndisHandlePnPRequest(Irp) and never dereferences FsContext. No surviving live reference to the freed pool is present in either build; deferring the free from CLEANUP to CLOSE is a lifetime-correctness refactor, not the repair of a reachable dangling pointer.

Dispatch routing:

  1. User mode calls CreateFile("\\.\NDIS", ...), arriving as IRP_MJ_CREATE on \Device\Ndis.
  2. ndisDriverDispatch (@ 0x1C0002AE0) compares the DeviceObject against ndisLwmDeviceObject; when it does not match it calls ndisDispatchRequest, otherwise ndisLwmDispatchIrp.
  3. ndisDispatchRequest allocates the 0x38-byte 'NDoC' pool during IRP_MJ_CREATE and stores it in FileObject->FsContext.
  4. CloseHandle() produces IRP_MJ_CLEANUP (0x12) then IRP_MJ_CLOSE (2); the feature gate selects which callback frees the pool.

3. Pseudocode Diff

The function is one large IRP dispatcher; only the relevant tail of each path is shown. The decompilation matches ndis_unpatched.c / ndis_patched.c at ndisDispatchRequest @ 0x1C0002B08.

// ===== UNPATCHED — ndisDispatchRequest =====

// --- IRP_MJ_CLOSE (MajorFunction == 2) ---
case 2u:
    if ( Feature_3003324730__private_IsEnabledDeviceUsage() == 0 )
        goto complete;                    // feature disabled -> do NOT free here
    FileObject = CurrentStackLocation->FileObject;
    FsContext  = FileObject->FsContext;
    FileObject->FsContext = nullptr;
    if ( FsContext != nullptr )
        ExFreePoolWithTag(FsContext, 0);  // feature enabled -> free here
    _InterlockedDecrement(&dword_1C00F5C28);
    goto complete;

// --- IRP_MJ_CLEANUP (MajorFunction == 0x12) tail ---
    // ... miniport ref/deref, list unlink under spinlock, compartment deref ...
    if ( Feature_3003324730__private_IsEnabledDeviceUsage() != 0 )
        goto complete;                    // feature enabled -> do NOT free here
    FsContext = pool;                     // feature disabled -> free here
    CurrentStackLocation->FileObject->FsContext = nullptr;
    ExFreePoolWithTag(FsContext, 0);
    _InterlockedDecrement(&dword_1C00F5C28);
    goto complete;
// ===== PATCHED — ndisDispatchRequest =====

// --- IRP_MJ_CLOSE (MajorFunction == 2) — unconditional free ---
case 2u:
    FileObject = CurrentStackLocation->FileObject;
    FsContext  = FileObject->FsContext;
    FileObject->FsContext = nullptr;
    if ( FsContext != nullptr )
        ExFreePoolWithTag(FsContext, 0);  // always free here
    _InterlockedDecrement(&dword_1C00F5C20);
    goto complete;

// --- IRP_MJ_CLEANUP (MajorFunction == 0x12) tail — no free ---
    // ... miniport ref/deref, list unlink under spinlock ...
    if ( compartment != nullptr ) {
        ndisIfDereferenceCompartmentForUser(compartment);
        pool->compartment = nullptr;
    }
    goto complete;                        // straight to completion, no ExFreePoolWithTag

The two changes are: 1. The Feature_3003324730__private_IsEnabledDeviceUsage() call and the free block are removed from IRP_MJ_CLEANUP. 2. The Feature_3003324730__private_IsEnabledDeviceUsage() gate is removed from IRP_MJ_CLOSE; the free is now unconditional (NULL check retained).

The counter address shift (dword_1C00F5C28dword_1C00F5C20) is a data-section relocation, not a logic change.


4. Assembly Analysis

Unpatched — ndisDispatchRequest @ 0x1C0002B08 (relevant regions)

; --- prologue / MajorFunction dispatch ---
00000001C0002B1E  mov     r15, [rdx+0B8h]                 ; r15 = IO_STACK_LOCATION
00000001C0002B40  call    ?ndisReferencePackage@@...       ; ndisReferencePackage
00000001C0002B45  movzx   r8d, byte ptr [r15]              ; r8d = MajorFunction
00000001C0002B49  test    r8d, r8d
00000001C0002B4C  jz      loc_1C0002D65                    ; r8==0  -> IRP_MJ_CREATE
00000001C0002B52  sub     r8d, 2
00000001C0002B56  jz      loc_1C0002D45                    ; r8==2  -> IRP_MJ_CLOSE
00000001C0002B5C  sub     r8d, 0Ch
00000001C0002B60  jz      loc_1C0002D2E                    ; r8==0xE -> DEVICE_CONTROL
00000001C0002B66  sub     r8d, 1
00000001C0002B6A  jz      loc_1C0002E03                    ; r8==0xF -> completes
00000001C0002B70  sub     r8d, 3
00000001C0002B74  jz      short loc_1C0002B96             ; r8==0x12 -> IRP_MJ_CLEANUP
00000001C0002B76  cmp     r8d, 5
00000001C0002B7A  jz      short loc_1C0002B86             ; r8==0x17 -> ndisDriverSystemDispatch

; ===== IRP_MJ_CREATE (r8 == 0) =====
00000001C0002D65  mov     edx, 38h                        ; size 0x38
00000001C0002D6A  mov     r8d, 636F444Eh                  ; tag 'NDoC'
00000001C0002D70  lea     ecx, [rdx+8]                    ; PoolFlags = 0x40 (NON_PAGED)
00000001C0002D73  call    cs:__imp_ExAllocatePool2
00000001C0002DEE  mov     [rax+18h], rdi                  ; FileObject->FsContext = pool
00000001C0002DF2  lock inc cs:dword_1C00F5C28             ; allocation counter++

; ===== IRP_MJ_CLOSE (r8 == 2) — feature-gated free =====
00000001C0002D45  call    Feature_3003324730__private_IsEnabledDeviceUsage
00000001C0002D4A  test    eax, eax
00000001C0002D4C  jz      loc_1C0002E03                    ; feature disabled -> skip free
00000001C0002D52  mov     rax, [r15+30h]                   ; FileObject
00000001C0002D56  mov     rcx, [rax+18h]                   ; FsContext (pool)
00000001C0002D5A  mov     [rax+18h], rbx                   ; FsContext = NULL
00000001C0002D5E  test    rcx, rcx
00000001C0002D61  jz      short loc_1C0002D1B
00000001C0002D63  jmp     short loc_1C0002D0D              ; -> shared free/dec block

; ===== IRP_MJ_CLEANUP (r8 == 0x12) — teardown then feature-gated free =====
00000001C0002B96  mov     rax, [r15+30h]                   ; FileObject
00000001C0002B9A  mov     rdi, [rax+18h]                   ; pool pointer
00000001C0002B9E  mov     rsi, [rdi+20h]                   ; miniport block (pool+0x20)
; ... ndisReferenceMiniportByHandle / spinlock / LIST_ENTRY unlink /
;     ndisDereferenceMiniport (all balanced within this block) ...
00000001C0002CE3  mov     rcx, [rdi+30h]                   ; compartment handle (pool+0x30)
00000001C0002CE7  test    rcx, rcx
00000001C0002CEA  jz      short loc_1C0002CF5
00000001C0002CEC  call    ?ndisIfDereferenceCompartmentForUser@@YAJPEAX@Z
00000001C0002CF1  mov     [rdi+30h], rbx                   ; pool->compartment = NULL
00000001C0002CF5  call    Feature_3003324730__private_IsEnabledDeviceUsage
00000001C0002CFA  test    eax, eax
00000001C0002CFC  jnz     loc_1C0002E03                    ; feature enabled -> skip free
00000001C0002D02  mov     rax, [r15+30h]
00000001C0002D06  mov     rcx, rdi                         ; free the pool
00000001C0002D09  mov     [rax+18h], rbx                   ; FsContext = NULL
00000001C0002D0D  xor     edx, edx
00000001C0002D0F  call    cs:__imp_ExFreePoolWithTag       ; free (shared with CLOSE path)
00000001C0002D1B  lock dec cs:dword_1C00F5C28             ; allocation counter--
00000001C0002D22  jmp     loc_1C0002E03

; ===== completion / epilogue =====
00000001C0002E03  mov     dl, 2
00000001C0002E05  mov     [rbp+30h], ebx                   ; IoStatus.Status
00000001C0002E0B  call    cs:__imp_IofCompleteRequest
00000001C0002E1E  call    cs:__imp_MmUnlockPagableImageSection
00000001C0002E2A  lock dec cs:?ndisPkgs@@3PAU_PKG_REF@@A

Patched — ndisDispatchRequest @ 0x1C0002B08 (where the change lives)

; ===== IRP_MJ_CLOSE (r8 == 2) — unconditional free =====
00000001C0002D21  mov     rax, [rdi+30h]                   ; FileObject
00000001C0002D25  mov     rcx, [rax+18h]                   ; FsContext (pool)
00000001C0002D29  mov     [rax+18h], rbx                   ; FsContext = NULL
00000001C0002D2D  test    rcx, rcx                         ; NULL check only
00000001C0002D30  jz      short loc_1C0002D40
00000001C0002D32  xor     edx, edx
00000001C0002D34  call    cs:__imp_ExFreePoolWithTag       ; free
00000001C0002D40  lock dec cs:dword_1C00F5C20             ; counter-- (relocated)
00000001C0002D47  jmp     loc_1C0002DEA

; ===== IRP_MJ_CLEANUP (r8 == 0x12) — no free =====
00000001C0002CE7  mov     rcx, [rdi+30h]                   ; compartment handle
00000001C0002CEB  test    rcx, rcx
00000001C0002CEE  jz      loc_1C0002DEA
00000001C0002CF4  call    ?ndisIfDereferenceCompartmentForUser@@YAJPEAX@Z
00000001C0002CF9  mov     [rdi+30h], rbx
00000001C0002CFD  jmp     loc_1C0002DEA                    ; -> completion, NO ExFreePoolWithTag

Direction of the change. The patched build defers the free from the earlier callback (IRP_MJ_CLEANUP) to the later one (IRP_MJ_CLOSE) and removes the feature gate. This is the more conservative object lifetime (the pool lives until the last file-object reference is dropped). It is not reversed and it is not a relaxation, but it does not correspond to a demonstrable reachable dangling access — see §2.


5. Trigger / Reachability

  1. Open the device. CreateFileW(L"\\.\NDIS", ...) dispatches as IRP_MJ_CREATE (MajorFunction == 0); ndisDispatchRequest allocates the 0x38-byte 'NDoC' pool and stores it in FileObject->FsContext.
  2. Close the handle. CloseHandle() produces IRP_MJ_CLEANUP (0x12) followed by IRP_MJ_CLOSE (2). In the unpatched build the Feature_3003324730 gate decides which of the two callbacks frees the pool; in the patched build the free always occurs in IRP_MJ_CLOSE.
  3. No cross-callback stale reference is reachable. The pool pointer exists only in FileObject->FsContext, which is NULLed on free. The miniport reference taken during IRP_MJ_CLEANUP teardown is released in the same block, and the LIST_ENTRY is unlinked under the AoAc spinlock before any free. IRP_MJ_DEVICE_CONTROL (0xE) does not read FsContext. There is therefore no demonstrable window in which a freed-pool pointer is dereferenced.

6. Exploit Primitive & Development Notes

No exploit primitive is present. The change is a WIL feature-staging graduation of an object-lifetime refactor, not a memory-safety fix. In both builds the freed pool pointer is confined to FileObject->FsContext and is set to NULL at the point of free; no surviving live pointer to the freed allocation was found on any user-reachable path. There is no demonstrable use-after-free, no arbitrary read/write, and no privilege-escalation primitive to develop.


7. Runtime Observation Notes

For anyone reproducing the behavioral difference on a live target:

  • ndisDispatchRequest @ 0x1C0002B08 is the dispatcher for \Device\Ndis (reached from ndisDriverDispatch @ 0x1C0002AE0 when the DeviceObject is not ndisLwmDeviceObject).
  • Unpatched: setting a breakpoint on Feature_3003324730__private_IsEnabledDeviceUsage @ 0x1C00373E8 and reading Feature_3003324730__private_featureState shows which free path is taken (return 0 → free in IRP_MJ_CLEANUP; return non-zero → free in IRP_MJ_CLOSE).
  • Patched: the feature function is absent; the IRP_MJ_CLOSE path (0x1C0002D21) always frees the pool and IRP_MJ_CLEANUP never does.
  • The 'NDoC' (0x636F444E) allocation is 0x38 bytes, POOL_FLAG_NON_PAGED; the allocation counter is dword_1C00F5C28 (unpatched) / dword_1C00F5C20 (patched).

Pool structure layout ('NDoC', 0x38 bytes)

Offset Size Field
+0x00 0x08 access-check result bytes (3 used)
+0x08 0x08 LIST_ENTRY.Flink
+0x10 0x08 LIST_ENTRY.Blink
+0x18 0x04 counter / state
+0x1c 0x04 secondary counter
+0x20 0x08 miniport block pointer
+0x28 0x08 reserved
+0x30 0x08 compartment handle

Globals referenced

  • Feature_3003324730__private_featureState — WIL feature state read by Feature_3003324730__private_IsEnabledDeviceUsage (bit 0x10 = cached, bit 0x1 = enabled); on cache miss the descriptor Feature_3003324730__private_descriptor is consulted via wil_details_IsEnabledFallback.
  • dword_1C00F5C28 (unpatched) / dword_1C00F5C20 (patched) — 'NDoC' allocation counter.
  • ndisLwmDeviceObject — control DeviceObject compared in ndisDriverDispatch to route IRPs to ndisDispatchRequest.

8. Changed Functions — Full Triage

ndisDispatchRequest @ 0x1C0002B08 (similarity 0.9299)

  • What changed: The Feature_3003324730__private_IsEnabledDeviceUsage() gate is removed from both the IRP_MJ_CLEANUP (0x12) and IRP_MJ_CLOSE (2) paths. IRP_MJ_CLEANUP no longer frees the 'NDoC' pool at all; IRP_MJ_CLOSE now frees it unconditionally (NULL check retained). Both Feature_3003324730__private_IsEnabledDeviceUsage calls (0x1C0002CF5 in CLEANUP, 0x1C0002D45 in CLOSE) are deleted.
  • Layout-only change: The allocation counter shifted from dword_1C00F5C28 to dword_1C00F5C20 due to a data-section relocation, not a logic change.
  • Assessment: Object-lifetime refactor delivered via WIL feature-staging graduation. No demonstrable reachable use-after-free; not security-relevant.

No other function changed in code. An independent normalized comparison of both builds flagged 147 additional functions, but each differs only in relocated data-symbol addresses (uniform ±8-byte data-section shift, e.g. qword_1C00F5E18qword_1C00F5E10) and shifted locret_ label targets — pure relocation churn with no instruction-stream change.


9. Unmatched Functions

  • Removed (present only in unpatched): Feature_3003324730__private_IsEnabledDeviceUsage @ 0x1C00373E8 and Feature_3003324730__private_IsEnabledFallback @ 0x1C0037420. These are the WIL feature-staging helpers for feature ID 3003324730; once the gate calls were deleted from ndisDispatchRequest, both became dead code and were dropped. No reference to Feature_3003324730 remains in the patched binary.
  • Added: none.

No new sanitizer, bounds check, or mitigation was introduced, and no mitigation was removed.


10. Confidence & Caveats

  • Confidence: High that the only code change is the removal of the Feature_3003324730 gate in ndisDispatchRequest, moving the 'NDoC' pool free from IRP_MJ_CLEANUP to IRP_MJ_CLOSE. This is confirmed in both the disassembly and the decompilation of both builds, and by an independent whole-binary diff.
  • Confidence: High that this is not a demonstrable security fix. The freed pool pointer is confined to FileObject->FsContext (NULLed on free), the miniport reference taken during CLEANUP is balanced within the same block, and no user-reachable path dereferences the pool after the free.
  • Nature of the change: WIL feature-staging graduation of an object-lifetime refactor. The direction (deferring the free to the later IRP_MJ_CLOSE callback) is the conservative one and is consistent with lifetime hardening, but no reachable dangling access exists in either build, so it does not warrant a memory-safety classification.
  • CWE / severity: No CWE assigned; severity None (no security-relevant change).