afd.sys — missing output-buffer bounds check on accepted-connection transport-address copy in AfdSanAcceptCore (CWE-120) fixed
KB5075912
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(unpatched0x1C007D410, patched0x1C007D550) 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(unpatched0x1C007D410, patched0x1C007D550; 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()(at0x1C0009C00), with a newIrp->RequestorMode == 0branch. When the feature is enabled, a non-raw user-mode bind passes0x101instead of0x100as the 7th argument toAfdTdiCreateAO, while kernel-mode binds keep0x100. 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_DENIEDcheck (AfdDisableRawSecurity == 0 && *(byte)(obj+3) == 0, status0xC0000022) is preserved unchanged and fires identically for user and kernel callers. All of the previously listed data/global/completion-routine "changes" (data_1c002a7d8→data_1c002a7f8, thesub_1c006c6xx/sub_1c003e8xx/sub_1c003e7xxshifts,data_1c002a640→data_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@0x1C005FBF0→0x1C005FBE0(relocated; matched by content).AfdGetInformation@0x1C00397E0→0x1C00397F0, 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 (0x1C000A400 → 0x1C000A380) 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 @ 0x1C007D410→0x1C007D550 |
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 @ 0x1C005FBF0→0x1C005FBE0 |
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 @ 0x1C00397E0→0x1C00397F0 |
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 AfdRestartAccept → AfdServiceSuperAccept → AfdSanAcceptCore, 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.