1. Overview

  • Unpatched Binary: afd_unpatched.sys
  • Patched Binary: afd_patched.sys
  • Overall Similarity Score: 0.9888
  • Diff Statistics: Matched: 1097 | Changed: 15 | Identical: 1082 | Unmatched: 0
  • Verdict: The patch delivers two related kernel address-buffer bounds fixes that were previously staged behind WIL feature flags and are made unconditional in the patched build:
  • Finding A (primary, CWE-787): an out-of-bounds write in the AFD SAN accept address handling (AfdSanConnectHandler and its twin AfdSanAcceptCore). The unpatched default path copies an attacker-influenced-length address field into an MDL-mapped kernel buffer without clamping to the buffer capacity.
  • Finding B (CWE-125): an out-of-bounds read in the SuperAccept transport-address retrieval completion routine (AfdRestartSuperAcceptGetAddress) that leaks up to 10 bytes of adjacent kernel memory to user mode.
  • In both cases the unpatched build keeps the unsafe path as the default when its feature flag is disabled; the patch removes the flag and applies the bounds clamp unconditionally.

2. Vulnerability Summary

Finding A (primary) — Out-of-bounds write in SAN accept address handling

  • Severity: High (kernel out-of-bounds write with attacker-influenced length into an MDL-mapped buffer)
  • Vulnerability Class: Out-of-bounds write (CWE-787)
  • Affected Functions: AfdSanConnectHandler (unpatched sub_1C00772A0 / patched sub_1C00771B0) and its twin AfdSanAcceptCore (sub_1C007608C in both builds)
  • Feature flag: g_Feature_232206649_59779869 (evaluated via EvaluateCurrentState, sub_1c0015714) — present 5 times in the unpatched decompilation, 0 times in the patched build.

Root Cause: When an accepted SAN connection's address data is written into the accept output buffer, the copy length is taken from a 16-bit length field at the start of the source data (*(uint16*)Src). In the unpatched build the bounds clamp against the destination buffer capacity is gated behind EvaluateCurrentState(&g_Feature_232206649_59779869). When the flag is disabled (its default state), control falls into a legacy path that issues memmove(dst, Src+2, *(uint16*)Src + 2) (and a sibling memmove(dst+8, Src, *(uint16*)Src + 4)) with no clamp against the buffer capacity. dst lies inside the MDL-mapped accept buffer, so a length field larger than the remaining capacity drives the copy past the end of that buffer — a kernel out-of-bounds write of attacker-influenced data. The patched build removes the feature flag and the entire unclamped legacy path, so the copy length is always clamped to the buffer capacity (capacity - 2 for the address copy, capacity - 8 for the header+address copy).

Assembly evidence — AfdSanConnectHandler:

; ===== UNPATCHED sub_1C00772A0  feature-gated clamp, unclamped default path =====
0x1c007788d: lea     rcx, g_Feature_232206649_59779869_FeatureDescriptorDetails
0x1c0077894: call    EvaluateCurrentState
0x1c0077899: test    eax, eax
0x1c007789b: jz      0x1c007796a          ; flag OFF -> legacy UNCLAMPED path
; --- flag ON: clamped path (capacity taken from word [r12+0x18]) ---
0x1c00778a1: cmp     dword [r12+0x18], 6
0x1c0077905: mov     eax, 0xfffe          ; clamp to capacity - 2
0x1c007790a: add     si, ax
0x1c007791d: call    memmove              ; clamped copy
; --- flag OFF: legacy path, NO capacity clamp ---
0x1c007796a: cmp     dword [r12+0x18], r14d
0x1c00779ad: movzx   r8d, word [r15]      ; r8 = *(uint16*)Src
0x1c00779b1: add     r8, 2                ; size = *(uint16*)Src + 2  (unclamped)
0x1c00779bc: call    memmove              ; OOB WRITE (source length, not capacity)
0x1c00779df: movzx   eax, word [r9]       ; sibling: size = *(uint16*)Src + 4
0x1c00779e3: add     eax, 4
0x1c00779f0: call    memmove              ; OOB WRITE (unclamped)

