ndis.sys — WIL feature-staged relocation of per-file-object context free
KB5079473
1. Overview
- Unpatched binary:
ndis_unpatched.sys - Patched binary:
ndis_patched.sys - Overall similarity: 0.992
- Function diff stats:
- Matched: 3585
- Changed (logic): 1 (
ndisDispatchRequest) - Identical: 3584
- Unmatched (unpatched): 0
- Unmatched (patched): 2 (the two
Feature_2869107002staging helpers, present only in the patched build)
Verdict: The only behavioral change between the two builds is inside ndis!ndisDispatchRequest (0x140042480, the driver's IRP dispatch handler). The change inserts a WIL (Windows feature-staging) gate, Feature_2869107002__private_IsEnabledDeviceUsageNoInline (0x1400A790C), that — only when the feature is enabled — moves the free of the per-file-object context block ('NDoc', 0x40-byte NonPagedPool) from the IRP_MJ_CLEANUP path (case 0x12) to the IRP_MJ_CLOSE path (case 2). When the feature is disabled (the delivered default, which reproduces the unpatched behavior exactly), the block is still freed in IRP_MJ_CLEANUP and the two builds behave identically. No security-relevant change is delivered. An independent function-level diff confirms every other function that differs (about 86) differs only in relocated absolute jump/data addresses caused by the two inserted feature helpers shifting downstream code; none contains a logic change.
2. Vulnerability Summary
Finding 1 — Informational: WIL feature-staged relocation of the per-file-object context free (no delivered security change)
- Severity: None (informational)
- Classification: No security-relevant change (feature-staging / servicing churn)
- CWE: none applicable
- Affected function:
ndis!ndisDispatchRequest(0x140042480), the driver's IRP major-function dispatch handler. - Object involved: 0x40-byte
NonPagedPoolallocation, tag'NDoc'(0x636F444E), which is the file object'sFsContext. It is allocated inIRP_MJ_CREATE(case0) and stored atFileObject->FsContext(FILE_OBJECT+0x18), whereFileObject = IrpSp->FileObject(IO_STACK_LOCATION+0x30) andIrpSpis the current stack location (IRP+0xB8).
What the code actually does
ndisDispatchRequest switches on IrpSp->MajorFunction (the byte at IO_STACK_LOCATION+0) with 24 cases. The relevant cases:
- case
0(IRP_MJ_CREATE) allocates the 0x40-byte'NDoc'block and stores it as the file object'sFsContext(0x1400424F3–0x140042577), and increments a global open counterdword_140120FC0. - case
0x12(IRP_MJ_CLEANUP) is the per-handle teardown. It reads the context (0x140042583: mov rax,[rdi+30h]→ FileObject;0x140042587: mov r13,[rax+18h]→Pintor13), performs spinlock-protected miniport/adapter list work, then at the tail nulls the stored slot and freesP. - case
2(IRP_MJ_CLOSE) in the unpatched build does nothing withP— it shares the completion tail and just completes the IRP.
In the unpatched teardown tail (0x1400429C1), P is already held in a register captured at CLEANUP entry (r13), and the stored slot is nulled immediately before the free:
0x1400429CB mov rax, [rax+30h] ; FileObject
0x1400429CF mov [rax+18h], r15 ; FileObject->FsContext = 0 (nulled before free)
0x1400429D3 call ExFreePoolWithTag ; free(P)
IRP_MJ_CLEANUP is issued once per file object (when the last user-mode handle reference is released), so this path does not double-free P by itself, and it does clear the stored pointer before freeing.
What the patch changes
The patch inserts the WIL feature gate Feature_2869107002__private_IsEnabledDeviceUsageNoInline (0x1400A790C) at two points and splits IRP_MJ_CLOSE (case 2) out of the shared completion tail into its own handler:
- case
0x12(CLEANUP), tail (0x1400429C1): if the feature is enabled, skip the free entirely and complete; if disabled, run the original null-then-free. - case
2(CLOSE), new handler (0x140042D27): if the feature is disabled, just complete (unpatched behavior); if enabled, readPfromFileObject->FsContext, null the slot, and freePif non-null.
Net effect: when the feature is on, the 'NDoc' free moves from IRP_MJ_CLEANUP to IRP_MJ_CLOSE; when the feature is off (the shipped default, identical to the unpatched build), nothing changes. The feature helper (0x1400A790C) simply reads Feature_2869107002__private_featureState, tests bit 0x10, and otherwise calls Feature_2869107002__private_IsEnabledFallback — it is a feature-enablement check, not an object-state validation.
Why this is not a delivered security fix
- It is gated behind a default-off WIL feature stage with the old path fully retained in the else branch. The delivered behavior of both builds is identical; the alternative path only executes under servicing-controlled feature enablement.
- The change is a lifetime relocation (free during handle teardown vs. free during final close), not the insertion of any bounds check, reference check, or synchronization primitive. Both builds already null the stored pointer before freeing.
- No attacker-triggerable free race is present.
IRP_MJ_CLEANUPfires once per file object.IRP_MJ_DEVICE_CONTROL(case0x0E) routes tondisHandlePnPRequestand does not freeP;IRP_MJ_SYSTEM_CONTROL(case0x17) routes tondisDriverSystemDispatch. There is no second dispatch path that frees the same block, so no concurrent double-free ofPis reachable from this function.
Reachability
ndisDispatchRequest is the NDIS control-device IRP dispatcher and is reachable from user mode subject to the device security descriptor (ndisSecurityDescriptor, applied via ndisCheckAccess in the IRP_MJ_CREATE path). Reaching the dispatcher is normal driver operation; the changed code path is the ordinary per-handle teardown, not an attacker-controlled primitive.
3. Pseudocode Diff
The following reflects the real switch and the inserted feature gate.
// ndisDispatchRequest (both builds) — switch on IrpSp->MajorFunction
case IRP_MJ_CREATE: // case 0
P = ExAllocatePool2(NonPagedPool, 0x40, 'NDoc');
FileObject->FsContext = P; // *(FileObject+0x18) = P
dword_140120FC0++;
break;
case IRP_MJ_CLEANUP: // case 0x12 (per-handle teardown; P held in r13)
... spinlock-protected miniport/adapter list work ...
teardown_tail: // 0x1400429C1
// UNPATCHED:
FileObject->FsContext = 0;
ExFreePoolWithTag(P, 0);
dword_140120FC0--;
// PATCHED:
if (Feature_2869107002__IsEnabled()) // 0x1400A790C, WIL feature stage
goto complete; // feature ON: do not free here
FileObject->FsContext = 0; // feature OFF: original path
ExFreePoolWithTag(P, 0);
dword_140120FC0--;
break;
case IRP_MJ_CLOSE: // case 2
// UNPATCHED: no cleanup of P; just complete.
// PATCHED (0x140042D27):
if (!Feature_2869107002__IsEnabled()) // feature OFF: unpatched behavior
goto complete;
P = FileObject->FsContext; // feature ON: free here instead
FileObject->FsContext = 0;
if (P) ExFreePoolWithTag(P, 0);
break;
}
4. Assembly Analysis
UNPATCHED — ndisDispatchRequest case 0x12 (CLEANUP) teardown tail
0x1400429C1 : mov rax, [rsp+arg_8] ; rax = IrpSp (saved at entry)
0x1400429C6 : xor edx, edx ; Tag = 0
0x1400429C8 : mov rcx, r13 ; rcx = P (captured at CLEANUP entry)
0x1400429CB : mov rax, [rax+30h] ; rax = FileObject
0x1400429CF : mov [rax+18h], r15 ; FileObject->FsContext = 0
0x1400429D3 : call cs:__imp_ExFreePoolWithTag ; free(P)
0x1400429DF : lock dec cs:dword_140120FC0 ; open counter--
0x1400429E6 : jmp loc_140042D42 ; complete IRP
; Case 2 (IRP_MJ_CLOSE): no dedicated handler — shares the completion tail
; (jumptable "cases 2,15" -> loc_140042D42), performs no cleanup of P.
PATCHED — same function, feature-gate insertions
; case 0x12 (CLEANUP) teardown tail — feature gate added
0x1400429C1 : call Feature_2869107002__private_IsEnabledDeviceUsageNoInline ; 0x1400A790C
0x1400429C6 : test eax, eax
0x1400429C8 : jnz loc_140042D71 ; feature ON -> skip free, just complete
0x1400429CE : mov rax, [rsp+arg_8] ; feature OFF -> original path:
0x1400429D3 : mov rcx, r13 ; P
0x1400429D6 : mov rax, [rax+30h] ; FileObject
0x1400429DA : mov [rax+18h], r15 ; FileObject->FsContext = 0
0x1400429DE : xor edx, edx ; Tag = 0
0x1400429E0 : call cs:__imp_ExFreePoolWithTag ; free(P)
0x1400429EC : lock dec cs:dword_140120FC0
0x1400429F3 : jmp loc_140042D71
; case 2 (IRP_MJ_CLOSE) — NEW dedicated handler
0x140042D27 : call Feature_2869107002__private_IsEnabledDeviceUsageNoInline
0x140042D2C : test eax, eax
0x140042D2E : jz loc_140042D71 ; feature OFF -> complete (unpatched behavior)
0x140042D30 : mov rax, [rdi+30h] ; FileObject
0x140042D34 : mov rcx, [rax+18h] ; P = FileObject->FsContext
0x140042D38 : mov [rax+18h], r15 ; FileObject->FsContext = 0
0x140042D3C : test rcx, rcx
0x140042D3F : jz loc_1400429EC ; P == 0 -> skip free
0x140042D45 : jmp loc_1400429DE ; else -> shared free path
Feature helper (patched only)
; Feature_2869107002__private_IsEnabledDeviceUsageNoInline @ 0x1400A790C
0x1400A7916 : mov eax, cs:Feature_2869107002__private_featureState
0x1400A7920 : test al, 10h
0x1400A7922 : jz short 0x1400A7929
0x1400A7924 : and eax, 1
0x1400A7927 : jmp short 0x1400A7938
0x1400A7929 : mov edx, 3
0x1400A7933 : call Feature_2869107002__private_IsEnabledFallback
0x1400A7938 : add rsp, 28h
0x1400A793C : retn
Side-by-side summary
| Concern | UNPATCHED | PATCHED (feature OFF = default) | PATCHED (feature ON) |
|---|---|---|---|
'NDoc' free site |
IRP_MJ_CLEANUP |
IRP_MJ_CLEANUP (identical) |
IRP_MJ_CLOSE |
FsContext nulled before free |
yes | yes | yes |
IRP_MJ_CLOSE cleanup of P |
none | none (identical) | capture-null-free |
Feature helper 0x1400A790C |
absent | present, returns disabled | present, returns enabled |
5. Trigger Conditions
There is no vulnerability to trigger. The changed code path is the ordinary per-handle teardown/close of the NDIS control device's file object. Opening \Device\NDIS, issuing IOCTLs, and closing the handle exercises ndisDispatchRequest, but:
- The
'NDoc'block is allocated once per open (IRP_MJ_CREATE) and freed once per teardown (IRP_MJ_CLEANUPwith the feature off, orIRP_MJ_CLOSEwith the feature on). No dispatch path frees it twice. - With the shipped default (feature off) the patched and unpatched code are byte-for-byte equivalent along this path.
No pool-corruption, use-after-free, or double-free condition is reachable from this change.
6. Exploit Primitive & Development Notes
None. This change delivers no exploitable primitive:
- No double-free: only one dispatch path frees the
'NDoc'block, and the storedFsContextpointer is nulled before the free in both builds. - No use-after-free primitive is demonstrable from the changed code; the relocation of the free from
IRP_MJ_CLEANUPtoIRP_MJ_CLOSEonly takes effect under a default-off feature stage. - No information leak, arbitrary read/write, or control-flow primitive is introduced or fixed here.
7. Analysis Notes
This finding is a WIL feature-staging change, not a security patch, so there is no proof-of-concept to reproduce. For reference, the observable facts in the binaries are:
- The
'NDoc'(0x636F444E) 0x40-byteNonPagedPoolblock is the file object'sFsContext, set inIRP_MJ_CREATEat0x140042573(mov [FileObject+18h], P). - The global open counter
dword_140120FC0is incremented inIRP_MJ_CREATE(0x140042577) and decremented on the free path (0x1400429ECpatched /0x1400429DFunpatched). - The gate function
Feature_2869107002__private_IsEnabledDeviceUsageNoInline(0x1400A790C) readsFeature_2869107002__private_featureStateand, unless bit0x10is set, falls back toFeature_2869107002__private_IsEnabledFallback. Its return value determines whether the free happens inIRP_MJ_CLEANUPorIRP_MJ_CLOSE.
There is no reachable fault, bugcheck, or corruption to observe from this change on the default (feature-off) configuration.
8. Changed Functions — Full Triage
ndisDispatchRequest (0x140042480) — similarity 0.9577 — feature-staging (not security-relevant)
- What changed (behavioral):
- Inserted the WIL feature gate
Feature_2869107002__private_IsEnabledDeviceUsageNoInline(0x1400A790C) at theIRP_MJ_CLEANUPteardown tail (0x1400429C1) so the'NDoc'free is skipped when the feature is enabled. - Split
IRP_MJ_CLOSE(case2) out of the shared completion tail into a dedicated handler (0x140042D27) that, when the feature is enabled, performs the'NDoc'capture-null-free; when disabled it behaves exactly like the unpatched build (no cleanup). - Consequently the switch jump-table index array moved (
byte_140103E31→byte_140103E2D) and case2became its own jumptable target instead of aliasing the default completion path. - What did not change: the miniport/adapter list teardown logic, the
IRP_MJ_CREATEallocation, the pointer-nulling-before-free ordering, and all inline call targets are identical. The block at[rdi+3D7h]/gs:188h/gs:1A4h(the SmFx state-machine tail) was relocated but is instruction-for-instruction identical between builds. - Assessment: feature-gated with the old path retained and off by default; delivered behavior is identical to the unpatched build. Not a security fix.
Other functions (~86) reported as differing
Confirmed by an independent content-level diff to differ only in relocated absolute addresses (e.g. locret_14006EC39 → locret_14006EC69, a constant +0x30 shift; switch-table base 0x104146 → 0x104142). These shifts are caused by the two inserted Feature_2869107002 helpers moving downstream code and data. None contains a logic change and none is security-relevant.
9. Unmatched Functions
- Removed: none.
- Added (patched only):
Feature_2869107002__private_IsEnabledDeviceUsageNoInline(0x1400A790C) andFeature_2869107002__private_IsEnabledFallback— the WIL feature-staging machinery for feature2869107002("DeviceUsage"). These are not security functions; they gate the relocation described above.
10. Confidence & Caveats
Confidence: High that this is a WIL feature-staging change and not a delivered security fix.
- The single logic change is fully explained by the inserted feature gate
0x1400A790C, which is a feature-enablement check (readsFeature_2869107002__private_featureState, else calls the fallback), not an object-state or bounds validation. - The alternative behavior (free at
IRP_MJ_CLOSEinstead ofIRP_MJ_CLEANUP) only executes when the feature is enabled; the shipped default reproduces the unpatched behavior exactly. - No double-free or use-after-free is reachable from this function: the block is freed on a single, once-per-file-object path, and the stored pointer is nulled before the free in both builds.
- The remaining function differences are relocation artifacts from the two inserted feature helpers, not logic changes.
Caveat: the runtime default state of feature 2869107002 is controlled by servicing/feature configuration and cannot be fully determined from these binaries alone; regardless of that state, the change is a staged rollout with the prior path retained and therefore does not constitute a delivered security fix.