1. Overview

Field Value
Unpatched binary afd_unpatched.sys
Patched binary afd_patched.sys
Overall similarity 0.9806
Matched functions 1228
Changed functions 6
Identical functions 1222
Unmatched (unpatched) 0
Unmatched (patched) 0

Verdict: The patch adds a missing upper-bound check on the transport-address copies that AFD performs into the caller's MDL-mapped accept output buffer. In the unpatched build the copy lengths are read from the transport-address structure and used directly, compared against the output-buffer length only for the value zero. The same unbounded pattern exists in two functions, AfdSanAcceptCore (accept IRP completion) and AfdSanConnectHandler (SAN connect handler). The patch clamps each copy length to the output-buffer length. Both fixes are gated behind the runtime feature flag Feature_97988921__private_IsEnabledDeviceUsage; when that flag is off, the patched code path is byte-for-byte the old, unbounded code.

A second, independent security fix is also present in the BTHMS_RFCOMM socket-transfer / information-query paths (Finding 4). In the unpatched build the unbind race-protection lock (AfdPreventUnbind / AfdReallowUnbind) around the connection's IofCallDriver + KeWaitForSingleObject transfer (in AfdSocketTransferBegin / AfdSocketTransferEnd) and around the deliver-status AfdIssueDeviceControl (in AfdGetInformation) is taken only when a private feature flag is enabled; with the flag off, those paths issue I/O on the connection's device/file objects with no protection against a concurrent AfdUnbind tearing the connection down. The patch deletes those feature gates entirely (the gate functions Feature_831225147* and Feature_51867961* no longer exist anywhere in the patched image) and makes the lock unconditional, so the protection is now mandatory rather than a staged rollout.


2. Vulnerability Summary

Finding 1 — High severity

  • Vulnerability class: Kernel out-of-bounds write / buffer copy without checking size of input (CWE-120).
  • Affected function: AfdSanAcceptCore @ 0x1C007C0CC (AFD SAN accept IRP completion routine).
  • Companion instance: AfdSanConnectHandler (unpatched 0x1C007D410, patched 0x1C007D550) carries the identical unbounded-copy pattern and receives the identical clamp fix (see Finding 2).

Root cause

AfdSanAcceptCore runs when an AFD SAN accept IRP completes. It copies the accepted connection's transport-address data into the caller's output buffer, which has been MDL-mapped into kernel address space. The copy destination base is the mapped buffer at *(IRP+8); the write offsets and the output-buffer length live in a descriptor at *(IRP+0xB8) (call it rbx), where *(rbx+0x10) is the output-buffer length and *(rbx+8) / *(rbx+0x18) are write offsets.

The copy lengths are taken from the connection's transport-address structure (v40 = *(IRP-context+0x18)): the address length word at +0x14 and an ancillary length word at +0x18. In the unpatched build these lengths are used directly as memmove sizes (plus a small constant), and the only check against the output-buffer length is:

0x1C007C3A8  mov   edx, [rbx+10h]      ; edx = output-buffer length
0x1C007C3AB  test  edx, edx
0x1C007C3AD  jz    0x1C007C56B          ; skip the copies only when length == 0

There is no upper-bound check. If the transport-address length fields are larger than the output buffer, the memmove writes past the end of the MDL-mapped kernel allocation.

The function performs two logical copies, each in two variants selected by the transport flag at *(connection+0xAC): 1. The transport-address copy (size = *(+0x14) + 8 for the user-transport variant, *(+0x14) + 2 for the kernel-transport variant). 2. An ancillary copy, gated by *(rbx+0x18) != 0 (size = *(+0x18) + 4 / + 2).

The patch: 1. Adds a lower-bound gate *(rbx+0x10) >= 0xC before the copies (behind the feature flag). 2. Clamps the transport-address copy size to the output-buffer length: cmp eax, r12d / cmovb r12d, eax (user variant), and to length - 2 via an overflow-safe compare (sub rcx, r13 / cmp rcx, rax / jnb, kernel variant). 3. Adds a minimum-size gate on the ancillary copy (cmp eax, 6 / jb bail) and clamps its size (cmovnb/cmovge). 4. Leaves the copy primitive unchanged: both builds call the same memmove (it is at 0x1C000A400 in the unpatched image and at 0x1C000A380 in the patched image only because the module relocated; it is the same function). 5. Gates all of the above behind Feature_97988921__private_IsEnabledDeviceUsage. With the feature disabled, the patched ancillary path falls through to the original unbounded copies.

