1. Overview

  • Unpatched Binary: ndis_unpatched.sys
  • Patched Binary: ndis_patched.sys
  • Overall Similarity Score: 0.9897
  • Diff Statistics:
  • Matched Functions: 3583
  • Changed Functions: 40
  • Identical Functions: 3543
  • Unmatched Functions (Unpatched/Patched): 0 / 0
  • Verdict: The patch removes a WIL feature gate (Feature_NDPQualityWinter25__private_IsEnabledDeviceUsageNoInline) binary-wide (present 24 times in the unpatched build, 0 times in the patched build) and folds the affected code to the safe path. This is a delivered fix, not a staged rollout: the old (feature-off) path is not retained in any else-branch. Two genuine memory-safety defects are corrected. (1) In ndisReferenceOpenByHandle and ndisPktMonRegisterAllOpens, the feature-off path checks an _NDIS_OPEN_BLOCK reference count outside the spinlock that guards it and then calls a blind increment helper (ndisMReferenceOpen), a reference-count race (CWE-362) that can resurrect a reference on an open block being torn down (use-after-free, CWE-416); the patched build always uses the check-under-lock helper ndisMTryReferenceOpen. (2) In ndisIfAliasChange, a 16-bit length subtraction can underflow when a length validation is gated off, producing an out-of-bounds read (CWE-191 → CWE-125); the patched build performs the length check unconditionally.

2. Vulnerability Summary

Finding 1: Open-block reference-count race in ndisReferenceOpenByHandle (Medium)

  • Vulnerability Class: Reference-count race condition (CWE-362) leading to use-after-free (CWE-416).
  • Affected Function: ndisReferenceOpenByHandle (0x1400119A0 unpatched, 0x140011920 patched).
  • Root Cause: The unpatched function selects between two reference paths using the WIL feature gate Feature_NDPQualityWinter25__private_IsEnabledDeviceUsageNoInline (0x14009B460). When the gate is off, it reads the open-block reference count at openblock+0xe4 at 0x140011a6b while holding the global open-list lock and the per-open-block lock at openblock+0xe8, but not the spinlock at openblock+0x258 that actually guards +0xe4. If the count is non-zero it calls ndisMReferenceOpen (0x140059890), which acquires +0x258 and increments +0xe4 unconditionally. A concurrent thread that decrements the count to zero under +0x258 between the unlocked read and the increment causes the blind increment to resurrect a reference on an open block that is being freed. The gate-on / patched path uses ndisMTryReferenceOpen, which acquires +0x258 and re-checks +0xe4 before referencing, closing the window.
  • Attacker-Reachable Entry Point & Data Flow:
  • ndisReferenceOpenByHandle is a shared helper that looks up an open block by handle in ndisGlobalOpenList and takes a reference.
  • It is called from ndisMOidRequest (0x1400117F0, call site 0x140011814) and five other open-block reference sites; OID request routing by open handle is reachable from NDIS protocol/filter operations.
  • Triggering the race requires referencing an open block by handle concurrently with that binding being closed/torn down.

Finding 2: Open-block reference-count race in ndisPktMonRegisterAllOpens (Medium)

  • Vulnerability Class: Reference-count race condition (CWE-362) leading to use-after-free (CWE-416).
  • Affected Function: ndisPktMonRegisterAllOpens (0x1400B6668 unpatched, 0x1400B6420 patched).
  • Root Cause: Same pattern as Finding 1. The unpatched feature-off path checks openblock+0xe4 at 0x1400b66e8 under the open-block lock at +0xe8 and then calls the blind ndisMReferenceOpen (0x1400b66f6) which re-acquires +0x258 and increments unconditionally. The patched version (0x1400B6420) removes the gate and always calls ndisMTryReferenceOpen (0x1400b646e).
  • Attacker-Reachable Entry Point & Data Flow:
  • ndisPktMonRegisterComponentsCallback (0x1400B6890) — invoked when PktMon (packet-capture) component registration runs.
  • ndisPktMonRegisterAllOpens — walks the open-block list and references each open block to register it with PktMon.
  • ndisMReferenceOpen (unsafe increment), racing concurrent open-block teardown.

