1. Overview

  • Unpatched Binary: netio_unpatched.sys
  • Patched Binary: netio_patched.sys
  • Overall Similarity Score: 0.9923
  • Diff Statistics:
  • Matched Functions: 1901
  • Changed Functions: 1 (NsiGetParameterEx)
  • Added Functions (Patched only): 2 (WIL feature-staging helpers for Feature_TCPIP_2025_2511_SFI_58579577_Fix)
  • Identical Functions: 1900
  • Verdict: No delivered security-relevant change. The only functional edit in NsiGetParameterEx is an early-out that is gated behind the WIL feature flag Feature_TCPIP_2025_2511_SFI_58579577_Fix, with the original code path retained unchanged in the else branch (staged rollout). The default/feature-disabled behavior is byte-for-byte equivalent to the unpatched build. The remaining differences are register reallocation and one allocation site converted from ExAllocatePool3 to NsipAllocateLowPriority.

2. Vulnerability Summary

  • Severity: None (no reachable security-relevant change delivered).
  • Vulnerability Class: N/A. The error-handling structure of NsiGetParameterEx is identical in both builds; error paths route to the free/exit tail, not to the copy.
  • Affected Function: NsiGetParameterEx (0x140029E30 in both builds)

Analysis

The function NsiGetParameterEx retrieves a parameter from a registered NSI provider. It computes a required buffer size, then runs a grow-and-retry loop: it allocates a buffer, zeroes it, dispatches to the provider callback via _guard_dispatch_icall, and if the callback returns the buffer-too-small status 0xC0000023 it doubles the size (up to 0x100000) and retries. On success it copies the requested slice back to the caller's output buffer with memmove.

In the unpatched build the error handling is already correct. Every error condition routes to the WPP error-logging block, then to the shared free/exit tail, and never reaches the copy:

  • Allocation failure inside the loop (0x14002A3C2 jz loc_14002A47E) goes to the alloc-failure logging block, which sets STATUS_INSUFFICIENT_RESOURCES (0xC000009A) and falls into the error-logging block at 0x14002A4B8.
  • A negative callback status (0x14002A409 js loc_14002A4B8) goes to the same error-logging block.
  • The post-loop status check (0x14002A465 test ebx,ebx / 0x14002A467 js loc_14002A4B8) sends a negative status to the same block.

The error-logging block at 0x14002A4B8 ends with WPP_SF_D at 0x14002A4EB and then continues to the free/exit tail at 0x14002A4F0 (test r14,r14 → conditional ExFreePoolWithTagjmp loc_14002A103 at 0x14002A50A, the function exit). The memmove at 0x14002A477 sits at a lower address than the error block and is followed by its own jmp short loc_14002A4F0 at 0x14002A47C. The memmove is reached only on the success paths (0x14002A419 jnb loc_14002A469, and the post-loop fall-through when the status is non-negative). There is no fall-through from the error block into the copy — that convergence is impossible because the error block is physically after the copy, and both branches jump to the shared tail.

The patched build keeps this exact structure (error block 0x14002A51D, memmove at 0x14002A4DC, shared free/exit tail at 0x14002A555, exit at 0x14002A0FC).

What actually changed

The patched build inserts a feature-gate immediately after the size computation (0x14002A367):

mov     eax, cs:Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_featureState
test    al, 10h
...
call    Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_IsEnabledDeviceUsageNoInline
test    eax, eax
jz      loc_14002A3C9          ; feature OFF -> original loop (unchanged)
test    byte ptr [r14+10h], 2
jz      loc_14002A3C9          ; provider capability bit clear -> original loop (unchanged)
cmp     esi, 100000h
jb      loc_14002A3D5          ; size < 1MB -> enter loop normally
mov     ebx, 0C000000Dh        ; else STATUS_INVALID_PARAMETER, log and return

The new behavior is reached only when the WIL feature is enabled and the provider advertises capability bit [provider+0x10] & 2 and the computed size is >= 0x100000. In that narrow case it returns STATUS_INVALID_PARAMETER (0xC000000D) instead of proceeding. In every other case control transfers to loc_14002A3C9, which is the original grow-and-retry loop. This is a feature-gated staged rollout with the old path fully retained, not a delivered fix. The default (feature-disabled) behavior is identical to the unpatched build.

Two further, non-security differences: the buffer pointer register moved from r14 (unpatched) to r15 (patched) with the provider pointer moving the other way (register reallocation), and one allocation site was converted from ExAllocatePool3 to NsipAllocateLowPriority.


3. Pseudocode Diff

The relevant unpatched control flow (error paths and copy are mutually exclusive; the copy is never reached on error):

