ndis.sys — feature-staging retirement moving per-handle context free from IRP_MJ_CLEANUP to IRP_MJ_CLOSE
KB5094127
1. Overview
- Unpatched Binary ID:
ndis_unpatched.sys - Patched Binary ID:
ndis_patched.sys - Overall Similarity Score:
0.9908 - Diff Statistics: 5 changed functions, 10 removed functions (all Windows feature-staging config-query helpers), 0 added functions. Every other function that appears to differ does so only by relocated absolute addresses; no other function changed logic.
- Verdict: The delta between the two builds is a single coherent Windows feature-staging (WIL) change. The
DeviceUsagestaging feature (feature id 3473086778) is retired: its enable-check gate and the associated staging-config machinery are removed, and the behavior that the feature previously gated is hardcoded on. Inside the\Device\Ndisdispatch routine (ndisDispatchRequest, 0x1C001D550) this hard-enables the "free the per-handle context duringIRP_MJ_CLOSE" path and drops the "free duringIRP_MJ_CLEANUP" path. The move from CLEANUP to CLOSE is the standard object-lifecycle placement, but the change is delivered as a feature-flag retirement, the CLEANUP-free behavior was only ever the feature-disabled fallback, and no reachable use-after-free is demonstrable from the binaries. There is no demonstrated security-relevant fix here.
2. Change Summary
- Severity: None (informational). No demonstrable reachable impact.
- Change Class: Windows feature-staging (WIL) retirement / object-lifecycle placement change.
- Affected Function:
ndisDispatchRequest@ 0x1C001D550 (IRP dispatch routine for the\Device\Ndiscontrol device).
What changed
The per-handle context (FsContext) is a 0x38-byte NonPagedPoolNx allocation with pool tag 'NDoC' (0x636F444E), allocated in the IRP_MJ_CREATE path and stored in FileObject->FsContext.
In the unpatched build, whether that allocation is freed during IRP_MJ_CLEANUP or during IRP_MJ_CLOSE is selected at runtime by a call to Feature_3473086778__private_IsEnabledDeviceUsage (@ 0x1C003F56C). That function is a standard WIL feature-staging gate: it reads the cached feature state, and when the state bit is not yet cached it falls back to Feature_3473086778__private_IsEnabledFallback. Its two return values drive two mirror-image code paths:
- Feature enabled (returns non-zero):
IRP_MJ_CLEANUPskips the free and only completes the IRP;IRP_MJ_CLOSEperforms the free. (This is the deferred-to-CLOSE behavior.) - Feature disabled (returns zero):
IRP_MJ_CLEANUPperforms the free;IRP_MJ_CLOSEskips it.
In the patched build the feature is retired. The gate call is gone from both paths, along with the entire staging-config query machinery (10 functions removed). IRP_MJ_CLEANUP now only performs list/spinlock/miniport-reference teardown and completes the IRP; IRP_MJ_CLOSE unconditionally frees FsContext (when non-NULL). This is exactly the feature-enabled behavior made permanent.
Why this is not a demonstrated security fix
- The selection is a WIL feature-staging flag, not an application-state or attacker-influenced condition. Whether the "free during CLEANUP" fallback is ever taken at runtime depends on the feature's staged configuration, which is not determinable from the binaries.
- The deferred-to-CLOSE behavior already exists in the unpatched build (it is the feature-enabled branch). The patch delivers a staged feature, it does not add a check that was absent.
- No reachable use-after-free is demonstrable. Freeing a file-object context during
IRP_MJ_CLEANUPis only unsafe if some still-outstanding reference to it survives past CLEANUP; nothing in these two functions establishes such an outstanding reference, and the DEVICE_CONTROL path here does not create one that is shown to referenceFsContextafter CLEANUP.
Attack Surface
\Device\Ndis is reachable via its symbolic link. The dispatch routine handles IRP_MJ_CREATE (major 0), IRP_MJ_CLOSE (2), IRP_MJ_DEVICE_CONTROL (0x0E), IRP_MJ_INTERNAL_DEVICE_CONTROL (0x0F, completed with no work), IRP_MJ_CLEANUP (0x12), and IRP_MJ_SYSTEM_CONTROL (0x17). The DEVICE_CONTROL path calls ndisHandlePnPRequest (@ 0x1C012CDB8), the NDIS PnP-request handler. The specific behavior of any request that would keep a live reference to FsContext across CLEANUP is not established by this diff.
3. Pseudocode Diff
The following pseudocode reflects the logic in ndisDispatchRequest (0x1C001D550). The condition is the WIL feature gate, not a general runtime flag.
Unpatched Code:
NTSTATUS ndisDispatchRequest(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp);
if (ioStack->MajorFunction == IRP_MJ_CLEANUP) {
// ... miniport-reference / list / spinlock teardown ...
if (Feature_3473086778__private_IsEnabledDeviceUsage() == FALSE) {
// feature-disabled fallback: free here
ExFreePoolWithTag(FsContext, 0);
ioStack->FileObject->FsContext = NULL;
}
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
else if (ioStack->MajorFunction == IRP_MJ_CLOSE) {
if (Feature_3473086778__private_IsEnabledDeviceUsage() == TRUE) {
PVOID p = ioStack->FileObject->FsContext;
ioStack->FileObject->FsContext = NULL;
if (p) ExFreePoolWithTag(p, 0);
}
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
// ...
}
Patched Code:
NTSTATUS ndisDispatchRequest(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp);
if (ioStack->MajorFunction == IRP_MJ_CLEANUP) {
// ... miniport-reference / list / spinlock teardown only; no free ...
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
else if (ioStack->MajorFunction == IRP_MJ_CLOSE) {
// WIL gate removed: always free here
PVOID p = ioStack->FileObject->FsContext;
ioStack->FileObject->FsContext = NULL;
if (p) ExFreePoolWithTag(p, 0);
IoCompleteRequest(Irp, IO_NO_INCREMENT);
}
// ...
}
4. Assembly Analysis
Real disassembly from the two builds. In the unpatched build the CLEANUP and CLOSE paths share the free site at 0x1C001D75C/0x1C001D75E; both are guarded by the WIL feature gate.
Unpatched IRP_MJ_CLEANUP Path (major 0x12)
0x1C001D744 call Feature_3473086778__private_IsEnabledDeviceUsage
0x1C001D749 test eax, eax
0x1C001D74B jnz loc_1C001D827 ; feature ENABLED -> skip free, complete
0x1C001D751 mov rax, [r15+30h] ; FileObject
0x1C001D755 mov rcx, rdi ; FsContext
0x1C001D758 mov [rax+18h], rbx ; FileObject->FsContext = NULL
0x1C001D75C xor edx, edx ; Tag = 0
0x1C001D75E call cs:__imp_ExFreePoolWithTag ; free (feature-disabled fallback only)
0x1C001D76A lock dec cs:dword_1C00E70DC ; decrement global counter
0x1C001D771 jmp loc_1C001D827
Unpatched IRP_MJ_CLOSE Path (major 2)
0x1C001D78C call Feature_3473086778__private_IsEnabledDeviceUsage
0x1C001D791 test eax, eax
0x1C001D793 jz loc_1C001D827 ; feature DISABLED -> skip free, complete
0x1C001D799 mov rax, [r15+30h] ; FileObject
0x1C001D79D mov rcx, [rax+18h] ; FsContext
0x1C001D7A1 mov [rax+18h], rbx ; FileObject->FsContext = NULL
0x1C001D7A5 test rcx, rcx
0x1C001D7A8 jz short loc_1C001D76A ; NULL -> just decrement
0x1C001D7AA jmp short loc_1C001D75C ; -> shared free at 0x1C001D75C/75E
Patched IRP_MJ_CLOSE Path (major 2) — unconditional free, no feature gate
0x1C001D760 mov rax, [rdi+30h] ; FileObject
0x1C001D764 mov rcx, [rax+18h] ; FsContext
0x1C001D768 mov [rax+18h], rbx ; FileObject->FsContext = NULL
0x1C001D76C test rcx, rcx
0x1C001D76F jz short loc_1C001D77F
0x1C001D771 xor edx, edx ; Tag = 0
0x1C001D773 call cs:__imp_ExFreePoolWithTag
0x1C001D77F lock dec cs:dword_1C00E609C
0x1C001D786 jmp short loc_1C001D804
Patched IRP_MJ_CLEANUP Path (major 0x12) — teardown then complete, no free
0x1C001D72F mov rcx, [rdi+30h] ; sub-object at FsContext+0x30
0x1C001D733 test rcx, rcx
0x1C001D736 jz loc_1C001D804 ; none -> complete
0x1C001D73C call ?ndisIfDereferenceCompartmentForUser@@YAJPEAX@Z
0x1C001D741 mov [rdi+30h], rbx ; FsContext+0x30 = NULL
0x1C001D745 jmp loc_1C001D804 ; complete, no FsContext free
5. Trigger / Reachability Notes
\Device\Ndisis opened viaCreateFileon its symbolic link (IRP_MJ_CREATE, major 0), which allocates theFsContext(0x38 bytes, tag'NDoC',NonPagedPoolNx) and stores it inFileObject->FsContext.- Closing the last handle sends
IRP_MJ_CLEANUP(major 0x12); the final dereference sendsIRP_MJ_CLOSE(major 2). - Whether the unpatched build frees
FsContextduring CLEANUP or during CLOSE is decided solely by the WIL feature gateFeature_3473086778__private_IsEnabledDeviceUsage. This is a staged-feature configuration value, not an attacker-controlled condition, and its runtime default is not determinable from the binary. - No path in this routine is shown to leave a live reference to
FsContextoutstanding pastIRP_MJ_CLEANUP, so a use-after-free arising from the CLEANUP-free fallback is not demonstrable here. The DEVICE_CONTROL handler on this device isndisHandlePnPRequest(@ 0x1C012CDB8).
6. Impact Assessment
- No demonstrable reachable primitive. The change relocates a pool free from
IRP_MJ_CLEANUPtoIRP_MJ_CLOSEand does so by retiring a WIL feature. The deferred-to-CLOSE behavior already existed in the unpatched build as the feature-enabled branch. - Moving a file-object-context free from CLEANUP to CLOSE is the conventional object-lifecycle placement and is defensively preferable, but no outstanding reference surviving CLEANUP is established in these binaries, so no use-after-free is proven and no exploit primitive is supported.
- No information leak, no bounds/length/allocation change, and no attacker-controlled condition is involved.
7. Analyst Playbook
For an analyst inspecting the unpatched binary:
Breakpoints
bp ndis!ndisDispatchRequest
Why: main dispatch for \Device\Ndis. At entry rdx = IRP, r15 = [rdx+0xB8] (current IO_STACK_LOCATION), and r8d = byte [r15] = MajorFunction. Watch r8d to distinguish CREATE(0)/CLOSE(2)/DEVICE_CONTROL(0x0E)/CLEANUP(0x12)/SYSTEM_CONTROL(0x17).
bp ndis+0x3f56c
Why: Feature_3473086778__private_IsEnabledDeviceUsage, the WIL feature gate. eax at return selects which path frees FsContext: non-zero defers the free to CLOSE, zero frees during CLEANUP.
bp ndis+0x1d75e
Why: the shared ExFreePoolWithTag site used by both the CLEANUP-fallback and the CLOSE path in the unpatched build. rcx holds the FsContext pointer.
What to Inspect
- At
ndis!ndisDispatchRequestentry:r8d(MajorFunction). 0x00 = CREATE, 0x02 = CLOSE, 0x0E = DEVICE_CONTROL, 0x12 = CLEANUP, 0x17 = SYSTEM_CONTROL. - At 0x1C001D75E:
rcxholds theFsContextpointer being freed.!pool rcxshould show a 0x38-byte allocation with tag'NDoC'(0x636F444E).
Key Instructions/Offsets
- 0x1C001D744:
call Feature_3473086778__private_IsEnabledDeviceUsagein the CLEANUP path (removed in the patched build). - 0x1C001D78C: the same gate call in the CLOSE path (removed in the patched build).
- 0x1C001D75E: the shared
ExFreePoolWithTagcall (unpatched). - 0x1C001D780:
call ndisHandlePnPRequest— the DEVICE_CONTROL handler. - 0x1C001D818:
mov [rax+18h], rdi— storesFsContextintoFileObject->FsContextduring CREATE.
Struct/Offset Notes
The FsContext structure (0x38 bytes, tag 'NDoC'), from observed accesses in ndisDispatchRequest:
- +0x00 / +0x01 (bytes): access-check results written during CREATE by two ndisCheckAccess calls (against ndisSecurityDescriptor and unk_1C00E6E48).
- +0x08 (LIST_ENTRY): list linkage unlinked under the miniport spinlock during CLEANUP.
- +0x18 (dword): count/state field (cmp [rdi+18h], ebx, and dec dword [r14+18h] on the miniport block).
- +0x1C (dword): compared as a count (mov r8d, [rdi+1Ch]; test; jle).
- +0x20 (pointer): _NDIS_MINIPORT_BLOCK pointer; the block's spinlock is at +0x1168.
- +0x30 (pointer): sub-object released via ndisIfDereferenceCompartmentForUser during CLEANUP.
8. Changed Functions — Full Triage
All five changed functions are Windows feature-staging (WIL) machinery centered on the DeviceUsage feature retirement.
ndisDispatchRequest@ 0x1C001D550 (Sim: 0.93):\Device\NdisIRP dispatch. The twoFeature_3473086778__private_IsEnabledDeviceUsagegate calls are removed;FsContextis now freed unconditionally inIRP_MJ_CLOSEand never inIRP_MJ_CLEANUP. Remaining differences are compiler register re-allocation from that removal. This is the feature-enabled behavior made permanent; not a demonstrated security fix.wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath@ 0x1C003E254 (Sim: 0.8259): WIL feature-state-cache helper. The reporting-kind computation branch is removed and the value is hardcoded to 0x10; the corresponding argument is dropped. Feature-cache plumbing, not security-relevant.wil_details_FeatureReporting_ReportUsageToService@ 0x1C003DFE4 (Sim: 0.9868): WIL usage-telemetry helper. The reporting "kind" is hardcoded to 3 internally instead of taken from an argument. Telemetry plumbing, not security-relevant.Feature_NdisDatapathVerifier__private_ReportDeviceUsage@ 0x1C003DC44 (Sim: 0.9704): WIL usage-reporting caller updated to match the helper's new hardcoded-argument convention. Not security-relevant.Feature_ScreenON_NAPS__private_ReportDeviceUsage@ 0x1C004030C (Sim: 0.9822): the same caller-side change applied to a different feature-reporting object. Not security-relevant.
An independent content-level diff of both builds (matching by function content across relocations, ignoring address-only differences) confirms these are the only functions with logic changes. Every other apparently-differing function differs solely by relocated absolute addresses.
9. Removed / Added Functions
Ten functions present in the unpatched build are absent from the patched build; all are WIL staging-config query machinery removed when the DeviceUsage feature was retired:
Feature_3473086778__private_IsEnabledDeviceUsageFeature_3473086778__private_IsEnabledFallbackwil_RtlStagingConfig_QueryFeatureStatewil_StagingConfig_QueryFeatureStatewil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledStatewil_details_GetCurrentFeatureEnabledStatewil_details_IsEnabledFallbackwil_details_StagingConfigFeature_HasUniqueStatewil_details_StagingConfig_Loadwil_details_StagingConfig_QueryFeatureState
No functions were added.
10. Confidence & Caveats
- Confidence: High that this is a WIL feature-staging retirement and not a demonstrated security fix. The gate
Feature_3473086778__private_IsEnabledDeviceUsageand the entire staging-config query path are removed, and the retained behavior is exactly the feature-enabled branch that already existed in the unpatched build. - Caveat: Relocating a file-object-context free from
IRP_MJ_CLEANUPtoIRP_MJ_CLOSEis the conventional and defensively preferable object-lifecycle placement. If a real outstanding reference toFsContextsurvived past CLEANUP, the CLEANUP-free fallback would be a use-after-free. No such surviving reference is established by these binaries, so this remains an unproven lifecycle hardening delivered via feature-flag retirement, not a demonstrated vulnerability fix. Its severity is informational.