Finding 3: Interface-alias length underflow in ndisIfAliasChange (Medium)

  • Vulnerability Class: Integer underflow (CWE-191) leading to out-of-bounds read (CWE-125).
  • Affected Function: ndisIfAliasChange (0x1400CFE18 unpatched, 0x1400CFB48 patched).
  • Root Cause: The unpatched function only enforces the length check a1->Length < ndisDeviceStr.Length when the cached feature flag ndisNDPQualityWinter25IsEnabled is set (if ( ndisNDPQualityWinter25IsEnabled != 0 && a1->Length < ndisDeviceStr.Length )). When the feature is off, the check is skipped and a1->Length - ndisDeviceStr.Length (a USHORT subtraction) underflows to a large value that becomes GuidString.Length, while GuidString.Buffer is advanced past the end of a1->Buffer. RtlGUIDFromString then reads out of bounds. The patched function performs if ( a1->Length < ndisDeviceStr.Length ) unconditionally.
  • Attacker-Reachable Entry Point & Data Flow:
  • ndisDriverDispatch (0x140044F80) — driver IRP dispatch.
  • ndisDispatchRequest (0x140044080).
  • ndisHandlePnPRequest (0x140184EB0).
  • ndisHandleUModePnPOp (0x140075D00) — handles user-mode PnP operations; calls ndisIfAliasChange at 0x140075EF8.
  • ndisIfAliasChange (0x1400CFE18), with the interface-name string a1 supplied through the request.

3. Pseudocode Diff

ndisReferenceOpenByHandle (Finding 1)

The unpatched function selects the path with the feature gate. The feature-off path reads the count outside the +0x258 lock and then blind-increments.

// --- UNPATCHED ndisReferenceOpenByHandle @ 0x1400119A0 ---
KeAcquireSpinLockAtDpcLevel(openblock + 0xe8);
if (Feature_NDPQualityWinter25__private_IsEnabledDeviceUsageNoInline()) {
    // feature ON: safe path
    if (!TEST_BIT(*(openblock + 0xe0), 0xf))
        rdi = ndisMTryReferenceOpen(openblock, tag);   // checks *(openblock+0xe4) INSIDE +0x258 lock
}
// feature OFF: unsafe path
else if (!TEST_BIT(*(openblock + 0xe0), 0xf) && *(openblock + 0xe4)) { // <-- read outside +0x258 lock
    ndisMReferenceOpen(openblock, tag);                // <-- increments unconditionally under +0x258 (race)
    rdi = 1;
}
KeReleaseSpinLockFromDpcLevel(openblock + 0xe8);

// --- PATCHED ndisReferenceOpenByHandle @ 0x140011920 ---
KeAcquireSpinLockRaiseToDpc(openblock + 0x258);
if (*(openblock + 0xe4)) {                             // check INSIDE the +0x258 lock
    NdisReferenceWithTag(*(openblock + 0x250), tag);
    *(openblock + 0xe4) += 1;
}
KeReleaseSpinLock(openblock + 0x258, ...);

ndisMReferenceOpen vs ndisMTryReferenceOpen (helpers)

The difference between the unsafe and safe reference helpers used in Findings 1 and 2.

// --- UNSAFE HELPER: ndisMReferenceOpen @ 0x140059890 ---
void ndisMReferenceOpen(openblock, tag) {
    KeAcquireSpinLockRaiseToDpc(openblock + 0x258);
    // *** no re-check of *(openblock+0xe4) ***
    NdisReferenceWithTag(*(openblock + 0x250), tag);
    *(openblock + 0xe4) += 1;                          // <-- always increments
    KeReleaseSpinLock(openblock + 0x258, ...);
}

// --- SAFE HELPER: ndisMTryReferenceOpen @ 0x1400a0b60 ---
char ndisMTryReferenceOpen(openblock, tag) {
    char ok = 0;
    KeAcquireSpinLockRaiseToDpc(openblock + 0x258);
    if (*(openblock + 0xe4)) {                         // <-- check INSIDE +0x258 lock
        NdisReferenceWithTag(*(openblock + 0x250), tag);
        *(openblock + 0xe4) += 1;
        ok = 1;
    }
    KeReleaseSpinLock(openblock + 0x258, ...);
    return ok;
}

ndisIfAliasChange (Finding 3)

The length check is gated off in the unpatched build and unconditional in the patched build.