Reachability

AfdSanAcceptCore is called from AfdServiceSuperAccept (0x1C004D208), AfdAccept (0x1C004D270), and AfdServiceWaitForListen (0x1C004DAC8). AfdServiceSuperAccept is in turn reached from AfdRestartAccept (0x1C004D0B0), the accept-event completion path. These call edges are present in the binary. The end-to-end path from a user-mode accept() down to this completion routine, and the exact IOCTL used, were not traced through the full dispatch table and are not asserted here.

Finding 2 — High severity (same mechanism, second location)

  • Affected function: AfdSanConnectHandler (unpatched 0x1C007D410, patched 0x1C007D550; relocated, matched by content).
  • This is not the caller of AfdSanAcceptCore; it is a sibling SAN handler that copies the peer/local address into the accept output MDL with the same unbounded pattern.

In the unpatched build the peer-address copy uses the length word inside the SAN connect buffer (*(u16*)Src) directly:

memmove(v55,     (char *)Src + 2, *(unsigned __int16 *)Src + 2LL);   // unbounded
memmove(v55 + 8, Src,             *(unsigned __int16 *)Src + 4LL);   // unbounded

The patched build wraps the same copies in a feature-gated branch that clamps the length to the destination capacity (u16)Parameters.Read.ByteOffset.LowPart and adds a >= 6 minimum-size guard:

if ( Feature_97988921__private_IsEnabledDeviceUsage() != 0 ) {
    if ( v52 >= 6 ) {                        // NEW minimum-size guard
        v53 = (unsigned __int16)v52;         // destination capacity
        ...
        v62 = v58 + 2;
        if ( v58 + 2 >= v53 - 2 ) v62 = v53 - 2;   // NEW clamp to capacity - 2
        memmove(v56, (char *)Src + 2, v62);
        ...
        v68 = v58 + 4;
        if ( v58 + 4 >= v53 - 8 ) v68 = v53 - 8;   // NEW clamp to capacity - 8
        memmove(v56 + 8, v57, v68);
    }
} else if ( v52 != 0 ) {
    // feature-off fallback == the old unbounded copy
    memmove(v71, (char *)Src + 2, *(unsigned __int16 *)Src + 2LL);
    memmove(v71 + 8, v72, *v72 + 4LL);
}

The low similarity (0.2334) is driven by this added clamp block plus relocation. The following claims that appeared under this heading are not supported by the binaries and have been removed: the rax_1 < 0x14 || rax_1 > 0xfffffff3 bound, the ExAllocatePoolWithQuotaTag(size = rax_1 + 0xc) allocation, the rax_6 < 0x20 output check, the IoIs32bitProcess-aware 8/16-byte output sizing, and the input-length bounds check are all present identically in both builds (they are pre-existing, not added by the patch). There is no STATUS_ACCESS_DENIED (0xC0000022) path in either build.

Finding 3 — Low severity (feature-gated behavioral tweak, not a memory-safety fix)

  • Affected function: AfdBind @ 0x1C0038DB0 (AFD bind handler).
  • The one real code change: the raw-socket security region is now gated behind Feature_3299291449__private_IsEnabledDeviceUsage() (at 0x1C0009C00), with a new Irp->RequestorMode == 0 branch. When the feature is enabled, a non-raw user-mode bind passes 0x101 instead of 0x100 as the 7th argument to AfdTdiCreateAO, while kernel-mode binds keep 0x100. With the feature disabled the function behaves exactly as before.
  • This does not add or remove a "who can bind" gate: the raw-socket STATUS_ACCESS_DENIED check (AfdDisableRawSecurity == 0 && *(byte)(obj+3) == 0, status 0xC0000022) is preserved unchanged and fires identically for user and kernel callers. All of the previously listed data/global/completion-routine "changes" (data_1c002a7d8data_1c002a7f8, the sub_1c006c6xx/sub_1c003e8xx/sub_1c003e7xx shifts, data_1c002a640data_1c002a660, the helper and telemetry-global shifts) are relocation artifacts: each address resolves to the same named symbol in both builds (AfdGlobalData, AfdRestartBindGetAddress, AfdTdiCreateAO, AfdIsAddressInUse, AfdPriorityBoost, AfdGetTransportInfo, AfdTdiSetEventHandlers, and the per-build WPP trace-GUID global). Not a memory-safety fix.