; ===== PATCHED sub_1C00771B0  feature flag removed, clamp always applied =====
0x1c00777bb: cmp     dword [r15+0x18], 6  ; unconditional guard (no EvaluateCurrentState)
0x1c0077827: mov     eax, 0xfffe          ; clamp to capacity - 2
0x1c007782c: add     si, ax
0x1c007783e: call    memmove              ; clamped copy
0x1c0077870: mov     eax, 0xfff8          ; clamp to capacity - 8
0x1c0077875: add     si, ax
0x1c007788a: call    memmove              ; clamped copy

The twin AfdSanAcceptCore (sub_1C007608C) carries the identical change: the unpatched build references g_Feature_232206649_59779869 at 0x1c00762d8, 0x1c0076303, and 0x1c007642d and retains an unclamped legacy copy (memmove at 0x1c00765bb); the patched build has no reference to that feature and reaches the clamped copies (memmove at 0x1c0076491 after mov eax, 0xfffe, and 0x1c00764da after mov eax, 0xfff8) unconditionally.

Attacker-Reachable Entry Point: AfdSanConnectHandler and AfdSanAcceptCore run in the AFD SAN (switched-fabric / Winsock Direct provider) accept path. The over-long copy is bounded only by the 16-bit length field in the source address data, so the reachable primitive is a kernel out-of-bounds write. Confirming the exact size and page alignment of the destination MDL-mapped buffer is required to characterize how far past the buffer the write reaches.

Finding B — Out-of-bounds read in SuperAccept address retrieval

  • Severity: Medium (bounded kernel information disclosure of up to 10 bytes; no out-of-bounds write or memory corruption)
  • Vulnerability Class: Out-of-bounds read (CWE-125) / Kernel Information Disclosure
  • Affected Function: AfdRestartSuperAcceptGetAddress (sub_1C0065660)
  • Feature flag: g_Feature_499346745_59190809 (evaluated via EvaluateCurrentState, sub_1c0015714).

Sections 3 through 7 below detail Finding B.

Root Cause: The function, AfdRestartSuperAcceptGetAddress (sub_1C0065660), is an IRP completion routine that runs during AcceptEx (SuperAccept) processing to retrieve the transport address returned by the TDI transport provider. It shifts the address data backward in the mapped buffer by 10 bytes using memmove (sub_1c000c900). Specifically, it copies from (buffer + offset + 0xa) to (buffer + offset). The length of this copy (rbx_1) is derived from a 16-bit length field located at offset +8 within the returned data (*(buffer + offset + 8) + 2).

In the unpatched code's default path, this copy length is clamped to the remaining space in the MDL (Memory Descriptor List) buffer (MdlAddress->ByteCount - offset), rather than the actual size of the data returned by the transport. Because the source pointer begins 10 bytes ahead of the destination, this miscalculation causes the copy operation to read up to 10 bytes past the end of the validly mapped buffer. The buffer is a partial MDL built (via IoBuildPartialMdl in AfdContinueSuperAccept) over the user-supplied AcceptEx output buffer and then mapped into system address space, so the overread runs past that mapping into adjacent kernel (system address space) memory. The patched version fixes this by unconditionally clamping the copy length to (total_returned_length - 0xa), ensuring the read strictly remains within the bounds of the returned data.

Attacker-Reachable Entry Point & Call Chain: 1. User-mode application issues an AcceptEx operation, which reaches AFD as IRP_MJ_DEVICE_CONTROL (IOCTL_AFD_SUPER_ACCEPT). 2. AfdDispatch (sub_1c0049d90) routes the IRP into the SuperAccept path. 3. AfdWaitForListen (sub_1c00589b0) / AfdRestartSuperAccept (sub_1c00654f0) drive the accept, issuing a TDI query for the connection's transport address. 4. AfdContinueSuperAccept (sub_1c0058170) and the restart chain register AfdRestartSuperAcceptGetAddress (sub_1c0065660) as the completion routine. 5. When the transport returns the address, the routine fires and performs the backward shift, triggering the out-of-bounds read that returns leaked kernel memory into the AcceptEx output buffer.

3. Pseudocode Diff

