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 loops i < v5.
  • Patched re-evaluates i < ndisMaxNumberOfProcessors directly 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) + base per iteration. Neither build performs a 4096-byte fill; the << 12 is the per-processor stride between slots, not a store length.
  • The only difference: the unpatched build hoists the count into edx before 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@Z with parameter 1 = 0x2C in both builds (unpatched 0x14005043B, patched 0x140054EDF). 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.