Finding 4 — High severity (race condition / use-after-free from missing unbind synchronization)

  • Vulnerability class: Kernel race condition from missing synchronization, leading to use-after-free (CWE-362, leading to CWE-416).
  • Affected functions (three locations, same mechanism):
  • AfdSocketTransferEnd @ 0x1C005F9A0 (same address both builds; content changed).
  • AfdSocketTransferBegin @ 0x1C005FBF00x1C005FBE0 (relocated; matched by content).
  • AfdGetInformation @ 0x1C00397E00x1C00397F0, the deliver-status handler ([rbx+2] value 3/4 path reached via the query buffer).

Root cause

These handlers run for BTHMS_RFCOMM-backed sockets (gated by RtlEqualUnicodeString(L"\Device\BTHMS_RFCOMM")). Each one forwards an IRP to the underlying transport: AfdSocketTransferBegin / AfdSocketTransferEnd call IofCallDriver then KeWaitForSingleObject on the connection's device object (rdi) and file object; AfdGetInformation calls AfdIssueDeviceControl on the connection's file object (*(rbx+0x18)). Before doing so they are supposed to take an unbind lock, AfdPreventUnbind, and release it afterward with AfdReallowUnbind. AfdPreventUnbind is what stops a concurrent AfdUnbind from freeing/tearing down the connection while the I/O is outstanding.

In the unpatched build this lock is taken only when a private feature flag is enabled. AfdSocketTransferBegin/End gate it behind Feature_831225147__private_IsEnabledDeviceUsage; AfdGetInformation gates it behind Feature_51867961__private_IsEnabledDeviceUsage. When the flag is off, the gate check jumps straight past AfdPreventUnbind into the IofCallDriver/KeWaitForSingleObject (or AfdIssueDeviceControl) sequence with no unbind protection, and the matching AfdReallowUnbind is skipped as well. A concurrent AfdUnbind on the same endpoint can then free the connection's device/file object while this path is dereferencing it and waiting on it: a use-after-free / race window.

In the patched build the feature gates are removed entirely. AfdPreventUnbind is now called unconditionally (subject only to the pre-existing AFD magic/flag short-circuits) and AfdReallowUnbind is called unconditionally on the way out. The gate functions themselves are gone: Feature_831225147__private_IsEnabledDeviceUsage, Feature_831225147__private_IsEnabledFallback, Feature_51867961__private_IsEnabledDeviceUsage, and Feature_51867961__private_IsEnabledFallback do not appear anywhere in afd_patched.sys (symbol-set diff: present in the unpatched image, absent in the patched image). Because the gate and its unprotected fallback path are both deleted, this is a genuine hardening fix that makes the lock mandatory, not feature-staging with the old path retained.

Evidence — AfdSocketTransferEnd @ 0x1C005F9A0

; === UNPATCHED: unbind lock is gated behind the feature flag ===
0x1C005FA72  call    Feature_831225147__private_IsEnabledDeviceUsage
0x1C005FA77  test    eax, eax
0x1C005FA79  jz      loc_1C005FB29             ; feature OFF -> skip AfdPreventUnbind entirely
; ... (AFD magic/flag short-circuits) ...
0x1C005FAB2  call    AfdPreventUnbind          ; reached ONLY when the feature is ON
0x1C005FAB7  test    al, al
0x1C005FAC2  mov     r15b, 1                   ; remember to reallow later
; --- feature-OFF path: straight into the transfer, no lock held ---
0x1C005FB29  test    rdi, rdi
0x1C005FB2C  jz      loc_1C005FAD5
0x1C005FB78  lock inc dword ptr [rbx+0E8h]
0x1C005FB85  call    cs:__imp_IofCallDriver     ; I/O on connection device/file object, unprotected
0x1C005FBAD  call    cs:__imp_KeWaitForSingleObject
; --- reallow is also gated by the same flag ---
0x1C005FADA  call    Feature_831225147__private_IsEnabledDeviceUsage
0x1C005FAE3  test    r15b, r15b
0x1C005FAEB  call    AfdReallowUnbind

