ndis.sys — WIL feature-staging graduation moving `\Device\Ndis` per-handle context free from IRP_MJ_CLEANUP to IRP_MJ_CLOSE
KB5093998
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\NdisIRP dispatch body) - Entry point:
CreateFile("\\.\NDIS")→IRP_MJ_CREATE;CloseHandle()→IRP_MJ_CLEANUPthenIRP_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_CLEANUPfrees the pool when the feature returns 0 (disabled); if the feature is enabled it skips the free and completes the IRP.IRP_MJ_CLOSEfrees 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_IsEnabledFallback → wil_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:
- User mode calls
CreateFile("\\.\NDIS", ...), arriving asIRP_MJ_CREATEon\Device\Ndis. ndisDriverDispatch(@0x1C0002AE0) compares theDeviceObjectagainstndisLwmDeviceObject; when it does not match it callsndisDispatchRequest, otherwisendisLwmDispatchIrp.ndisDispatchRequestallocates the 0x38-byte'NDoC'pool duringIRP_MJ_CREATEand stores it inFileObject->FsContext.CloseHandle()producesIRP_MJ_CLEANUP(0x12) thenIRP_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_1C00F5C28 → dword_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
- Open the device.
CreateFileW(L"\\.\NDIS", ...)dispatches asIRP_MJ_CREATE(MajorFunction == 0);ndisDispatchRequestallocates the 0x38-byte'NDoC'pool and stores it inFileObject->FsContext. - Close the handle.
CloseHandle()producesIRP_MJ_CLEANUP(0x12) followed byIRP_MJ_CLOSE(2). In the unpatched build theFeature_3003324730gate decides which of the two callbacks frees the pool; in the patched build the free always occurs inIRP_MJ_CLOSE. - No cross-callback stale reference is reachable. The pool pointer exists only in
FileObject->FsContext, which is NULLed on free. The miniport reference taken duringIRP_MJ_CLEANUPteardown is released in the same block, and theLIST_ENTRYis unlinked under the AoAc spinlock before any free.IRP_MJ_DEVICE_CONTROL(0xE) does not readFsContext. 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 @ 0x1C0002B08is the dispatcher for\Device\Ndis(reached fromndisDriverDispatch @ 0x1C0002AE0when theDeviceObjectis notndisLwmDeviceObject).- Unpatched: setting a breakpoint on
Feature_3003324730__private_IsEnabledDeviceUsage @ 0x1C00373E8and readingFeature_3003324730__private_featureStateshows which free path is taken (return 0 → free inIRP_MJ_CLEANUP; return non-zero → free inIRP_MJ_CLOSE). - Patched: the feature function is absent; the
IRP_MJ_CLOSEpath (0x1C0002D21) always frees the pool andIRP_MJ_CLEANUPnever does. - The
'NDoC'(0x636F444E) allocation is 0x38 bytes,POOL_FLAG_NON_PAGED; the allocation counter isdword_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 byFeature_3003324730__private_IsEnabledDeviceUsage(bit0x10= cached, bit0x1= enabled); on cache miss the descriptorFeature_3003324730__private_descriptoris consulted viawil_details_IsEnabledFallback.dword_1C00F5C28(unpatched) /dword_1C00F5C20(patched) —'NDoC'allocation counter.ndisLwmDeviceObject— control DeviceObject compared inndisDriverDispatchto route IRPs tondisDispatchRequest.
8. Changed Functions — Full Triage
ndisDispatchRequest @ 0x1C0002B08 (similarity 0.9299)
- What changed: The
Feature_3003324730__private_IsEnabledDeviceUsage()gate is removed from both theIRP_MJ_CLEANUP(0x12) andIRP_MJ_CLOSE(2) paths.IRP_MJ_CLEANUPno longer frees the'NDoC'pool at all;IRP_MJ_CLOSEnow frees it unconditionally (NULL check retained). BothFeature_3003324730__private_IsEnabledDeviceUsagecalls (0x1C0002CF5in CLEANUP,0x1C0002D45in CLOSE) are deleted. - Layout-only change: The allocation counter shifted from
dword_1C00F5C28todword_1C00F5C20due 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_1C00F5E18 → qword_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 @ 0x1C00373E8andFeature_3003324730__private_IsEnabledFallback @ 0x1C0037420. These are the WIL feature-staging helpers for feature ID3003324730; once the gate calls were deleted fromndisDispatchRequest, both became dead code and were dropped. No reference toFeature_3003324730remains 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_3003324730gate inndisDispatchRequest, moving the'NDoC'pool free fromIRP_MJ_CLEANUPtoIRP_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_CLOSEcallback) 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).