// UNPATCHED AfdRestartSuperAcceptGetAddress (sub_1C0065660) — vulnerable completion routine
void* rbp_2 = *(Overlay_1 + 8) + MappedSystemVa_1;  // buffer + offset
int16_t rbx_1 = *(rbp_2 + 8) + 2;               // length field from returned data + 2

// VULNERABILITY: Feature flag check. Default path is vulnerable!
if (!EvaluateCurrentState(&g_Feature_499346745_59190809))  // sub_1c0015714
{
    // Clamps copy length to MDL ByteCount - offset (remaining buffer space)
    struct _MDL* MdlAddress_1 = arg2->MdlAddress;
    if (MdlAddress_1->ByteCount - *(Overlay_1 + 8) < rbx_1)
        rbx_1 = MdlAddress_1->ByteCount - *(Overlay_1 + 8);
    // BUG: Source is rbp_2 + 0xa, so the final read extends up to 10 bytes PAST MDL end
}
else  // Safe alternate path (flag ON)
{
    int16_t rax_6 = *(Overlay_1 + 0x18);
    if (rax_6 < 0xa) rax_6 = 0; else rax_6 -= 0xa;
    if (rax_6 < rbx_1) rbx_1 = rax_6;
}

// memmove(dst, src, len) -- OOB READ occurs here
sub_1c000c900(rbp_2, rbp_2 + 0xa, rbx_1);        

// PATCHED AfdRestartSuperAcceptGetAddress (sub_1C0065830) — always uses safe clamp
// Feature flag and unsafe ByteCount path removed; always clamps to returned length - 0xa
int16_t rax_5 = *(Overlay_1 + 0x18);
if (rax_5 < 0xa) rax_5 = 0; else rax_5 -= 0xa;
if (rax_5 < rdi_1) rdi_1 = rax_5;  

// Safe copy - stays within bounds of returned data
sub_1c000c900(rsi_2, rsi_2 + 0xa, rdi_1); 

4. Assembly Analysis

The assembly block below shows the vulnerable sequence in the unpatched binary. The issue stems from using MdlAddress->ByteCount to bound the copy operation.

; ===== VULNERABLE SECTION  default path (feature flag OFF) =====
0x1c0065756: mov     ebp, dword [rsi+0x8]          ; ebp = offset into buffer
0x1c0065760: mov     r15d, dword [rsi+0x18]        ; r15d = total_returned_length
0x1c0065764: add     rbp, rax                      ; rbp = MappedSystemVa + offset
0x1c0065767: movzx   ebx, word [rbp+0x8]           ; ebx = *(data+8) = returned length field
0x1c006576b: add     bx, 0x2                       ; rbx_1 = length + 2
0x1c006576f: call    0x1c0015714                   ; EvaluateCurrentState (feature flag check)
0x1c0065774: test    eax, eax
0x1c0065776: je      0x1c0065798                   ; if flag OFF  VULNERABLE PATH
; --- VULNERABLE PATH (flag OFF) ---
0x1c0065798: mov     rdx, qword [rdi+0x8]          ; rdx = MDL
0x1c006579c: movzx   eax, bx                        ; eax = rbx_1 (length + 2)
0x1c006579f: mov     ecx, dword [rdx+0x28]         ; ecx = MdlAddress->ByteCount (32-bit!)
0x1c00657a2: sub     ecx, dword [rsi+0x8]          ; ecx -= offset
0x1c00657a5: cmp     ecx, eax                       ; if ByteCount-offset >= rbx_1
0x1c00657a7: jae     0x1c00657b1                   ;   skip clamp (KEEP OVERSIZED rbx_1) 
0x1c00657a9: movzx   ebx, word [rdx+0x28]          ; re-read ByteCount as 16-bit
0x1c00657ad: sub     bx, word [rsi+0x8]            ; 16-bit clamp to ByteCount - offset
; ===== END VULNERABLE SECTION =====
0x1c00657b1: movzx   r8d, bx                        ; r8d = final copy length
0x1c00657b5: lea     rdx, [rbp+0xa]                ; src = rbp + 10
0x1c00657b9: mov     rcx, rbp                       ; dst = rbp
0x1c00657bc: call    0x1c000c900                   ; memmove(dst, src, len)  OOB READ