; === PATCHED: no feature call; lock is unconditional ===
0x1C005FA72  movzx   eax, word ptr [rbx]        ; feature call removed
; ... (same AFD magic/flag short-circuits) ...
0x1C005FAA5  call    AfdPreventUnbind          ; UNCONDITIONAL
0x1C005FAAA  test    al, al
0x1C005FAB8  mov     r14b, 1
0x1C005FB2E  call    cs:__imp_IofCallDriver     ; now always preceded by AfdPreventUnbind
0x1C005FB56  call    cs:__imp_KeWaitForSingleObject
0x1C005FB86  test    r14b, r14b
0x1C005FB8E  call    AfdReallowUnbind          ; UNCONDITIONAL (when the lock was taken)

Evidence — AfdGetInformation deliver-status path

; === UNPATCHED: lock gated behind Feature_51867961 ===
0x1C0039BC6  call    Feature_51867961__private_IsEnabledDeviceUsage
0x1C0039BCB  test    eax, eax
0x1C0039BCD  jz      loc_1C0039C79             ; feature OFF -> jump past AfdPreventUnbind
0x1C0039C09  call    AfdPreventUnbind          ; feature-ON path only
0x1C0039C58  call    AfdIssueDeviceControl      ; deliver-status I/O on FileObject *(rbx+0x18)
0x1C0039C72  call    AfdReallowUnbind
; --- feature-OFF path issues the same device control with no lock ---
0x1C0039C9D  call    AfdIssueDeviceControl      ; unprotected

; === PATCHED: no feature call; lock unconditional ===
0x1C0039C0C  call    AfdPreventUnbind          ; UNCONDITIONAL
0x1C0039C5B  call    AfdIssueDeviceControl
0x1C0039C75  call    AfdReallowUnbind

AfdSocketTransferBegin carries the byte-for-byte same change: unpatched gates AfdPreventUnbind (0x1C005FD02) / AfdReallowUnbind (0x1C005FD3B) behind Feature_831225147__private_IsEnabledDeviceUsage (0x1C005FCC2, 0x1C005FD2A) around its IofCallDriver (0x1C005FDD5) / KeWaitForSingleObject (0x1C005FDFD); patched calls AfdPreventUnbind (0x1C005FCE5) unconditionally and drops the feature calls.

Direction and classification

The direction is unambiguous: unpatched takes the unbind lock only conditionally (weaker), patched takes it always (stricter). The demonstrable defect is the missing synchronization (CWE-362); its consequence is a use-after-free of the connection's device/file object if a concurrent AfdUnbind wins the race (CWE-416).

Reachability

All three functions self-gate on RtlEqualUnicodeString(L"\Device\BTHMS_RFCOMM"), i.e. they only run for sockets backed by the Bluetooth RFCOMM transport, and are entered through AFD's IOCTL processing from a user-mode socket handle (AfdGetInformation via the AFD_GET_INFO / IOCTL_AFD_GET_INFORMATION deliver-status query). This makes the paths attacker-reachable from user mode for that transport. As with the primary finding, the end-to-end IOCTL dispatch was not traced through the full dispatch table, and exploitation additionally requires winning the race against a concurrent unbind; the severity reflects that this is a genuine but not-fully-traced kernel memory-safety fix.


3. Pseudocode Diff

AfdSanAcceptCore @ 0x1C007C0CC — before/after

// =================== UNPATCHED (unbounded) ===================
v23 = v15[4];                                   // output-buffer length: *( *(IRP+0xB8) + 0x10 )
if ( v23 != 0 )                                 // ONLY a zero check -- no upper bound
{
    v24 = *(_WORD *)(v16 + 20);                 // address length word at +0x14 of the connection data
    v25 = *(struct _MDL **)(a1 + 8);            // caller output MDL
    if ( *(_BYTE *)(v8 + 172) != 0 )            // transport flag at +0xAC (kernel-transport variant)
    {
        LOWORD(Object) = v24 + 2;
        // ... map the MDL ...
        memmove(&v30[v29 + v28], (const void *)(v40 + 22), (unsigned __int16)Object);   // size = *(+0x14) + 2
        *(_WORD *)&Irql[v43 - 2] = (_WORD)Object;
    }
    else                                        // user-transport variant
    {
        // ... map the MDL ...
        memmove(&v31[v15[2] + v15[6]], (const void *)(v16 + 16), v24 + 8LL);            // size = *(+0x14) + 8
    }
    if ( v15[6] != 0 )                          // ancillary copy, gated only by *(rbx+0x18) != 0
    {
        v32 = v40 + *(unsigned __int16 *)(v40 + 20);
        v35 = &v34[v15[2]];
        if ( *(_BYTE *)(v8 + 172) != 0 )
            memmove(v35, (const void *)(v32 + 26), *(unsigned __int16 *)(v32 + 24) + 2LL);   // unbounded
        else
            memmove(v35 + 8, (const void *)(v32 + 24), *(unsigned __int16 *)(v32 + 24) + 4LL); // unbounded
    }
}

