- Affected binary:
netio.sys10.0.19041.7291 → 10.0.19041.7548 - CVE: CVE-2026-42975
- Patch date: 2026-07-14
- CVSS: 8.0
- Class: Buffer overflow
- Patch KB: KB5099444 and related
TL;DR
The Network Store Interface (NSI) module inside netio.sys resolves information-provider requests through NsipFindInformationProvider, which takes a caller-supplied index and returns a pointer into a fixed-stride array of provider records. The pre-patch code validated that index with if (index > count), an off-by-one that accepts index == count and hands back a pointer exactly one 104-byte (0x68) element past the end of the provider table. Because the request path is reachable from the Bluetooth Port Driver, an adjacent-network attacker can drive this lookup with a controlled index and obtain an out-of-bounds heap read/write primitive against whatever lies after the array — the basis for remote code execution. The patch introduces a Feature_2706360632 gate (enabled by default) that tightens the comparison to >=, so only indices 0..count-1 reach the pointer computation.
Background
NSI is the kernel-side plumbing that generic-purpose components — TCP/IP, the firewall, and the Bluetooth Port Driver among them — use to read and write network state without each subsystem inventing its own IOCTL surface. A caller submits an NSI request describing a module, an object, and a set of parameters; netio.sys routes that to the registered information provider that owns the object and copies structured data in or out.
The routing table lives on what the decompiler calls the NMP context. NsipGetNmpContext retrieves it, and offset +0x30 (plVar3[6]) on that context points at a provider descriptor block. Two fields matter here:
*(uint *)(block + 4)— the number of provider entries in the table.*(longlong *)(block + 8)— the base address of the provider array itself.
Each provider record is 0x68 (104) bytes wide, so element i lives at base + 104*i. NsipFindInformationProvider is the helper that turns a request’s numeric index into a pointer to the corresponding record:
*a2 = context; // return the referenced NMP context
*a3 = *(base) + 104 * index; // return the provider record pointer
The returned record pointer flows back up to NsipGetAllInformationProviderParameters, the sole caller, which then dereferences it to service the parameter enumeration — meaning any bad pointer produced here is consumed as a live provider structure.
Root Cause
The index is fully attacker-influenced. It is read straight out of the inbound request buffer:
v9 = *(_QWORD *)(NmpContext + 48); // provider descriptor block
v10 = *(unsigned int *)(a1 + 24); // index = request->[+0x18], caller-controlled
if ( (unsigned int)v10 > *(_DWORD *)(v9 + 4) ) // count at block+4
{
NsipDereferenceNmpContext(v7);
return 3221226021LL; // 0xC0000225 STATUS_NOT_FOUND
}
*a2 = v7;
*a3 = *(_QWORD *)(v9 + 8) + 104 * v10; // base + 104*index
return 0;
The defect is the comparison operator. The table holds count valid entries at indices 0 through count-1. The guard v10 > count only rejects indices strictly greater than the count, so the single value v10 == count sails through validation. The subsequent arithmetic then computes:
base + 104 * count
which is precisely the address one full 104-byte element beyond the last valid record — the first byte past the end of the provider array. No second bounds check exists downstream; *a3 is returned with STATUS_SUCCESS and the caller treats the out-of-bounds address as a legitimate provider record. Whatever heap data follows the allocation is now interpreted as a provider descriptor and read (and, through the fields that descriptor exposes, written) accordingly.
This is a textbook fencepost error: the allocation is sized for count elements, but the accepted index range is count + 1 values wide.
The Patch
The fix touches exactly one function, NsipFindInformationProvider, and adds one call — Feature_2706360632__private_IsEnabledDeviceUsage — as the called diff confirms. The function grew from 117 to 151 bytes. The corrected logic:
v10 = *(unsigned int *)(a1 + 24);
if ( !(unsigned int)Feature_2706360632__private_IsEnabledDeviceUsage() )
{
// rollback / disabled branch: original off-by-one behavior
if ( (unsigned int)v10 <= *(_DWORD *)(v9 + 4) )
goto LABEL_5; // accepts index == count
LABEL_7:
NsipDereferenceNmpContext(v7);
return 3221226021LL;
}
if ( (unsigned int)v10 >= *(_DWORD *)(v9 + 4) ) // fixed comparison
goto LABEL_7; // rejects index == count
LABEL_5:
*a2 = v7;
*a3 = *(_QWORD *)(v9 + 8) + 104 * v10;
return 0;
The cleaner C-decompilation of the post-patch build makes the two branches explicit:
uVar5 = Feature_2706360632__private_IsEnabledDeviceUsage();
if ((int)uVar5 == 0) {
if (*(uint *)(lVar2 + 4) < uVar1) goto LAB_1; // old: reject count < index
}
else if (*(uint *)(lVar2 + 4) <= uVar1) { // new: reject count <= index
LAB_1:
NsipDereferenceNmpContext((int *)plVar3);
goto LAB_0; // 0xC0000225
}
*param_2 = plVar3;
*param_3 = (ulonglong)uVar1 * 0x68 + *(longlong *)(lVar2 + 8);
The whole change is the flip from count < index to count <= index. In the enabled path — Feature_2706360632__private_IsEnabledDeviceUsage() returns non-zero by default, per the standard Controlled Feature Rollout killswitch semantics — the rejection now fires when count <= index, i.e. for any index >= count. The boundary value index == count is finally excluded, so only 0..count-1 reach the base + 0x68*index computation. The else/disabled branch preserves the original count < index test verbatim, giving Microsoft a runtime rollback that restores the old (vulnerable) behavior if the flag is disabled.
Per Rule 2 of feature-gate analysis, this is an added gate wrapping new safe behavior, not the removal of an existing one, so it is the live fix rather than rollout cleanup. Nothing else in the function changed: the same NMP context acquisition, the same NsipDereferenceNmpContext on the failure path, and the same success return remain in place.
Exploitability
The primitive is an out-of-bounds access at a fixed, predictable offset: exactly base + 104*count, one element past the provider array on the pool allocation that backs the NMP context’s provider table. Because the returned pointer is consumed as a full provider record by NsipGetAllInformationProviderParameters, the adjacent heap contents are dereferenced through that record’s field layout — giving both an information-leak surface (record fields read back into the request’s output parameters) and, depending on which fields are treated as writable state during parameter enumeration, a controlled out-of-bounds write. That write-through-a-fake-descriptor pattern is what elevates a single-element overrun into remote code execution.
Triggering it requires no race: the index comes directly from the request buffer, and a single request carrying index == count deterministically walks off the end. The attacker needs to know or spray to control count and the memory immediately following the provider table, which is the practical work of the exploit, but there is no timing window to win.
Reachability is what makes CVE-2026-42975 notable. The path is exposed through the Windows Bluetooth Port Driver, so an unauthenticated attacker within Bluetooth range — the “adjacent network” attack vector reflected in the CVSS 8.0 score — can submit the malformed NSI request without any local foothold or credentials. The bug itself is trivial (a one-character comparison error), and the only reason the complexity isn’t rated lower is the heap-grooming required to place a useful object one element past the array; the trigger condition and the reachability are both straightforward.
Detection
The distinguishing artifact is the presence of a call to Feature_2706360632__private_IsEnabledDeviceUsage inside NsipFindInformationProvider. Pre-patch netio.sys (build 7291) has no such call and validates with a strict >; patched netio.sys (build 7548) calls the feature routine and, in the default-enabled branch, uses >=. Fielded telemetry can also flag STATUS_NOT_FOUND (0xC0000225) returns from NSI provider lookups spiking against a particular module, which would indicate index-boundary probing on a patched host.
Pre patch functions
Full decompilation of NsipFindInformationProvider before the patch:
undefined8 NsipFindInformationProvider(longlong param_1,undefined8 *param_2,longlong *param_3)
{
uint uVar1;
longlong lVar2;
longlong *plVar3;
uint local_28 [2];
longlong local_20;
undefined8 local_18;
local_28[0] = 0;
local_28[1] = 0;
local_18 = 0;
local_20 = param_1;
plVar3 = NsipGetNmpContext(local_28);
if (plVar3 != (longlong *)0x0) {
lVar2 = plVar3[6];
uVar1 = *(uint *)(param_1 + 0x18);
if (uVar1 <= *(uint *)(lVar2 + 4)) {
*param_2 = plVar3;
*param_3 = (ulonglong)uVar1 * 0x68 + *(longlong *)(lVar2 + 8);
return 0;
}
NsipDereferenceNmpContext((int *)plVar3);
}
return 0xc0000225;
}
Post patch functions
Full decompilation of NsipFindInformationProvider after the patch:
undefined8 NsipFindInformationProvider(longlong param_1,undefined8 *param_2,longlong *param_3)
{
uint uVar1;
longlong lVar2;
longlong *plVar3;
undefined8 uVar4;
ulonglong uVar5;
uint local_28 [2];
longlong local_20;
undefined8 local_18;
local_28[0] = 0;
local_28[1] = 0;
local_18 = 0;
local_20 = param_1;
plVar3 = NsipGetNmpContext(local_28);
if (plVar3 == (longlong *)0x0) {
LAB_0:
uVar4 = 0xc0000225;
}
else {
lVar2 = plVar3[6];
uVar1 = *(uint *)(param_1 + 0x18);
uVar5 = Feature_2706360632__private_IsEnabledDeviceUsage();
if ((int)uVar5 == 0) {
if (*(uint *)(lVar2 + 4) < uVar1) goto LAB_1;
}
else if (*(uint *)(lVar2 + 4) <= uVar1) {
LAB_1:
NsipDereferenceNmpContext((int *)plVar3);
goto LAB_0;
}
*param_2 = plVar3;
*param_3 = (ulonglong)uVar1 * 0x68 + *(longlong *)(lVar2 + 8);
uVar4 = 0;
}
return uVar4;
}