In the patched binary (AfdRestartSuperAcceptGetAddress @ 0x1C0065830), the feature-flag call and the entire ByteCount-clamp path are gone. The code unconditionally computes total_returned_length - 0xa and clamps the copy length to it before the memmove:

0x1c0065928: movzx   eax, word [rbp+0x18]         ; rax = total_returned_length
0x1c0065934: cmp     ax, 0xa
0x1c0065938: jb      0x1c0065944
0x1c006593a: mov     ecx, 0xfff6
0x1c006593f: add     ax, cx                        ; rax = returned_length - 10
0x1c0065944: xor     eax, eax                      ; (if < 10) clamp to 0
0x1c0065946: cmp     ax, di
0x1c0065950: cmovb   di, ax                        ; clamp copy length to returned_length - 10
0x1c0065954: movzx   r8d, di
0x1c0065958: call    memmove                       ; safe copy

5. Trigger Conditions

  1. Interface: A user-mode application performs an AcceptEx on an AFD (\Device\Afd) listening socket; this reaches AFD as IRP_MJ_DEVICE_CONTROL (IOCTL_AFD_SUPER_ACCEPT), driving the SuperAccept code path.
  2. Address Retrieval: During SuperAccept, AFD issues a TDI query to obtain the accepted connection's transport address; the result is placed in the MDL-mapped buffer processed by AfdRestartSuperAcceptGetAddress.
  3. Length Field: The 16-bit length field at data offset +8 in the returned buffer, when +2, is greater than the actual returned data size but less than the remaining MDL buffer size (ByteCount - offset).
  4. Logic State: The feature flag EvaluateCurrentState(&g_Feature_499346745_59190809) (sub_1c0015714) must return FALSE (its default/unpatched state) to take the vulnerable code path.
  5. Observable Effect: The AcceptEx output buffer (the local/remote transport addresses returned to user mode) is populated with the address data followed by up to 10 bytes read from beyond the end of the mapped buffer. No BSOD is expected unless the overread crosses an unmapped page boundary.

6. Exploit Primitive & Development Notes

  • Primitive: Kernel information disclosure. The memmove reads its source from destination + 0xa but the copy length is bounded (in the vulnerable path) by MdlAddress->ByteCount - offset rather than returned_length - 0xa, so the read can extend up to 10 bytes past the end of the MDL-mapped buffer. Those trailing bytes are whatever memory immediately follows the buffer's mapping, and they are written back into the AcceptEx address output surfaced to user mode.
  • Attacker-arrangeable condition: The ByteCount used for the clamp is the size of the AcceptEx output buffer supplied by the user-mode caller. By sizing that buffer so that ByteCount - offset sits at or just above the returned address length, the caller drives the copy length up to ByteCount - offset while the source remains shifted by +0xa, producing the overread. The operation is repeatable across AcceptEx calls.
  • Demonstrable impact: Up to 10 bytes of adjacent kernel memory are disclosed to user mode per triggering call. No out-of-bounds write, control-flow hijack, or memory corruption is present in this routine, so any further use of the disclosed bytes is out of scope for what this change demonstrates.

7. Debugger PoC Playbook