// =================== PATCHED (bounded, feature-gated) ===================
if ( v16[4] != 0
     && ((unsigned int)Feature_97988921__private_IsEnabledDeviceUsage() == 0 || v16[4] >= 0xCu) )  // NEW length floor
{
    LODWORD(v66) = *(unsigned __int16 *)(v17 + 20);     // address length at +0x14
    v24 = (_DWORD)v66 + 8;
    if ( *(_BYTE *)(v8 + 172) != 0 )                    // kernel-transport variant
    {
        v25 = (_WORD)v66 + 2;
        if ( Feature_97988921__private_IsEnabledDeviceUsage() != 0
             && (unsigned __int64)(unsigned int)v16[4] - 2 < v25 )
            v25 = *((_WORD *)v16 + 8) - 2;              // NEW: clamp to (length - 2)
        // ... map ...
        memmove(&v30[v28 + v29], (const void *)(v61 + 22), v25);
    }
    else                                                // user-transport variant
    {
        if ( Feature_97988921__private_IsEnabledDeviceUsage() != 0 && v16[4] < v24 )
            v24 = v16[4];                               // NEW: clamp to length
        // ... map ...
        memmove(&v33[v16[2] + v16[6]], (const void *)(v61 + 16), v24);
    }
    if ( Feature_97988921__private_IsEnabledDeviceUsage() != 0 )
    {
        if ( v16[6] >= 6 )                              // NEW: minimum-size gate on the ancillary copy
        {
            // ... clamp v46 / v52 to the remaining destination space, then memmove ...
        }
    }
    else if ( v16[6] != 0 )                             // feature-off fallback == old unbounded copies
    {
        memmove(v56, (const void *)(v53 + 26), *(unsigned __int16 *)(v53 + 24) + 2LL);
        memmove(v56 + 8, (const void *)(v53 + 24), *(unsigned __int16 *)(v53 + 24) + 4LL);
    }
}

The critical change is the introduction of: - the length floor v16[4] >= 0xC (asm cmp dword [rbx+10h], 0Ch / jb), - the transport-address size clamp cmovb r12d, eax (user variant) and the sub rcx, r13 / cmp rcx, rax / jnb clamp to length - 2 (kernel variant), - the ancillary minimum-size gate cmp eax, 6 / jb and its cmovnb / cmovge clamps, - all gated behind Feature_97988921__private_IsEnabledDeviceUsage.

The copy primitive is unchanged: every call is to memmove in both builds.


4. Assembly Analysis

AfdSanAcceptCore @ 0x1C007C0CC — unpatched (unbounded copies)

; === capacity gate: only a zero-check ===
0x1C007C3A8  mov     edx, [rbx+10h]           ; edx = output-buffer length; rbx = *(IRP+0xB8)
0x1C007C3AB  test    edx, edx
0x1C007C3AD  jz      0x1C007C56B               ; skip all copies only when length == 0  (no upper bound)
0x1C007C3B3  movzx   r8d, word ptr [r10+14h]   ; copy length from connection data +0x14

; === transport-address copy, kernel-transport variant (flag != 0) ===
0x1C007C446  movzx   r8d, word ptr [rsp+0A8h+Object]   ; size = *(+0x14) + 2
0x1C007C44F  mov     rdx, [rsp+0A8h+var_60]
0x1C007C454  add     rdx, 16h                  ; Src = connection data + 0x16
0x1C007C461  add     rcx, rax                  ; dst inside MDL-mapped buffer
0x1C007C464  call    memmove                    ; *** unbounded copy ***

; === transport-address copy, user-transport variant (flag == 0) ===
0x1C007C4AE  movzx   r8d, r8w
0x1C007C4B2  add     r8, 8                     ; size = *(+0x14) + 8
0x1C007C4B6  lea     rdx, [r10+10h]            ; Src = connection data + 0x10
0x1C007C4C0  add     rax, r9
0x1C007C4C3  add     rcx, rax                  ; dst inside MDL-mapped buffer
0x1C007C4C6  call    memmove                    ; *** unbounded copy ***