// --- UNPATCHED ndisIfAliasChange @ 0x1400CFE18 ---
if ( ndisNDPQualityWinter25IsEnabled != 0 && a1->Length < ndisDeviceStr.Length )
    goto fail;                                         // only checked when feature ON
v7 = a1->Length - ndisDeviceStr.Length;                // USHORT subtraction, underflows if unchecked
GuidString.Buffer = &a1->Buffer[ndisDeviceStr.Length >> 1];
GuidString.Length = v7;
GuidString.MaximumLength = v7 + 2;
RtlGUIDFromString(&GuidString, &Guid);                 // OOB read when v7 underflowed

// --- PATCHED ndisIfAliasChange @ 0x1400CFB48 ---
if ( a1->Length < ndisDeviceStr.Length )
    goto fail;                                         // always enforced
GuidString.Length = a1->Length - ndisDeviceStr.Length;
GuidString.MaximumLength = a1->Length - ndisDeviceStr.Length + 2;
GuidString.Buffer = &a1->Buffer[ndisDeviceStr.Length >> 1];
RtlGUIDFromString(&GuidString, &Guid);

4. Assembly Analysis

ndisReferenceOpenByHandle (Unpatched, 0x1400119A0)

The feature-off path reads openblock+0xe4 before entering the blind increment helper.

0000000140011A08  call    cs:__imp_KeAcquireSpinLockRaiseToDpc     ; ndisGlobalOpenListLock
0000000140011A3E  lea     rcx, [rbx+0E8h]                          ; per-open-block lock
0000000140011A4A  call    cs:__imp_KeAcquireSpinLockAtDpcLevel
0000000140011A56  call    Feature_NDPQualityWinter25__private_IsEnabledDeviceUsageNoInline
0000000140011A5B  test    eax, eax
0000000140011A5D  mov     eax, [rbx+0E0h]
0000000140011A63  jnz     short loc_140011A85                      ; feature ON -> safe path
0000000140011A65  bt      eax, 0Fh
0000000140011A69  jb      short loc_140011A9A
0000000140011A6B  cmp     dword ptr [rbx+0E4h], 0                  ; read count WITHOUT +0x258 lock
0000000140011A72  jz      short loc_140011A9A
0000000140011A74  movzx   edx, bpl
0000000140011A78  mov     rcx, rbx
0000000140011A7B  call    ?ndisMReferenceOpen@@YAXPEAU_NDIS_OPEN_BLOCK@@W4_NDIS_OPEN_REFTAG@@@Z
0000000140011A80  mov     dil, 1
0000000140011A83  jmp     short loc_140011A9A
0000000140011A85  bt      eax, 0Fh                                 ; feature ON path
0000000140011A89  jb      short loc_140011A9A
0000000140011A92  call    ?ndisMTryReferenceOpen@@YAEPEAU_NDIS_OPEN_BLOCK@@W4_NDIS_OPEN_REFTAG@@@Z

ndisMReferenceOpen (Unsafe helper, 0x140059890)

The helper acquires +0x258 but increments with no liveness check.

00000001400598AA  add     rcx, 258h
00000001400598B1  call    cs:__imp_KeAcquireSpinLockRaiseToDpc     ; lock openblock+0x258
00000001400598BD  mov     rcx, [rsi+250h]
00000001400598CB  call    NdisReferenceWithTag
00000001400598D0  inc     dword ptr [rsi+0E4h]                     ; unconditional increment
00000001400598E0  call    cs:__imp_KeReleaseSpinLock

ndisMTryReferenceOpen (Safe helper, 0x1400a0b60)

The helper acquires +0x258 and re-checks +0xe4 before referencing.

00000001400A0B8B  call    cs:__imp_KeAcquireSpinLockRaiseToDpc     ; lock openblock+0x258
00000001400A0B9A  cmp     [rbx+0E4h], edi                         ; check INSIDE lock (edi=0)
00000001400A0BA0  jz      short loc_1400A0BBA                      ; skip if zero
00000001400A0BA2  mov     rcx, [rbx+250h]
00000001400A0BAC  call    NdisReferenceWithTag
00000001400A0BB1  inc     dword ptr [rbx+0E4h]                     ; increment only after check
00000001400A0BB7  mov     dil, 1
00000001400A0BC0  call    cs:__imp_KeReleaseSpinLock

