ndis.sys — NDIS open-block reference-count use-after-free race and interface-alias integer-underflow out-of-bounds read (CWE-416/CWE-125) fixed
KB5077181
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) InndisReferenceOpenByHandleandndisPktMonRegisterAllOpens, the feature-off path checks an_NDIS_OPEN_BLOCKreference 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 helperndisMTryReferenceOpen. (2) InndisIfAliasChange, 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(0x1400119A0unpatched,0x140011920patched). - 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 atopenblock+0xe4at0x140011a6bwhile holding the global open-list lock and the per-open-block lock atopenblock+0xe8, but not the spinlock atopenblock+0x258that actually guards+0xe4. If the count is non-zero it callsndisMReferenceOpen(0x140059890), which acquires+0x258and increments+0xe4unconditionally. A concurrent thread that decrements the count to zero under+0x258between 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 usesndisMTryReferenceOpen, which acquires+0x258and re-checks+0xe4before referencing, closing the window. - Attacker-Reachable Entry Point & Data Flow:
ndisReferenceOpenByHandleis a shared helper that looks up an open block by handle inndisGlobalOpenListand takes a reference.- It is called from
ndisMOidRequest(0x1400117F0, call site0x140011814) 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(0x1400B6668unpatched,0x1400B6420patched). - Root Cause: Same pattern as Finding 1. The unpatched feature-off path checks
openblock+0xe4at0x1400b66e8under the open-block lock at+0xe8and then calls the blindndisMReferenceOpen(0x1400b66f6) which re-acquires+0x258and increments unconditionally. The patched version (0x1400B6420) removes the gate and always callsndisMTryReferenceOpen(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(0x1400CFE18unpatched,0x1400CFB48patched). - Root Cause: The unpatched function only enforces the length check
a1->Length < ndisDeviceStr.Lengthwhen the cached feature flagndisNDPQualityWinter25IsEnabledis set (if ( ndisNDPQualityWinter25IsEnabled != 0 && a1->Length < ndisDeviceStr.Length )). When the feature is off, the check is skipped anda1->Length - ndisDeviceStr.Length(aUSHORTsubtraction) underflows to a large value that becomesGuidString.Length, whileGuidString.Bufferis advanced past the end ofa1->Buffer.RtlGUIDFromStringthen reads out of bounds. The patched function performsif ( 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; callsndisIfAliasChangeat0x140075EF8.ndisIfAliasChange(0x1400CFE18), with the interface-name stringa1supplied 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):
- Setup: On a system running the unpatched
ndis.syswhere theNDPQualityWinter25feature is off, register an NDIS protocol (NdisRegisterProtocolEx) or lightweight filter (NdisFRegisterFilterDriver) so open blocks (protocol↔miniport bindings) exist. - 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 decrementsopenblock+0xe4to zero under theopenblock+0x258spinlock. - Race Window: The window is between the unlocked read at
0x140011a6b(Finding 1) /0x1400b66e8(Finding 2) and the locked increment at0x1400598d0insidendisMReferenceOpen. - 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.
0xD1DRIVER_IRQL_NOT_LESS_OR_EQUAL or0x50PAGE_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 ndisHandleUModePnPOp → ndisIfAliasChange) 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_STRINGtoRtlGUIDFromString. 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 (thememmoveintoifAliasis separately clamped to0x200). - Notes: The fix is delivered by removing the
NDPQualityWinter25feature 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 readcmp dword ptr [rbx+0E4h], 0;rbxis the_NDIS_OPEN_BLOCK. The+0x258spinlock is not held here.ndis!ndisMReferenceOpen+0x40(0x1400598d0) — the unconditionalinc dword ptr [rsi+0E4h];rsiis the open block. Compare[rsi+0xe4]here to the value read at0x140011a6b.- 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+0xe4from open-block close/teardown paths under+0x258. - Key Offsets:
0x140011a56— call to theNDPQualityWinter25feature gate (selects safe vs unsafe path).0x140011a6b— read of+0xe4outside the+0x258spinlock.0x140011a7b— call tondisMReferenceOpen(unsafe path).0x140011a92— call tondisMTryReferenceOpen(safe path, for comparison).0x1400598d0— unconditional increment insidendisMReferenceOpen.0x1400a0b9a— the correct check inside the+0x258lock inndisMTryReferenceOpen.- Open-block offsets:
+0xe0flags dword (bit0xf= closing/teardown state),+0xe4reference count,+0x250refcount block passed toNdisReferenceWithTag,+0x258spinlock guarding+0xe4,+0xe8per-open-block spinlock,+0x180next pointer. Global open list atndisGlobalOpenList, guarded byndisGlobalOpenListLock.
8. Changed Functions — Full Triage
Security-relevant changes
ndisReferenceOpenByHandle(0x1400119A0→0x140011920): [Finding 1] Feature gate removed; the unlocked check plus blindndisMReferenceOpenpath is eliminated. The patched version acquires+0x258, checks+0xe4, and increments only if non-zero.ndisPktMonRegisterAllOpens(0x1400B6668→0x1400B6420): [Finding 2] Same fix in the PktMon open-registration path; always usesndisMTryReferenceOpen.ndisIfAliasChange(0x1400CFE18→0x1400CFB48): [Finding 3] Thea1->Length < ndisDeviceStr.Lengthcheck is made unconditional, preventing theUSHORTunderflow that fed an out-of-bounds read toRtlGUIDFromString.
Supporting / behavioral changes
ndisReadRegistry(0x14009DA20): Removed the assignmentndisNDPQualityWinter25IsEnabled = 1(guarded by the feature gate). This global no longer exists in the patched build, which is why thendisIfAliasChangecheck is always taken.DriverEntry(0x140191234): Removed theNDPQualityWinter25feature-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:
0NDIS routines. (TheFeature_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
NDPQualityWinter25gate 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
NDPQualityWinter25feature is off. The exact default state of that feature on shipping systems is not determinable from the binaries.