; === ancillary copy, gated only by (*(rbx+0x18) != 0) ===
0x1C007C4CB  cmp     [rbx+18h], edi
0x1C007C4CE  jbe     0x1C007C56B
0x1C007C512  movzx   r8d, word ptr [r12+18h]   ; size from +0x18 field
0x1C007C51E  add     r8, r13                   ; + 2  (kernel variant)
0x1C007C529  call    memmove                    ; *** unbounded copy ***
0x1C007C54B  movzx   r8d, word ptr [r12+18h]
0x1C007C551  add     r8, 4                     ; + 4  (user variant)
0x1C007C55E  call    memmove                    ; *** unbounded copy ***

AfdSanAcceptCore @ 0x1C007C0CC — patched (bounded, feature-gated)

; === capacity gate now has a real lower bound behind the feature flag ===
0x1C007C3B8  cmp     [rbx+10h], esi            ; esi = 0
0x1C007C3BB  jbe     0x1C007C6A5               ; bail if length == 0
0x1C007C3C1  call    Feature_97988921__private_IsEnabledDeviceUsage
0x1C007C3C6  test    eax, eax
0x1C007C3C8  jz      0x1C007C3D4
0x1C007C3CA  cmp     dword ptr [rbx+10h], 0Ch  ; *** NEW: require length >= 12 ***
0x1C007C3CE  jb      0x1C007C6A5

; === user-transport variant: clamp size to the output-buffer length ===
0x1C007C4C1  call    Feature_97988921__private_IsEnabledDeviceUsage
0x1C007C4C6  test    eax, eax
0x1C007C4C8  jz      0x1C007C4D4
0x1C007C4CA  mov     eax, [rbx+10h]
0x1C007C4CD  cmp     eax, r12d
0x1C007C4D0  cmovb   r12d, eax                 ; *** NEW: clamp size to length ***
0x1C007C4F5  mov     r8d, r12d                 ; Size = clamped
0x1C007C50E  call    memmove

; === kernel-transport variant: overflow-safe clamp to (length - 2) ===
0x1C007C405  call    Feature_97988921__private_IsEnabledDeviceUsage
0x1C007C40E  mov     ecx, [rbx+10h]
0x1C007C411  sub     rcx, r13                  ; length - 2
0x1C007C418  cmp     rcx, rax
0x1C007C41B  jnb     0x1C007C426
0x1C007C41D  movzx   r12d, word ptr [rbx+10h]
0x1C007C422  sub     r12w, r13w                ; *** NEW: clamp to length - 2 ***
0x1C007C4A9  call    memmove

; === ancillary copy: new minimum-size guard + clamp (feature on); old path when off ===
0x1C007C519  call    Feature_97988921__private_IsEnabledDeviceUsage
0x1C007C528  test    ecx, ecx
0x1C007C52A  jz      0x1C007C617               ; feature off -> old unbounded fallback
0x1C007C530  cmp     eax, 6
0x1C007C533  jb      0x1C007C6A5               ; *** NEW: require ancillary length >= 6 ***
0x1C007C5AD  cmovnb  dx, r13w                  ; *** NEW: clamp (kernel variant) ***
0x1C007C5C2  call    memmove
0x1C007C5FC  cmovge  dx, r13w                  ; *** NEW: clamp (user variant) ***
0x1C007C60D  call    memmove
0x1C007C66E  call    memmove                    ; feature-off fallback (unbounded, == unpatched)
0x1C007C6A0  call    memmove                    ; feature-off fallback (unbounded, == unpatched)

Key takeaways: - The output-buffer length lives at *(rbx+0x10) where rbx = *(IRP+0xB8). In the unpatched build it is only compared to zero. - The copy sizes come from the connection transport-address words at +0x14 and +0x18; in the unpatched build they reach memmove with no upper bound. - The patch adds a length floor (>= 0xC), size clamps (cmovb / overflow-safe sub/cmp, cmovnb/cmovge), and an ancillary minimum-size gate (cmp eax, 6), all behind Feature_97988921__private_IsEnabledDeviceUsage. - The same memmove function is used before and after; its address moved (0x1C000A4000x1C000A380) only because the module relocated.