For an analyst with a kernel debugger (KD/WinDbg) attached to the unpatched binary:

  • Breakpoints:
  • bp afd_unpatched!AfdRestartSuperAcceptGetAddress (sub_1c0065660, function entry)
  • bp afd_unpatched!sub_1c0065660+0x15c (call to memmove performing the OOB read, 0x1c00657bc)
  • bp afd_unpatched!sub_1c0065660+0x10f (feature flag check, 0x1c006576f)

  • What to inspect:

  • At the feature flag check: Step over the call 0x1c0015714. If eax returns 0, the vulnerable path is taken.
  • At 0x1c00657bc (the call 0x1c000c900):

    • rcx = Destination pointer (MappedSystemVa + offset).
    • rdx = Source pointer (rcx + 0xa).
    • r8d = Computed copy length (clamped against MDL ByteCount).
    • r15d = Actual returned data length.
    • If r8d > (r15d - 0xa), the read is out of bounds. The memory accessed at [rdx + r8d] will exceed the MDL mapping boundary.
  • Key Instructions/Offsets:

  • 0x1c006579f (mov ecx, dword [rdx+0x28]): The instruction that improperly reads the MDL ByteCount to clamp the copy length instead of using the returned data size.
  • 0x1c00657bc: The memmove call executing the OOB read.

  • Trigger Setup:

  • Set up an AFD listening socket and issue an AcceptEx.
  • Drive the SuperAccept path so AfdRestartSuperAcceptGetAddress processes a transport-address buffer whose length field at offset +8 exceeds the actual returned data size by up to ~10 bytes.

  • Expected Observation: Upon hitting 0x1c00657bc, inspect the source and destination pointers in rcx and rdx. Use !pte <rdx+r8d> to observe that the copy operation crosses the end of the mapped buffer. After executing the call, inspect the AcceptEx address output returned to user mode; the trailing bytes will contain memory read from beyond the mapped buffer.

8. Changed Functions — Full Triage

Two of the changed functions carry the primary security fix (Finding A): AfdSanConnectHandler (sub_1C00772A0 unpatched / sub_1C00771B0 patched) and AfdSanAcceptCore (sub_1C007608C). In both, the unpatched build gates the address-copy bounds clamp behind g_Feature_232206649_59779869 and retains an unclamped legacy copy path as the default; the patched build removes that feature flag and applies the clamp unconditionally (see Section 2, Finding A). These are the out-of-bounds write fix, not cosmetic changes.

The following functions (AfdSocketTransferEnd (sub_1C005B160), AfdSocketTransferBegin (sub_1C005B340), AfdQueryProviderInfo (sub_1C0036B40), AfdFreeTransportInfo (sub_1C0036BF8), AfdGetTransportInfo (sub_1C00369B0), WskTdiCreateCO (sub_1C0043978), WskTdiCreateAO (sub_1C004359C), AfdTdiCreateAO (sub_1C003AE08), AfdWaitForListen (sub_1C00589B0)) add device-attachment reference validation (IoGetDeviceAttachmentBaseRef / IoGetRelatedDeviceObject) gated behind newly added feature flags (g_Feature_3082489147_60797107, g_Feature_3420192058), with the original path retained in the disabled branch. Because the old path is kept and the checks are feature-gated, this is staged defense-in-depth rather than a delivered, unconditionally-enforced fix. AfdGetInformation (sub_1C00360D0) and AfdUnload (sub_1C003D2A0) only pick up the same feature-gated device-reference plumbing plus renumbering. AfdBind (sub_1C0034AD0) is a control-flow simplification (a removed feature gate and a transport-info structure offset shift from 0x38 to 0x40) with no change to the reachability of its raw-socket security check.

9. Unmatched Functions

No unmatched functions were added or removed in the patched binary.

10. Confidence & Caveats

  • Confidence: High. For both findings the two builds directly show a bounds clamp that is feature-gated (with an unsafe default path) in the unpatched binary and unconditional in the patched binary. Finding A is an unclamped memmove length (source length field) versus a capacity clamp; Finding B is a clamp against the MDL ByteCount versus the actual returned data size.
  • Assumptions: It is assumed the feature flags g_Feature_232206649_59779869 (Finding A) and g_Feature_499346745_59190809 (Finding B) return FALSE in the default unpatched OS state, so the unsafe default paths are the ones taken. This is typical for Microsoft's phased rollout architecture, and the patched build hard-removes both flags in favor of the clamped paths.
  • Verification Needed: For Finding A, confirm the size of the destination MDL-mapped SAN accept buffer and the maximum attainable value of the 16-bit source length field to bound how far the write reaches past the buffer. For Finding B, verify the page alignment and size of the MDL-mapped buffer to determine, for a given AcceptEx output-buffer size, whether the up-to-10-byte overread stays within the buffer's final mapped page or crosses into the following system mapping. !pte on the source-plus-length address confirms whether the access leaves the mapped region.