5. Trigger Conditions

To trigger the reference-count race (Findings 1 and 2):

  1. Setup: On a system running the unpatched ndis.sys where the NDPQualityWinter25 feature is off, register an NDIS protocol (NdisRegisterProtocolEx) or lightweight filter (NdisFRegisterFilterDriver) so open blocks (protocol↔miniport bindings) exist.
  2. Concurrency: Reference an open block by handle (e.g. OID routing through ndisMOidRequest, or PktMon registration for Finding 2) while concurrently tearing the same binding down so another thread decrements openblock+0xe4 to zero under the openblock+0x258 spinlock.
  3. Race Window: The window is between the unlocked read at 0x140011a6b (Finding 1) / 0x1400b66e8 (Finding 2) and the locked increment at 0x1400598d0 inside ndisMReferenceOpen.
  4. Observable Effect: If the count reaches zero during the window, the blind increment resurrects a reference on an open block being freed, which can lead to use-after-free when the object is later dereferenced. A kernel bugcheck (e.g. 0xD1 DRIVER_IRQL_NOT_LESS_OR_EQUAL or 0x50 PAGE_FAULT_IN_NONPAGED_AREA) is a possible outcome; no controlled exploitation primitive is demonstrated by the binaries.

To trigger the interface-alias underflow (Finding 3): issue a user-mode PnP/interface request (reaching ndisHandleUModePnPOpndisIfAliasChange) with an interface-name string whose Length field is smaller than the ndisDeviceStr device-prefix length. On the unpatched build with the feature off, the length check is skipped and the USHORT subtraction underflows, causing RtlGUIDFromString to read out of bounds.


6. Exploit Primitive & Development Notes

  • Primitive (Findings 1 & 2): A reference-count race on an _NDIS_OPEN_BLOCK (NonPagedPool). The blind increment can resurrect a reference on an open block whose count reached zero, a use-after-free condition. The binaries do not demonstrate any controlled read/write, information-leak, or code-execution primitive built on this; impact beyond kernel memory corruption / bugcheck is not established by the diff.
  • Primitive (Finding 3): An out-of-bounds read driven by a 16-bit length underflow, feeding an over-long UNICODE_STRING to RtlGUIDFromString. Impact is limited to reading kernel memory adjacent to the caller-supplied string (potential information disclosure or bugcheck); no out-of-bounds write is present (the memmove into ifAlias is separately clamped to 0x200).
  • Notes: The fix is delivered by removing the NDPQualityWinter25 feature gate binary-wide and always taking the checked paths (ndisMTryReferenceOpen; unconditional length check). The patched build retains no feature-off branch.

7. Debugger Playbook

For an analyst using a kernel debugger on the unpatched binary to observe the race:

  • Breakpoints:
  • ndis!ndisReferenceOpenByHandle+0xcb (0x140011a6b) — the unlocked read cmp dword ptr [rbx+0E4h], 0; rbx is the _NDIS_OPEN_BLOCK. The +0x258 spinlock is not held here.
  • ndis!ndisMReferenceOpen+0x40 (0x1400598d0) — the unconditional inc dword ptr [rsi+0E4h]; rsi is the open block. Compare [rsi+0xe4] here to the value read at 0x140011a6b.
  • What to Inspect:
  • At 0x140011a6b: rbx (open block pointer) and [rbx+0xe4] (reference count read outside +0x258).
  • At 0x1400598d0: rsi (open block pointer) and [rsi+0xe4] (count being incremented). Watch for concurrent decrements of +0xe4 from open-block close/teardown paths under +0x258.
  • Key Offsets:
  • 0x140011a56 — call to the NDPQualityWinter25 feature gate (selects safe vs unsafe path).
  • 0x140011a6b — read of +0xe4 outside the +0x258 spinlock.
  • 0x140011a7b — call to ndisMReferenceOpen (unsafe path).
  • 0x140011a92 — call to ndisMTryReferenceOpen (safe path, for comparison).
  • 0x1400598d0 — unconditional increment inside ndisMReferenceOpen.
  • 0x1400a0b9a — the correct check inside the +0x258 lock in ndisMTryReferenceOpen.
  • Open-block offsets: +0xe0 flags dword (bit 0xf = closing/teardown state), +0xe4 reference count, +0x250 refcount block passed to NdisReferenceWithTag, +0x258 spinlock guarding +0xe4, +0xe8 per-open-block spinlock, +0x180 next pointer. Global open list at ndisGlobalOpenList, guarded by ndisGlobalOpenListLock.