5. Trigger Conditions

The out-of-bounds write occurs when AfdSanAcceptCore (or AfdSanConnectHandler) copies a transport address whose length field exceeds the caller's output-buffer length, and the patch's feature flag is not in effect (unpatched build, or patched build with the feature disabled).

Preconditions visible in the code: 1. The accept output buffer is caller-supplied and MDL-mapped; its length is *( *(IRP+0xB8) + 0x10 ). A small output buffer makes the overflow easier. 2. The transport-address length words (+0x14, +0x18 of the connection data) must exceed that length. These come from the accepted connection's stored transport address (AfdSanAcceptCore) or from the SAN connect request buffer (AfdSanConnectHandler). 3. The ancillary copy additionally requires *( *(IRP+0xB8) + 0x18 ) != 0 (unpatched gate).

Whether a remote peer can directly control these length fields over the wire is not established by these functions alone; the length words are read from the connection/transport-address structure that AFD has already populated. Reproduction therefore depends on which transport stack populated that structure and how large an address it can present.

Observable confirmation of an overflow would be pool-corruption bugchecks on a following pool operation (e.g. 0x19 BAD_POOL_HEADER, 0xC2 BAD_POOL_CALLER) or 0x50 PAGE_FAULT_IN_NONPAGED_AREA when the write crosses the allocation boundary; with pool grooming the corruption can be silent.


6. Exploit Primitive & Development Notes

Primitive

A relative kernel write past the end of the MDL-mapped accept output allocation. The write: - length is driven by the 16-bit transport-address length words (+0x14 / +0x18), - content is the transport-address bytes, - destination is the caller's output buffer inside a pool allocation whose length is *( *(IRP+0xB8) + 0x10 ).

Turning it into a full exploit

The following is standard kernel-pool-overflow methodology; none of it is specific to a value read out of these binaries: 1. Pool grooming. Place a target object immediately after the accept output allocation by spraying objects of the same size class. 2. Corrupt an adjacent object. Overwrite a LIST_ENTRY or a callback/field in the following object. 3. KASLR defeat. Requires a separate information leak on modern Windows. 4. Code execution. Redirect control via the corrupted object, subject to the mitigations below.

Mitigation considerations

  • Pool cookie / safe unlinking, SMEP/SMAP, CFG, and HVCI/VBS all apply as usual on modern Windows x64 and constrain which corruption targets and control-flow strategies are viable.

These notes are general and are not derived from the disassembly; they are included only to characterize the class of the primitive.


7. Debugger PoC Playbook

WinDbg / KD attached to afd_unpatched.sys. Run lm afd to obtain the load base and add the function-relative offsets below.

Breakpoints

; (1) Accept IRP completion entry.
bp afd!AfdSanAcceptCore
;     rcx = arg1 (IRP), rdx = arg2 (file-object pointer), r8 = arg3 (IRP-context entry)

; (2) The copy primitive. Stop on every call; r8 = size, rdx = source, rcx = destination.
bp afd!memmove

; (3) The sibling handler with the same unbounded copy.
bp afd!AfdSanConnectHandler

What to inspect

At afd!AfdSanAcceptCore: after the prologue, rbx is loaded from *(IRP+0xB8); dd rbx+0x10 l1 is the output-buffer length. The connection data pointer (source of the length words) is *(IRP-context+0x18), with the address length at +0x14 and the ancillary length at +0x18.

At afd!memmove: compare @r8 (size) against the output-buffer length at *(rbx+0x10); @r8 greater than the length is the overflow condition.

Key instructions / offsets (function-relative to AfdSanAcceptCore)

Offset Address Instruction Notes
+0x2DC 0x1C007C3A8 mov edx, [rbx+10h] Loads the output-buffer length; only zero-checked in unpatched.
+0x398 0x1C007C464 call memmove Transport-address copy (kernel-transport variant), unbounded.
+0x3FA 0x1C007C4C6 call memmove Transport-address copy (user-transport variant), unbounded.
+0x45D 0x1C007C529 call memmove Ancillary copy (kernel variant), unbounded.
+0x492 0x1C007C55E call memmove Ancillary copy (user variant), unbounded.
Patched +0x2FE 0x1C007C3CA cmp dword [rbx+10h], 0Ch ADDED length floor.
Patched +0x404 0x1C007C4D0 cmovb r12d, eax ADDED size clamp.

