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_2869107002 staging 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 NonPagedPool allocation, tag 'NDoc' (0x636F444E), which is the file object's FsContext. It is allocated in IRP_MJ_CREATE (case 0) and stored at FileObject->FsContext (FILE_OBJECT+0x18), where FileObject = IrpSp->FileObject (IO_STACK_LOCATION+0x30) and IrpSp is 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's FsContext (0x1400424F30x140042577), and increments a global open counter dword_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]P into r13), performs spinlock-protected miniport/adapter list work, then at the tail nulls the stored slot and frees P.
  • case 2 (IRP_MJ_CLOSE) in the unpatched build does nothing with P — 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, read P from FileObject->FsContext, null the slot, and free P if 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_CLEANUP fires once per file object. IRP_MJ_DEVICE_CONTROL (case 0x0E) routes to ndisHandlePnPRequest and does not free P; IRP_MJ_SYSTEM_CONTROL (case 0x17) routes to ndisDriverSystemDispatch. There is no second dispatch path that frees the same block, so no concurrent double-free of P is 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_CLEANUP with the feature off, or IRP_MJ_CLOSE with 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 stored FsContext pointer 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_CLEANUP to IRP_MJ_CLOSE only 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-byte NonPagedPool block is the file object's FsContext, set in IRP_MJ_CREATE at 0x140042573 (mov [FileObject+18h], P).
  • The global open counter dword_140120FC0 is incremented in IRP_MJ_CREATE (0x140042577) and decremented on the free path (0x1400429EC patched / 0x1400429DF unpatched).
  • The gate function Feature_2869107002__private_IsEnabledDeviceUsageNoInline (0x1400A790C) reads Feature_2869107002__private_featureState and, unless bit 0x10 is set, falls back to Feature_2869107002__private_IsEnabledFallback. Its return value determines whether the free happens in IRP_MJ_CLEANUP or IRP_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 the IRP_MJ_CLEANUP teardown tail (0x1400429C1) so the 'NDoc' free is skipped when the feature is enabled.
  • Split IRP_MJ_CLOSE (case 2) 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_140103E31byte_140103E2D) and case 2 became its own jumptable target instead of aliasing the default completion path.
  • What did not change: the miniport/adapter list teardown logic, the IRP_MJ_CREATE allocation, 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_14006EC39locret_14006EC69, a constant +0x30 shift; switch-table base 0x1041460x104142). 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) and Feature_2869107002__private_IsEnabledFallback — the WIL feature-staging machinery for feature 2869107002 ("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 (reads Feature_2869107002__private_featureState, else calls the fallback), not an object-state or bounds validation.
  • The alternative behavior (free at IRP_MJ_CLOSE instead of IRP_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.