ndis.sys — feature-staged deferral of per-open context free from Cleanup to Close
KB5078885
1. Overview
- Unpatched Binary:
ndis_unpatched.sys - Patched Binary:
ndis_patched.sys - Overall Similarity: 0.9908
- Diff Statistics:
- Matched Functions: 3124
- Changed Functions: 5
- Identical Functions: 3119
- Unmatched (Unpatched): 0
- Unmatched (Patched): 0
Verdict: The only behavioral change in the NDIS control-device IRP dispatch routine (ndisDispatchRequest @ 0x1C001D550) is gated behind a Windows feature-staging flag (Feature_3473086778__private_IsEnabledDeviceUsage @ 0x1C003F56C). When that flag is disabled, the patched build behaves identically to the unpatched build (the per-open context is freed during IRP_MJ_CLEANUP). When the flag is enabled, the free is deferred from IRP_MJ_CLEANUP to IRP_MJ_CLOSE. Because the original free-at-cleanup path is retained in full and the change is a staged, config-gated rollout, this diff does not constitute a delivered security fix. The remaining four changed functions are Windows feature-staging/reporting library helpers. No reachable use-after-free is demonstrable from the code.
2. Nature of the Change
- Severity: None (informational)
- Change Class: Feature-staged object-lifetime adjustment (defense-in-depth, not delivered by default)
- Affected Function:
ndisDispatchRequest(NDIS control-device IRP dispatch handler) @0x1C001D550
What actually changed:
ndisDispatchRequest is the dispatch routine for the NDIS control device. On IRP_MJ_CREATE (major function 0) it allocates a 0x38-byte NonPagedPoolNx per-open context with pool tag 'NDoC' (ExAllocatePoolWithTag) and stores it in the file object at offset +0x18 (FsContext), incrementing a global open counter.
In the unpatched build, IRP_MJ_CLEANUP (major function 0x12) tears down the context (unlinks it from the miniport binding list under a spinlock, drops the miniport reference, and releases the compartment sub-object at context+0x30 via ndisIfDereferenceCompartmentForUser), then unconditionally frees the context with ExFreePoolWithTag. IRP_MJ_CLOSE (major function 2) does nothing with the context.
The patched build inserts a call to the feature-staging gate Feature_3473086778__private_IsEnabledDeviceUsage in both paths:
- In IRP_MJ_CLEANUP, after the same teardown, if the gate returns nonzero (feature enabled) the free is skipped and the IRP is completed; if it returns zero (feature disabled) the context is freed exactly as before.
- In IRP_MJ_CLOSE, if the gate returns nonzero the context is loaded from FsContext, nulled, and freed here (the deferred free); if it returns zero, nothing is done (unpatched behavior).
Net effect: with the feature disabled the two builds are behaviorally identical (free at cleanup). With the feature enabled the free moves to the later IRP_MJ_CLOSE teardown point. Deferring a per-open context free from IRP_MJ_CLEANUP to IRP_MJ_CLOSE is a recognized object-lifetime hardening pattern, but here it is gated and the original path is preserved, so it is a staged rollout rather than an unconditionally delivered fix.
About the gate:
Feature_3473086778__private_IsEnabledDeviceUsage is standard Windows feature-staging (velocity) infrastructure. It reads Feature_3473086778__private_featureState, tests bit 0x10 (state-determined), and either returns state & 1 or falls back to Feature_3473086778__private_IsEnabledFallback with default value 3. It is a compile/configuration feature switch. It is not a runtime check of in-flight asynchronous operations.
Reachability note:
Within ndisDispatchRequest, IRP_MJ_DEVICE_CONTROL (major function 0xE) dispatches to ndisHandlePnPRequest (@ 0x1C012BDB8 in the unpatched build) in both builds; this path is unchanged. The cleanup path removes the context from its shared lists under a spinlock and drops the miniport/compartment references before freeing it. No path in this function was found that caches the raw context pointer beyond the lifetime of the IRP, so a reachable use-after-free tied to this free is not demonstrable from the disassembly.
3. Pseudocode Diff
Control-flow of the two builds. Comments describe the actual gate behavior.
// === UNPATCHED ndisDispatchRequest ===
// IRP_MJ_CLEANUP (major == 0x12)
void HandleCleanup(IRP* irp) {
FileObject* file = irp->Tail.Overlay.FileObject;
void* P = file->FsContext; // file+0x18
// Teardown: unlink from miniport binding list under spinlock,
// drop miniport reference, release sub-object at P+0x30.
file->FsContext = NULL;
ExFreePoolWithTag(P, 0); // free at cleanup
global_open_count--; // dword_1C00E609C
IofCompleteRequest(irp, 2);
}
// IRP_MJ_CLOSE (major == 2)
void HandleClose(IRP* irp) {
// Does nothing with the context
IofCompleteRequest(irp, 2);
}
// === PATCHED ndisDispatchRequest ===
// IRP_MJ_CLEANUP (major == 0x12)
void HandleCleanup(IRP* irp) {
// Same teardown ...
if (Feature_3473086778__private_IsEnabledDeviceUsage()) {
// Feature ON: skip free, defer to IRP_MJ_CLOSE
IofCompleteRequest(irp, 2);
} else {
// Feature OFF: free now, identical to unpatched
FileObject* file = irp->Tail.Overlay.FileObject;
void* P = file->FsContext;
file->FsContext = NULL;
ExFreePoolWithTag(P, 0);
global_open_count--; // dword_1C00E70DC
IofCompleteRequest(irp, 2);
}
}
// IRP_MJ_CLOSE (major == 2)
void HandleClose(IRP* irp) {
if (Feature_3473086778__private_IsEnabledDeviceUsage()) {
// Feature ON: free the deferred context here
FileObject* file = irp->Tail.Overlay.FileObject;
void* P = file->FsContext;
file->FsContext = NULL;
if (P) { ExFreePoolWithTag(P, 0); global_open_count--; }
}
// Feature OFF: nothing, identical to unpatched
IofCompleteRequest(irp, 2);
}
4. Assembly Analysis
UNPATCHED ndisDispatchRequest — IRP_MJ_CLEANUP free path
00000001C001D61B mov rax, [r15+30h] ; file object
00000001C001D61F mov rdi, [rax+18h] ; rdi = P (FsContext)
00000001C001D623 mov rbp, [rdi+20h] ; P->0x20 (miniport binding)
00000001C001D627 cmp [rdi+18h], ebx ; P->0x18 == 0 ?
00000001C001D62A jnz loc_1C005749E ; teardown (unlink, deref miniport)
00000001C001D630 mov rcx, [rdi+30h] ; P->0x30 sub-object
00000001C001D634 test rcx, rcx
00000001C001D637 jnz loc_1C00575E1 ; ndisIfDereferenceCompartmentForUser
00000001C001D63D mov rax, [r15+30h]
00000001C001D641 xor edx, edx ; Tag = 0
00000001C001D643 mov rcx, rdi ; P
00000001C001D646 mov [rax+18h], rbx ; FsContext = NULL
00000001C001D64A call cs:__imp_ExFreePoolWithTag ; free at cleanup (unconditional)
00000001C001D656 lock dec cs:dword_1C00E609C ; open count--
00000001C001D65D jmp loc_1C001D5BB ; complete IRP
UNPATCHED ndisDispatchRequest — IRP_MJ_CLOSE path
00000001C001D5A1 sub r8d, 2
00000001C001D5A5 jz short loc_1C001D5BB ; major==2: complete, no context handling
PATCHED ndisDispatchRequest — IRP_MJ_CLEANUP path (feature gate added)
00000001C001D732 mov rcx, [rdi+30h] ; P->0x30 sub-object
00000001C001D736 test rcx, rcx
00000001C001D739 jz short loc_1C001D744
00000001C001D73B call ?ndisIfDereferenceCompartmentForUser@@YAJPEAX@Z
00000001C001D740 mov [rdi+30h], rbx
00000001C001D744 call Feature_3473086778__private_IsEnabledDeviceUsage ; feature-staging gate
00000001C001D749 test eax, eax
00000001C001D74B jnz loc_1C001D827 ; feature ON: skip free, complete IRP
00000001C001D751 mov rax, [r15+30h]
00000001C001D755 mov rcx, rdi ; P
00000001C001D758 mov [rax+18h], rbx ; FsContext = NULL
00000001C001D75C xor edx, edx ; Tag = 0
00000001C001D75E call cs:__imp_ExFreePoolWithTag ; feature OFF: free (== unpatched)
00000001C001D76A lock dec cs:dword_1C00E70DC
00000001C001D771 jmp loc_1C001D827
PATCHED ndisDispatchRequest — IRP_MJ_CLOSE path (feature gate added)
00000001C001D78C call Feature_3473086778__private_IsEnabledDeviceUsage
00000001C001D791 test eax, eax
00000001C001D793 jz loc_1C001D827 ; feature OFF: nothing (== unpatched)
00000001C001D799 mov rax, [r15+30h] ; file object
00000001C001D79D mov rcx, [rax+18h] ; P from FsContext
00000001C001D7A1 mov [rax+18h], rbx ; FsContext = NULL
00000001C001D7A5 test rcx, rcx
00000001C001D7A8 jz short loc_1C001D76A ; skip if already NULL
00000001C001D7AA jmp short loc_1C001D75C ; free deferred context
The gate: Feature_3473086778__private_IsEnabledDeviceUsage @ 0x1C003F56C
00000001C003F576 mov eax, cs:Feature_3473086778__private_featureState
00000001C003F580 test al, 10h ; state determined?
00000001C003F582 jz short loc_1C003F589
00000001C003F584 and eax, 1 ; return cached state bit
00000001C003F587 jmp short loc_1C003F598
00000001C003F589 mov edx, 3 ; default enabled-state
00000001C003F593 call Feature_3473086778__private_IsEnabledFallback
5. Trigger Conditions
No reachable use-after-free is demonstrable from the changed code, so there is no security trigger to describe. The observable difference between the builds is limited to when the per-open 'NDoC' context is freed (during IRP_MJ_CLEANUP versus IRP_MJ_CLOSE), and only when the Feature_3473086778 DeviceUsage feature is enabled. With the feature disabled the two builds are indistinguishable at runtime.
6. Exploit Primitive & Development Notes
No exploit primitive is supported by the binaries. The per-open context free within ndisDispatchRequest is preceded, in both builds, by teardown that unlinks the context from its shared miniport binding list under a spinlock and drops the miniport and compartment references. No path was found in which the raw context pointer is retained past the IRP that frees it. The feature-staged move of the free to IRP_MJ_CLOSE is an object-lifetime hardening (defense-in-depth), not evidence of a demonstrable freed-memory access. Claims of pool spray, arbitrary kernel read/write, information disclosure, or privilege escalation are not substantiated by the code and are omitted.
7. Debugger Notes
To observe the behavioral difference on the two builds, the relevant instructions are:
Unpatched free at cleanup: ndis+ (ExFreePoolWithTag call) 0x1C001D64A
Patched cleanup gate: 0x1C001D744 (call Feature_3473086778__private_IsEnabledDeviceUsage)
Patched cleanup free: 0x1C001D75E (ExFreePoolWithTag, feature-OFF path)
Patched close gate: 0x1C001D78C (call Feature_3473086778__private_IsEnabledDeviceUsage)
Patched close free: 0x1C001D75E (ExFreePoolWithTag, feature-ON deferred path)
At the cleanup/close free instruction, rcx holds the pointer to the 0x38-byte 'NDoC' context being freed. The major function code is the byte at [r15] at function entry (0 = CREATE, 2 = CLOSE, 0xE = DEVICE_CONTROL, 0x12 = CLEANUP). These addresses only illustrate the timing difference; they do not correspond to any demonstrated fault.
Struct/Offset Notes
FileObject+0x18: per-open context pointer (FsContext).- Context (
0x38bytes, tag'NDoC',NonPagedPoolNx): +0x18: state field checked during cleanup.+0x20: miniport binding node.+0x30: compartment sub-object (released viandisIfDereferenceCompartmentForUser).
8. Changed Functions — Full Triage
ndisDispatchRequest@0x1C001D550(No security-relevant change): NDIS control-device IRP dispatch handler. Adds aFeature_3473086778__private_IsEnabledDeviceUsagegate in theIRP_MJ_CLEANUPandIRP_MJ_CLOSEpaths that, when enabled, defers the per-open context free from cleanup to close. The original free-at-cleanup path is retained when the feature is disabled. Feature-staged; not delivered by default.wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath@0x1C003DFC8(unpatched) (Non-security): Windows feature-staging state-cache helper, rebuilt as part of adding theFeature_3473086778DeviceUsage staging.wil_details_FeatureReporting_ReportUsageToService@0x1C003DE58(unpatched) (Non-security): Windows feature-staging usage-reporting helper, rebuilt for the same staging change.Feature_NdisDatapathVerifier__private_ReportDeviceUsage@0x1C003DAC4(unpatched) (Non-security): Feature-staging usage-report thunk for the DatapathVerifier feature; feature-reporting library churn.Feature_ScreenON_NAPS__private_ReportDeviceUsage@0x1C003F8EC(unpatched) (Non-security): Feature-staging usage-report thunk for the ScreenON_NAPS feature; feature-reporting library churn.
(The four helper functions relocated between builds; the addresses above are from the unpatched build. In the patched build those exact addresses are occupied by different, unrelated functions.)
9. Added / Removed Functions
- Removed: None.
- Added: The patch introduces new Windows feature-staging helper functions that implement and support the
Feature_3473086778DeviceUsage gate, includingFeature_3473086778__private_IsEnabledDeviceUsage(0x1C003F56C),Feature_3473086778__private_IsEnabledFallback(0x1C003F5A4), and thewil_details_StagingConfig_*/wil_details_GetCurrentFeatureEnabledState/wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledStatefamily. These are feature-staging configuration/state-cache plumbing, not security fixes.
Two additional functions (ndisEtwRegisterGuids, ndisDriverSystemDispatch) differ between builds only in a compile-time WPP trace-GUID symbol; this is tracing metadata churn with no control-flow change.
All security-relevant behavior remains contained in ndisDispatchRequest, whose only change is the feature-staged, config-gated deferral of the per-open context free.
10. Confidence & Caveats
- Confidence Level: High. The changed logic in
ndisDispatchRequestis fully accounted for: a single feature-staging gate that conditionally defers a free, with the original path retained. The gate is standard Windows feature-staging infrastructure, and the other four changed functions are feature-staging/reporting library helpers. - Direction: The patched build is not less strict. When the feature is enabled the free is moved to the later
IRP_MJ_CLOSEpoint; when disabled behavior is identical to the unpatched build. This is not a regression. - Caveat: Whether the
Feature_3473086778DeviceUsage feature is enabled in any given deployment is a configuration/velocity decision not determinable from the binary. Because the original free-at-cleanup path is preserved and no reachable use-after-free was demonstrable, this diff is classified as a feature-staged object-lifetime change with no delivered security impact.