8. Changed Functions — Full Triage

Function Similarity Change type Note
AfdSanAcceptCore @ 0x1C007C0CC 0.8249 security Primary fix. Accept IRP completion. Adds a length floor (>= 0xC), size clamps (cmovb; overflow-safe sub/cmp; cmovnb/cmovge), and an ancillary minimum-size gate, all behind Feature_97988921__private_IsEnabledDeviceUsage. Copy primitive (memmove) unchanged.
AfdSanConnectHandler @ 0x1C007D4100x1C007D550 0.2334 security Sibling SAN handler with the same unbounded peer-address copy. Adds the same feature-gated clamp: bounds *(u16)Src + 2/+4 to the destination capacity (u16)Parameters.Read.ByteOffset.LowPart - 2/-8, with a >= 6 minimum guard. Low similarity is the added clamp block plus relocation.
AfdBind @ 0x1C0038DB0 0.2706 behavioral Raw-security region gated behind Feature_3299291449__private_IsEnabledDeviceUsage() (0x1C0009C00) with a new RequestorMode == 0 branch that selects 0x101 vs 0x100 as the 7th arg to AfdTdiCreateAO. The STATUS_ACCESS_DENIED raw-socket check is unchanged. All other listed deltas are relocation artifacts. Not a memory-safety fix.
AfdSocketTransferEnd @ 0x1C005F9A0 security Finding 4. Unbind race-protection (AfdPreventUnbind/AfdReallowUnbind) around IofCallDriver+KeWaitForSingleObject was gated behind Feature_831225147__private_IsEnabledDeviceUsage in the unpatched build (lock skipped when the flag is off); the patch deletes the gate and makes the lock unconditional. Race/UAF fix (CWE-362 → CWE-416).
AfdSocketTransferBegin @ 0x1C005FBF00x1C005FBE0 0.879 security Finding 4 (same mechanism). Same Feature_831225147-gated AfdPreventUnbind/AfdReallowUnbind around IofCallDriver+KeWaitForSingleObject; gate deleted, lock now unconditional. (Not merely register allocation / relocation.)
AfdGetInformation @ 0x1C00397E00x1C00397F0 0.1886 security Finding 4 (same mechanism). Deliver-status handler; the AfdPreventUnbind/AfdReallowUnbind pair around AfdIssueDeviceControl was gated behind Feature_51867961__private_IsEnabledDeviceUsage (lock skipped when off); the patch deletes the gate and makes the lock unconditional. Not pure switch-table reorganization.

9. Unmatched Functions

None added or removed (unmatched_unpatched: 0, unmatched_patched: 0). The fix is entirely in-place.


10. Confidence & Caveats

Confidence: High that the security-relevant change is the addition of bounds clamping on the transport-address copies in AfdSanAcceptCore and AfdSanConnectHandler. The unpatched code compares the output-buffer length to zero only and passes the connection's length words to memmove unbounded; the patched code adds a length floor, size clamps, and a minimum-size gate on the same copies. The direction (unpatched unbounded → patched bounded) is unambiguous.

Caveats and assumptions: - The fix is gated behind Feature_97988921__private_IsEnabledDeviceUsage. With the feature disabled, the patched code paths fall back to the original unbounded copies; the protection is only active when the flag is enabled. - The end-to-end reachability from a user-mode accept() and the exact IOCTL were not traced through the full dispatch table. The confirmed call edges are AfdRestartAcceptAfdServiceSuperAcceptAfdSanAcceptCore, plus direct calls from AfdAccept and AfdServiceWaitForListen. - Whether the transport-address length words are attacker-controlled end-to-end (and to what magnitude) depends on the transport stack that populated the connection/transport-address structure; it is not established by these functions in isolation. - AfdSanConnectHandler's pre-existing size and output-buffer validation (the < 0x14/> 0xFFFFFFF3 bound, the ExAllocatePoolWithQuotaTag(size + 0xc) allocation, the < 0x20 / 8 / 0x10 output checks, and the input-length bounds check) are present identically in both builds and are not part of this patch. - AfdBind is included for completeness; its change is a feature-gated requestor-mode tweak to an AO-creation flag, not an authorization or memory-safety fix. - All addresses are as they appear in the diffed images; in a live session use lm afd and add the function-relative offsets.