// ==================== UNPATCHED ====================
// size = max(offset+size, provider_required_size)
while (size < 0x100000) {                 // 0x14002A35F / 0x14002A365
    if (P != NULL) ExFreePoolWithTag(P);  // 0x14002A370
    P = ExAllocatePool3(NON_PAGED, size, 'NSIr', ...);   // 0x14002A3B0, held in r14
    if (P == NULL) goto alloc_fail;       // 0x14002A3C2 -> 0x14002A47E
    memset(P, 0, size);                   // 0x14002A3D0
    status = provider_callback(P, size);  // 0x14002A3F9 _guard_dispatch_icall
    if (status == 0xC0000023) { size *= 2; continue; }   // 0x14002A400
    if (status < 0) goto err_log;         // 0x14002A409 -> 0x14002A4B8
    if (*sizep >= offset + size) goto copy;               // 0x14002A419 -> 0x14002A469
    size *= 2;                            // 0x14002A457
}
if (status < 0) goto err_log;             // 0x14002A465 -> 0x14002A4B8

copy:                                     // 0x14002A469
    memmove(out_buf, P + offset, out_size);   // 0x14002A477
    goto cleanup;                         // 0x14002A47C jmp 0x14002A4F0

alloc_fail:                               // 0x14002A47E
    WPP_SF(...); status = 0xC000009A;     // STATUS_INSUFFICIENT_RESOURCES
    // falls into err_log
err_log:                                  // 0x14002A4B8
    WPP_SF_D(status);                     // 0x14002A4EB  (NO copy here)
cleanup:                                  // 0x14002A4F0
    if (P != NULL) ExFreePoolWithTag(P);
    return status;                        // 0x14002A50A jmp 0x14002A103
// ==================== PATCHED ====================
// After size computation, before the loop:
if (Feature_TCPIP_2025_2511_SFI_58579577_Fix__IsEnabled()   // 0x14002A367
    && (provider[0x10] & 2)                                  // 0x14002A37F
    && size >= 0x100000) {                                   // 0x14002A386
    status = 0xC000000D;   // STATUS_INVALID_PARAMETER, log and return
} else {
    // identical grow-and-retry loop and identical error/copy
    // structure as the unpatched build (copy at 0x14002A4DC,
    // err_log at 0x14002A51D, cleanup at 0x14002A555)
}

4. Assembly Analysis

Disassembly from NsiGetParameterEx. The error block does not fall through to the copy; both funnel to the shared free/exit tail, and the error block is reached after the copy.

Unpatched (0x140029E30)

; --- loop / dispatch ---
000000014002A35F  cmp     esi, 100000h            ; size vs 1MB
000000014002A365  jnb     loc_14002A465           ; -> post-loop check
000000014002A3B0  call    __imp_ExAllocatePool3
000000014002A3BC  mov     r14, rax                ; P = allocation
000000014002A3BF  test    rax, rax
000000014002A3C2  jz      loc_14002A47E           ; alloc fail -> logging
000000014002A3F9  call    _guard_dispatch_icall   ; provider callback
000000014002A3FE  mov     ebx, eax
000000014002A400  cmp     eax, 0C0000023h         ; buffer-too-small?
000000014002A405  jz      short loc_14002A41B     ; retry (double size)
000000014002A407  test    eax, eax
000000014002A409  js      loc_14002A4B8           ; error -> err_log block
000000014002A419  jnb     short loc_14002A469     ; success -> copy setup
; --- post-loop ---
000000014002A465  test    ebx, ebx
000000014002A467  js      short loc_14002A4B8     ; error -> err_log block
; --- copy (success only) ---
000000014002A469  mov     edx, [rdi+4Ch]          ; offset
000000014002A470  add     rdx, r14                ; src = P + offset
000000014002A473  mov     rcx, [rdi+40h]          ; dst = output buffer
000000014002A477  call    memmove
000000014002A47C  jmp     short loc_14002A4F0     ; -> cleanup
; --- alloc failure ---
000000014002A47E  ... WPP_SF ...
000000014002A4B3  mov     ebx, 0C000009Ah         ; STATUS_INSUFFICIENT_RESOURCES
; --- err_log block (reached AFTER the copy; does NOT reach it) ---
000000014002A4B8  ... WPP_SF_D(status) ...
000000014002A4EB  call    WPP_SF_D
; --- shared cleanup / exit ---
000000014002A4F0  test    r14, r14
000000014002A4F3  jz      loc_14002A103
000000014002A4FE  call    __imp_ExFreePoolWithTag
000000014002A50A  jmp     loc_14002A103           ; return status

Patched (0x140029E30)