8. Changed Functions — Full Triage

Security-relevant changes

  • ndisReferenceOpenByHandle (0x1400119A00x140011920): [Finding 1] Feature gate removed; the unlocked check plus blind ndisMReferenceOpen path is eliminated. The patched version acquires +0x258, checks +0xe4, and increments only if non-zero.
  • ndisPktMonRegisterAllOpens (0x1400B66680x1400B6420): [Finding 2] Same fix in the PktMon open-registration path; always uses ndisMTryReferenceOpen.
  • ndisIfAliasChange (0x1400CFE180x1400CFB48): [Finding 3] The a1->Length < ndisDeviceStr.Length check is made unconditional, preventing the USHORT underflow that fed an out-of-bounds read to RtlGUIDFromString.

Supporting / behavioral changes

  • ndisReadRegistry (0x14009DA20): Removed the assignment ndisNDPQualityWinter25IsEnabled = 1 (guarded by the feature gate). This global no longer exists in the patched build, which is why the ndisIfAliasChange check is always taken.
  • DriverEntry (0x140191234): Removed the NDPQualityWinter25 feature-gate initialization call.

Other feature-gate touchpoints (no security impact)

The NDPQualityWinter25 gate appeared in eleven further functions, none of which fixes a memory-safety, reference-count, or bounds defect: * Identical dead branches (both arms of the gate performed the same operation; patched folds to the single statement): the NDIS filter flag accessors FILTER_SET_FLAG, FILTER_CLEAR_FLAG, FILTER_CLEAR_ALL_STATE_FLAGS, FILTER_TEST_FLAG, plus ndisFIndicateStatusInternal and ndisMRawIndicateStatusEx (their gated ternaries wrap those same flag helpers with identical arms and unchanged locking). * Discarded usage-telemetry call removed (return value ignored, no control-flow effect): ndisFilterSendNetBufferLists, ndisSendNBLToFilter, NdisQueryPendingIOCount (its check-then-compute is already inside the spinlock in both builds, so it is not the Finding 1 pattern). * Fixed-size output-field population / refactor: ndisBindReadProtocolDriverFromV2Registry and ndisBindReadProtocolDriverFromV3Registry gate whether a fully-initialized 16-byte Guid is copied into a fixed output-struct field; the patched build always copies it (and V3 additionally replaces hand-rolled registry parsing with helper calls). No bounds or lifetime defect on the feature-off path.

Cosmetic / register-allocation changes

The remaining changed functions differ only by compiler register reassignment, instruction reordering, WPP trace-GUID relocation, or the removal of the now-unused feature-gate stub functions (Feature_NDPQualityWinter25__private_IsEnabled*). No behavioral or security impact.


9. Unmatched Functions

  • Removed: 0 NDIS routines. (The Feature_NDPQualityWinter25__private_IsEnabled* stub functions and their fallbacks are gone in the patched build, consistent with the gate being removed.)
  • Added: 0

10. Confidence & Caveats

  • Confidence: High that the change is a delivered fix: the NDPQualityWinter25 gate is present 24 times in the unpatched build and 0 times in the patched build, with the code folded to the checked paths in every case and no feature-off branch retained.
  • Findings 1 & 2: The reference-count race is real (check outside +0x258, blind increment under +0x258, fixed by the check-under-lock helper). Severity is rated Medium: it is a local kernel race with use-after-free potential, but the binaries demonstrate no controlled exploitation primitive, and winning the window requires concurrent open-block teardown.
  • Finding 3: The integer underflow and resulting out-of-bounds read are real and reachable from a user-mode PnP request. Impact is a kernel out-of-bounds read (information disclosure or bugcheck); there is no out-of-bounds write on this path.
  • Assumptions: The unsafe behavior is active when the NDPQualityWinter25 feature is off. The exact default state of that feature on shipping systems is not determinable from the binaries.