ndis.sys — per-processor slot free loop is a compiler codegen difference
KB5079466
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | ndis_unpatched.sys |
| Patched binary | ndis_patched.sys |
| Overall similarity | 0.9424 (94.24%) |
| Matched functions | 3,576 |
| Changed functions | 379 |
| Identical functions | 3,197 |
| Unmatched (unpatched) | 0 |
| Unmatched (patched) | 0 |
Verdict: The function originally flagged as a security fix — ndisFreePerProcessorSlot (unpatched 0x140050390, patched 0x140054E30) — is behaviorally identical in both builds. Both builds use the same loop bound (ndisMaxNumberOfProcessors), write the same single 8-byte zero per per-processor slot, and call the same bugcheck helper (ndisBugCheckEx). The only difference is that the unpatched build caches ndisMaxNumberOfProcessors in a register before the loop while the patched build re-reads the global in the loop condition — a compiler codegen difference with no behavioral or security effect. No security-relevant change was identified in this binary diff.
2. Analysis Summary
Finding 1: ndisFreePerProcessorSlot loop — No security-relevant change
| Field | Detail |
|---|---|
| Severity | None (informational) |
| Class | No security-relevant change (compiler codegen difference) |
| Affected function | ndis!ndisFreePerProcessorSlot (unpatched 0x140050390, patched 0x140054E30) |
| Primitive | None |
What the function does. ndisFreePerProcessorSlot releases a per-processor descriptor slot back to a free list. It takes the slot pointer and an expected tag value. It first validates the tag stored in the descriptor page (bugchecking with code 0x2C on mismatch), then walks each processor's per-CPU region and writes a single zeroing store, and finally links the slot back onto ndisPerProcessorDescriptorLock-protected free list.
Why it is not a security change. The decompiled bodies of the two builds differ only in where the loop bound is loaded from:
- Unpatched caches
v5 = ndisMaxNumberOfProcessors;once, then loopsi < v5. - Patched re-evaluates
i < ndisMaxNumberOfProcessorsdirectly each iteration.
Both use the exact same global (ndisMaxNumberOfProcessors), the same per-processor stride (i << 12), the same store width (one 8-byte _QWORD zero, not a 4096-byte fill), and the same ndisBugCheckEx(0x2C, ...) validation helper. There is no substitution of one count variable for another, no change to the store size, and no change to the bounds. The loop bound is not attacker-controlled; it is the system processor count established at driver init.
The report's original claim of a loop-bound variable substitution arose from treating the same relocated global (ndisMaxNumberOfProcessors, which moves between builds) as two distinct variables. The disassembly and decompilation below show it is one variable in both builds.
3. Decompilation Diff
ndisFreePerProcessorSlot
// ================================================================
// UNPATCHED — ndisFreePerProcessorSlot @ 0x140050390
// ================================================================
void __fastcall ndisFreePerProcessorSlot(ULONG_PTR BugCheckParameter2, ULONG_PTR BugCheckParameter4)
{
ULONG_PTR v2; // rbx
__int64 v4; // rdi
unsigned int v5; // edx
unsigned int i; // eax
unsigned int v7; // ecx
KIRQL v8; // al
v2 = BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL;
v4 = (__int64)(BugCheckParameter2 - (BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL)) >> 3;
if ( *(_DWORD *)((BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL) + 4 * v4 - 4076) != (_DWORD)BugCheckParameter4 )
ndisBugCheckEx(
0x2Cu,
BugCheckParameter2,
*(unsigned int *)((BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL) + 4 * v4 - 4076),
(unsigned int)BugCheckParameter4);
v5 = ndisMaxNumberOfProcessors; // count cached in local
for ( i = 0; i < v5; *(_QWORD *)((v7 << 12) + BugCheckParameter2) = 0 ) // 8-byte zero per slot
v7 = i++;
v8 = KeAcquireSpinLockRaiseToDpc(&ndisPerProcessorDescriptorLock);
*(_DWORD *)(v2 + 4 * v4 - 4076) = *(_DWORD *)(v2 - 4080);
*(_DWORD *)(v2 - 4080) = v4 | 0xFE000000;
KeReleaseSpinLock(&ndisPerProcessorDescriptorLock, v8);
}
// ================================================================
// PATCHED — ndisFreePerProcessorSlot @ 0x140054E30
// ================================================================
void __fastcall ndisFreePerProcessorSlot(ULONG_PTR BugCheckParameter2, ULONG_PTR BugCheckParameter4)
{
ULONG_PTR v2; // rbx
__int64 v4; // rdi
unsigned int i; // eax
unsigned int v6; // ecx
KIRQL v7; // al
v2 = BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL;
v4 = (__int64)(BugCheckParameter2 - (BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL)) >> 3;
if ( *(_DWORD *)((BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL) + 4 * v4 - 4076) != (_DWORD)BugCheckParameter4 )
ndisBugCheckEx(
0x2Cu,
BugCheckParameter2,
*(unsigned int *)((BugCheckParameter2 & 0xFFFFFFFFFFFFF000uLL) + 4 * v4 - 4076),
(unsigned int)BugCheckParameter4);
for ( i = 0; i < ndisMaxNumberOfProcessors; *(_QWORD *)((v6 << 12) + BugCheckParameter2) = 0 ) // global re-read
v6 = i++;
v7 = KeAcquireSpinLockRaiseToDpc(&ndisPerProcessorDescriptorLock);
*(_DWORD *)(v2 + 4 * v4 - 4076) = *(_DWORD *)(v2 - 4080);
*(_DWORD *)(v2 - 4080) = v4 | 0xFE000000;
KeReleaseSpinLock(&ndisPerProcessorDescriptorLock, v7);
}
The tag validation, the free-list relink, the spinlock use, and the zeroing loop are all identical. The sole textual difference is the local-cache of ndisMaxNumberOfProcessors.
4. Assembly Analysis
ndisFreePerProcessorSlot — the zeroing loop in both builds
; ==================================================================
; UNPATCHED — ndisFreePerProcessorSlot @ 0x140050390
; ==================================================================
00000001400503BE mov edx, cs:?ndisMaxNumberOfProcessors@@3KA ; count loaded into edx once
00000001400503C4 xor r8d, r8d ; r8 = 0 (value stored)
00000001400503C7 mov eax, r8d ; i = 0
00000001400503CA test edx, edx
00000001400503CC jz short loc_1400503DF
00000001400503CE xchg ax, ax
00000001400503D0 mov ecx, eax
00000001400503D2 inc eax
00000001400503D4 shl ecx, 0Ch ; ecx = i << 12 (per-slot stride)
00000001400503D7 mov [rcx+r10], r8 ; store one 8-byte zero
00000001400503DB cmp eax, edx
00000001400503DD jb short loc_1400503D0
; ==================================================================
; PATCHED — ndisFreePerProcessorSlot @ 0x140054E30
; ==================================================================
0000000140054E5E xor edx, edx ; rdx = 0 (value stored)
0000000140054E60 cmp cs:?ndisMaxNumberOfProcessors@@3KA, edx ; compare against global directly
0000000140054E66 mov eax, edx ; i = 0
0000000140054E68 jbe short loc_140054E83
0000000140054E6A nop word ptr [rax+rax+00h]
0000000140054E70 mov ecx, eax
0000000140054E72 inc eax
0000000140054E74 shl ecx, 0Ch ; ecx = i << 12 (per-slot stride)
0000000140054E77 mov [rcx+r10], rdx ; store one 8-byte zero
0000000140054E7B cmp eax, cs:?ndisMaxNumberOfProcessors@@3KA ; re-read global each iteration
0000000140054E81 jb short loc_140054E70
Key annotations:
- Both builds bound the loop by
cs:?ndisMaxNumberOfProcessors@@3KA— the same global. There is no swap between two different count variables. - Both builds store a single 8-byte zero (
r8/rdx, each zeroed) at(i << 12) + baseper iteration. Neither build performs a 4096-byte fill; the<< 12is the per-processor stride between slots, not a store length. - The only difference: the unpatched build hoists the count into
edxbefore the loop and compares against it; the patched build compares against the memory operand directly each iteration. This is a loop-invariant-load codegen choice with no behavioral effect. - The tag-mismatch path calls
?ndisBugCheckEx@@YAX_K000@Zwith parameter 1 =0x2Cin both builds (unpatched0x14005043B, patched0x140054EDF). There is no change of bugcheck helper.
5. Trigger Conditions
There is no vulnerability to trigger. ndisFreePerProcessorSlot runs during NDIS teardown of per-processor descriptor slots. Its loop bound is the system processor count (ndisMaxNumberOfProcessors), which is not attacker-controlled, and both builds bound and store identically. No IOCTL, OID, or packet path changes behavior across the two builds for this function.
6. Exploit Primitive
None. Because the two builds are behaviorally identical for this function, there is no primitive (no out-of-bounds write, no out-of-bounds read, no information leak) introduced or removed by the change. The previously described pool-overflow and KASLR-leak scenarios do not correspond to any real difference between the binaries and are not supported by the disassembly.
7. Debugging Notes
Not applicable — there is no observable security behavior to reproduce. For reference, the function and its zeroing loop can be located at RVA 0x50390 (unpatched) and 0x54E30 (patched); the loop store is the mov [rcx+r10], r8 / mov [rcx+r10], rdx instruction inside it. Both are ordinary per-processor slot frees.
8. Changed Functions — Triage
Security-Relevant Changes
None identified.
Reviewed and Found Non-Security
| Function | Similarity | Change Type | Note |
|---|---|---|---|
ndisFreePerProcessorSlot (was reported as sub_140050390) |
0.9813 | Non-security | Per-processor slot free loop. Both builds use ndisMaxNumberOfProcessors as the bound and write one 8-byte zero per slot; the only difference is register-caching the count vs. re-reading the global. Bugcheck helper is ndisBugCheckEx in both. |
EthFilterDprIndicateReceive |
0.8942 | Behavioral | Ethernet receive indication path restructured; multicast/broadcast/promiscuous matching cascade re-ordered; RWLock release path simplified. Packet-matching outcome is equivalent. |
NdisMSetAttributesEx |
0.7311 | Behavioral | Miniport attribute flag handling consolidated (duplicated OR/AND branches merged). Timer-init and event-signaling callee targets changed. Flag results equivalent. |
NdisMInitializeTimer |
0.6930 | Behavioral | DPC callback selection uses a bit-test helper; callee targets changed. Selection semantics equivalent. |
sub_14007D570 / sub_14007AE20 |
0.5743 | Behavioral | Miniport halt/shutdown restructure; adds teardown for an additional context pointer and a media-type branch. Consistent with expanded teardown, not a security fix. |
NdisMTransferDataComplete |
0.8048 | Behavioral | Transfer-data completion restructured; MDL index and free-path logic preserved. |
NdisMWanIndicateReceive / NdisMWanIndicateReceiveComplete |
0.7548 | Behavioral | WAN receive path restructured; protocol-binding iteration preserved. |
NdisMWanSendComplete |
0.8184 | Behavioral | WAN send completion restructured; IRQL management uses a bit-test helper. Equivalent. |
sub_1400CF1D4 / sub_1400D0054 |
0.7152 | Behavioral | WMI/GUID registration restructured (telemetry calls removed). Pool size 0x6e8, tag 'Ndif', and the < 0x668 GUID copy bound are preserved in both builds. |
NdisMOidRequestComplete |
0.8626 | Behavioral | OID completion path restructured; async OID dispatch (0x8000000 flag) preserved. |
NdisOpenConfiguration |
0.7744 | Behavioral | Configuration open path restructured. |
NdisMAllocateSharedMemory |
0.9609 | Behavioral | DMA shared-memory allocation path restructured. |
NdisMRegisterInterruptEx |
0.9650 | Behavioral | Interrupt registration path restructured. |
sub_14007BA60 / sub_140089340 |
0.7076 | Behavioral | Large miniport init/registration dispatcher restructured; function signature widened. Consistent with feature/API expansion. |
NdisSendNetBufferLists |
0.9757 | Cosmetic | NBL send dispatch, minor refactor. |
NdisFIndicateReceiveNetBufferLists |
0.9932 | Cosmetic | Filter receive indication, trivial change. |
NdisFSendNetBufferLists |
0.9930 | Cosmetic | Filter send path, trivial change. |
The remaining changed functions in the ~379 total are of the same character: structural refactoring across a version-to-version rebuild, with no change to attacker-controllable bounds, lengths, or validation observed.
9. Unmatched Functions
No functions were added or removed between the two builds (unmatched_unpatched: 0, unmatched_patched: 0). The patch is entirely in-place modification of existing functions. No sanitizer was added and no validation check was removed.
10. Confidence & Caveats
Confidence: High (for the primary function)
Rationale:
- The unpatched and patched ndisFreePerProcessorSlot bodies were read directly from both the decompilation and the disassembly. Both bound the loop by ndisMaxNumberOfProcessors, store one 8-byte zero per per-processor stride, and validate via ndisBugCheckEx(0x2C). The only difference is register-caching the count versus re-reading the global — a compiler codegen choice.
- There is no variable substitution, no store-size change, no bugcheck-helper change, and no attacker-controlled bound. The function is not an initialization routine but a per-processor slot free routine.
Caveats: - The broader set of ~379 changed functions is consistent with a version-to-version rebuild (94.24% overall similarity). The functions triaged above were reviewed and show no security-relevant bound/length/validation change; a targeted sweep of attacker-facing paths (OID, receive/send, filter, WAN) did not surface an omitted security fix.