; --- new feature gate, old path retained in else ---
000000014002A367  mov     eax, cs:Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_featureState
000000014002A376  call    Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_IsEnabledDeviceUsageNoInline
000000014002A37B  test    eax, eax
000000014002A37D  jz      short loc_14002A3C9     ; feature OFF -> original loop
000000014002A37F  test    byte ptr [r14+10h], 2
000000014002A384  jz      short loc_14002A3C9     ; capability bit clear -> original loop
000000014002A386  cmp     esi, 100000h
000000014002A38C  jb      short loc_14002A3D5     ; size < 1MB -> enter loop normally
000000014002A38E  mov     ebx, 0C000000Dh         ; else STATUS_INVALID_PARAMETER
; --- copy (success only), unchanged structure ---
000000014002A4D5  add     rdx, r15                ; src = P + offset (P now in r15)
000000014002A4DC  call    memmove
000000014002A4E1  jmp     short loc_14002A555     ; -> cleanup
; --- err_log block, unchanged structure ---
000000014002A51D  ... WPP tracing ...
000000014002A550  call    WPP_SF_D
; --- shared cleanup / exit ---
000000014002A555  test    r15, r15
000000014002A558  jz      loc_14002A0FC
000000014002A56F  jmp     loc_14002A0FC

5. Trigger Conditions

No security-relevant primitive is reachable. NsiGetParameterEx is reachable from user mode via the NSI parameter-retrieval interface, but the error-handling structure that the analysis would have to exploit is identical in both builds and correctly routes every error to the free/exit tail without reaching the copy. The only new patched behavior (an early STATUS_INVALID_PARAMETER return for oversized requests to capability-flagged providers) is gated behind Feature_TCPIP_2025_2511_SFI_58579577_Fix with the original path retained, so it changes nothing by default and grants no attacker primitive.


6. Exploit Primitive & Development Notes

  • Provided Primitive: None. There is no fall-through from the error path to the copy in either build, so there is no NULL-pointer dereference or information-disclosure primitive introduced or removed by this patch.
  • Exploitation Requirements: N/A.
  • Mitigations Bypassed: None.

7. Debugger Notes

No exploitable behavior to reproduce. For orientation only, the relevant instructions in NsiGetParameterEx (unpatched) are:

netio!NsiGetParameterEx            0x140029E30   function entry (rcx = NSI request)
netio!NsiGetParameterEx+0x580      0x14002A3B0   ExAllocatePool3 (buffer allocation)
netio!NsiGetParameterEx+0x5C9      0x14002A3F9   _guard_dispatch_icall (provider callback)
netio!NsiGetParameterEx+0x647      0x14002A477   memmove (success path only)
netio!NsiGetParameterEx+0x6C0      0x14002A4F0   shared cleanup / ExFreePoolWithTag / return

The copy at 0x14002A477 is preceded by add rdx, r14 (source = allocated buffer + offset). In both builds the buffer is always allocated and null-checked before the callback runs on the success path, and error paths bypass the copy entirely.


8. Changed Functions — Full Triage

NsiGetParameterEx (0x140029E30)

  • Change Type: Not security relevant (feature-gated staged rollout + refactor).
  • Notes:
  • Feature gate: Added Feature_TCPIP_2025_2511_SFI_58579577_Fix check at 0x14002A367. When enabled and the provider capability bit [provider+0x10] & 2 is set and the computed size is >= 0x100000, the function returns STATUS_INVALID_PARAMETER (0xC000000D) early. When disabled or the bit is clear, control goes to the original loop at loc_14002A3C9 (unchanged behavior).
  • Register reallocation: The allocated-buffer pointer moved from r14 (unpatched) to r15 (patched); the provider pointer moved from r15 to r14. No behavioral effect.
  • Allocation refactor: One allocation site was converted from ExAllocatePool3 to NsipAllocateLowPriority, matching the other allocation paths in the function.
  • No error-handling change: The error/success separation is structurally identical in both builds; the copy is reached only on success in both.

9. Added / Unmatched Functions

Two functions exist only in the patched build:

  • Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_IsEnabledDeviceUsageNoInline
  • Feature_TCPIP_2025_2511_SFI_58579577_Fix__private_IsEnabledFallback

Both are WIL feature-staging scaffolding (they read __private_featureState / call wil_details_IsEnabledFallback) supporting the feature gate above. They are not security fixes in themselves. No functions were removed.


10. Confidence & Caveats

  • Confidence Level: High. The disassembly of both builds shows the error-logging block ends by continuing into the shared free/exit tail (0x14002A4F0 unpatched, 0x14002A555 patched), not into the memmove, which sits at a lower address and jumps to that same tail. The claimed unconditional fall-through from error logging to the copy does not exist in either build.
  • Nature of the patch: The one behavioral edit is gated behind Feature_TCPIP_2025_2511_SFI_58579577_Fix with the original path retained in the else branch — a staged rollout. The default (feature-disabled) behavior matches the unpatched build.
  • Scope: A content-based diff (matching functions across relocations, ignoring addresses and operands) confirms NsiGetParameterEx is the only changed function, plus the two added WIL helpers. No omitted security-relevant change was found.