afd.sys — Out-of-bounds write via missing 10-byte header reservation (CWE-787) in AfdRestartSuperAcceptGetAddress fixed
KB5082123
1. Overview
- Unpatched Binary:
afd_unpatched.sys - Patched Binary:
afd_patched.sys - Overall Similarity Score: 0.979
- Diff Statistics: 1182 matched functions, 18 changed functions, 1164 identical functions, and 0 unmatched functions in either direction.
- Verdict: The patch delivers two genuine hardening changes. The headline change is in the SuperAccept address-completion routine
AfdRestartSuperAcceptGetAddress: the unpatched build gates the correct copy-length clamp behind a runtime feature flag, and when that flag is off it omits the 10-byte reservation needed for the in-place buffer shift, allowing a bounded out-of-bounds write into the mapped transport-address buffer (CWE-787). The patched build removes the gate and always clamps. A second genuine change removes a feature-flag gate that could skip device-stack validation during accept/connect (CWE-843). Two further changes — an added, feature-gated callback-serialization counter at endpoint offset +0xf8, and a reference-count ordering tweak inAfdAddConnectedReference— are feature-staged (the old behavior is retained in an else-branch) and are not demonstrable vulnerabilities in the unpatched build.
2. Vulnerability Summary
Finding 1: Out-of-Bounds Write in SuperAccept Address Copy
- Severity: High
- Vulnerability Class: Out-of-bounds Write (CWE-787)
- Affected Function:
AfdRestartSuperAcceptGetAddress(unpatched @0x1C006D7F0, patched @0x1C006D730) — the AcceptEx/SuperAccept IRP completion routine. - Root Cause: The routine maps the transport-address buffer and shifts its contents forward by 10 (
0xa) bytes withmemmove(dst = ptr+0xa, src = ptr, size). The copy length must therefore be clamped to leave 10 bytes of headroom at the destination. In the unpatched build the correct clamp (size - 0xa) is gated behindEvaluateCurrentState(g_Feature_298020153_59190810_FeatureDescriptorDetails). When that flag evaluates false, the code instead clamps only toMdlByteCount - offset, which does not reserve the 10 bytes consumed by the+0xadestination shift. As a result thememmovedestination can extend up to 10 bytes past the end of the mapped buffer, writing transport-supplied bytes into adjacent memory. - Scope / Correction of impact: The overwrite is bounded to at most 10 bytes past the destination end. The
memmovesource stays within the buffer (srcthroughsrc + sizewheresize <= MdlByteCount - offset), so there is no out-of-bounds read and no kernel-memory information disclosure. The storemov [r15+rbp-2], dithat follows is present identically in both builds and merely records the computed length; it is not part of the defect and not a separate corruption primitive. - Attacker-Reachable Entry Point & Data Flow:
- A local process issues an AcceptEx /
AFD_SUPER_ACCEPToperation on a listening socket over\Device\Afd. - The IRP is dispatched to the TCP/IP transport, which completes it with the accepted connection's address data.
- Completion invokes
AfdRestartSuperAcceptGetAddress, which maps the address MDL and reads the length field at[mapped + offset + 8]. - With the feature flag off, the length is clamped only against
MdlByteCount - offset, andmemmoveshifts the data forward by 10 bytes, writing up to 10 bytes past the buffer end.
Finding 2: Feature-Staged Callback Serialization Counter (endpoint +0xf8)
- Severity: Low
- Vulnerability Class: Race Condition (CWE-362) — not demonstrated
- Affected Functions:
AfdTLListenComplete(unpatched @0x1C005FC10, patched @0x1C0060E90),AfdSetInformation,AfdQueryCompartmentId,AfdTLPauseUnPause,AfdGetAddress,AfdGetInformation. - What actually changed: The patched build introduces a counter at endpoint offset
+0xf8that is atomically incremented before, and decremented after, TDI callback invocations, and adds awhile (*(rbx+0xf8) != 0) { pause; }spin-wait inAfdTLListenCompletebefore its callback dispatch. The unpatched build contains no reference to offset+0xf8in these paths at all (the endpoint structure grew by 8 bytes; e.g. the close-refcount field moved from[rbx+0x158]to[rbx+0x160]). - Why this is not rated as a delivered fix: Every
+0xf8increment/decrement and the spin-wait are gated behindEvaluateCurrentState(g_Feature_3088522553_60513072_FeatureDescriptorDetails), and each gate keeps the original counter-less path in the else-branch (jzskips the counter when the flag is off). This is a staged rollout of a serialization mechanism, not an unconditionally delivered change. The callback dispatch is otherwise byte-for-byte equivalent between builds, and no use-after-free or double-free is demonstrable in the unpatched code, so the underlying race is not established.
Finding 3: Type Confusion via Bypassed Device-Stack Validation
- Severity: High
- Vulnerability Class: Type Confusion (CWE-843)
- Affected Functions:
AfdCreateConnection(unpatched @0x1C0055BF0, patched @0x1C0056280),AfdCreateConnection_Old(@0x1C0007DE4in both). - Root Cause: During accept/connect the driver compares the device-stack base of the endpoint's connection object against that of the supplied handle (
IoGetDeviceAttachmentBaseRefon each, thencmp), returningSTATUS_INVALID_PARAMETER(0xC000000D) on mismatch. In the unpatched build the entire comparison block is wrapped inif (EvaluateCurrentState(g_Feature_1552108857_59735561_FeatureDescriptorDetails)); when the flag is false the check is skipped and execution falls through toAfdIssueDeviceControlwithout validating that the handle belongs to the same device stack. An attacker can then associate a handle from a different device stack with the AFD endpoint, causing subsequent I/O to interpret the wrong object type. - Direction: Correct. The patched build removes the gate entirely and performs the
IoGetDeviceAttachmentBaseRefcomparison unconditionally (the only preceding branch is thehandle != NULLnull-check), so the bypass path no longer exists. - Attacker-Reachable Entry Point & Data Flow:
NtDeviceIoControlFiletargets\Device\Afdwith an accept/connect request supplying an attacker-chosen handle.- In the unpatched build the feature flag is off, so the device-stack comparison is skipped.
- The mismatched handle is attached to the AFD endpoint via
AfdIssueDeviceControl.
Finding 4: Reference-Count Ordering in AfdAddConnectedReference
- Severity: Low
- Vulnerability Class: Race Condition (CWE-362) — not demonstrated
- Affected Function:
AfdAddConnectedReference(unpatched @0x1C0058F28, patched @0x1C0059A48). - What actually changed: The unpatched build performs
lock inc [rbx+0x30](the reference count) afterKeReleaseInStackQueuedSpinLock. The patched build can perform the samelock incbefore the release — but only whenEvaluateCurrentState(g_Feature_2476938553_60405306_FeatureDescriptorDetails)is true; when the flag is false it retains the original order (increment after release). The increment is an atomiclock incin both builds and in both branches, so the count is never torn regardless of lock position. This is a feature-staged ordering adjustment with the old path retained; no use-after-free is demonstrable.
3. Pseudocode Diff
Finding 1: AfdRestartSuperAcceptGetAddress (High OOB write)
// === UNPATCHED ===
// ptr = MappedSystemVa + offset; size field = *(ctx + 0x18)
uint16_t len = *(uint16_t*)(ptr + 8) + 2; // transport-supplied length + 2
if (EvaluateCurrentState(&g_Feature_298020153_59190810)) {
// FLAG ON: correct clamp, reserves 0xa for the +0xa destination shift
uint16_t cap = (uint16_t)(*(uint16_t*)(ctx + 0x18) - 0xa);
if (*(uint16_t*)(ctx + 0x18) < 0xa) cap = 0;
len = (len <= cap) ? len : cap;
} else {
// FLAG OFF: clamps only to (MdlByteCount - offset); NO 0xa reservation
uint32_t avail = irp->MdlAddress->ByteCount - offset;
if (avail < len) len = (uint16_t)(irp->MdlAddress->ByteCount - offset);
}
memmove(ptr, ptr + 0xa, len); // dst = ptr + 0xa can end up to 10 bytes past buffer
*(uint16_t*)(sizefield + ptr - 2) = len; // identical in both builds
// === PATCHED ===
// No feature flag; always the reserving clamp
uint16_t cap = (uint16_t)(*(uint16_t*)(ctx + 0x18) - 0xa);
if (*(uint16_t*)(ctx + 0x18) < 0xa) cap = 0;
uint16_t len = *(uint16_t*)(ptr + 8) + 2;
len = (cap <= len) ? cap : len; // min(size - 0xa, len)
memmove(ptr, ptr + 0xa, len);
*(uint16_t*)(sizefield + ptr - 2) = len;
Finding 3: AfdCreateConnection (High Type Confusion)
// === UNPATCHED ===
if (EvaluateCurrentState(&g_Feature_1552108857_59735561)) {
ObReferenceObjectByHandle(handle, ...);
DEVICE_OBJECT* a = IoGetDeviceAttachmentBaseRef(connectionDevice);
DEVICE_OBJECT* b = IoGetDeviceAttachmentBaseRef(IoGetRelatedDeviceObject(handleObj));
if (a != b) return 0xC000000D; // STATUS_INVALID_PARAMETER
}
// flag off: comparison skipped entirely -> falls through to attach
AfdIssueDeviceControl(...);
// === PATCHED ===
// No feature flag; comparison always runs when a handle is supplied
if (handle != NULL) {
ObReferenceObjectByHandle(handle, ...);
DEVICE_OBJECT* a = IoGetDeviceAttachmentBaseRef(connectionDevice);
DEVICE_OBJECT* b = IoGetDeviceAttachmentBaseRef(IoGetRelatedDeviceObject(handleObj));
if (a != b) return 0xC000000D;
}
AfdIssueDeviceControl(...);
Finding 4: AfdAddConnectedReference (Low, feature-staged ordering)
// === UNPATCHED ===
bts [rbx+4], 0xc; // set state bit under lock
KeReleaseInStackQueuedSpinLock(&h); // lock released
lock inc [rbx+0x30]; // atomic increment after release
// === PATCHED ===
bts [rbx+4], 0xc;
if (EvaluateCurrentState(&g_Feature_2476938553_60405306)) {
lock inc [rbx+0x30]; // atomic increment before release
KeReleaseInStackQueuedSpinLock(&h);
} else {
KeReleaseInStackQueuedSpinLock(&h); // old order retained
lock inc [rbx+0x30];
}
4. Assembly Analysis
Finding 1: AfdRestartSuperAcceptGetAddress copy-length clamp
Unpatched build (@ 0x1C006D7F0), the feature-gated clamp and copy:
00000001C006D8CE cmp [rdi+0ACh], r12b ; endpoint TLV/address flag (r12b=0); must be set
00000001C006D8D5 jz loc_1C006D96B
00000001C006D8F9 mov ebp, [rsi+8] ; offset into the mapped buffer
00000001C006D8FC lea rcx, g_Feature_298020153_59190810_FeatureDescriptorDetails
00000001C006D903 mov r15d, [rsi+18h] ; buffer size field
00000001C006D907 add rbp, rax ; ptr = MappedSystemVa + offset
00000001C006D90A movzx edi, word ptr [rbp+8] ; transport-supplied length
00000001C006D90E add di, 2 ; len = length + 2
00000001C006D912 call EvaluateCurrentState
00000001C006D917 test eax, eax
00000001C006D919 jz short loc_1C006D937 ; flag OFF -> weak clamp
; --- SAFE PATH (flag on): reserves 0xa ---
00000001C006D91B movzx eax, word ptr [rsi+18h]
00000001C006D91F cmp ax, 0Ah
00000001C006D923 lea ecx, [rax-0Ah] ; cap = size - 0xa
00000001C006D926 cmovb cx, r12w ; if size < 0xa, cap = 0
00000001C006D92B cmp cx, di
00000001C006D92E cmovnb cx, di ; len = min(cap, len)
00000001C006D932 movzx edi, cx
00000001C006D935 jmp short loc_1C006D955
; --- WEAK PATH (flag off): no 0xa reservation ---
00000001C006D937 mov rax, [rbx+8] ; MDL
00000001C006D93B mov r8d, [rsi+8] ; offset
00000001C006D93F mov edx, [rax+28h] ; MDL ByteCount
00000001C006D942 mov ecx, edx
00000001C006D944 sub ecx, r8d ; avail = ByteCount - offset (0xa NOT reserved)
00000001C006D947 movzx eax, di
00000001C006D94A cmp ecx, eax
00000001C006D94C jnb short loc_1C006D955
00000001C006D94E movzx edi, dx
00000001C006D951 sub di, r8w ; len = ByteCount - offset
; --- COPY: destination is shifted +0xa ---
00000001C006D955 movzx r8d, di ; Size
00000001C006D959 lea rdx, [rbp+0Ah] ; dst = ptr + 0xa
00000001C006D95D mov rcx, rbp ; src = ptr
00000001C006D960 call memmove ; up to 10 bytes past buffer end when flag off
00000001C006D965 mov [r15+rbp-2], di ; store computed length (identical in both builds)
Patched build (@ 0x1C006D730), the same region with the flag removed and only the reserving clamp:
00000001C006D832 mov edi, [r15+18h] ; size field
00000001C006D836 mov esi, [r15+8] ; offset
00000001C006D83A add rsi, rax ; ptr = MappedSystemVa + offset
00000001C006D83D lea eax, [rdi-0Ah] ; cap = size - 0xa
00000001C006D840 movzx ecx, word ptr [rsi+8]
00000001C006D844 lea rdx, [rsi+0Ah] ; dst = ptr + 0xa
00000001C006D848 add cx, 2 ; len = length + 2
00000001C006D84C cmp di, 0Ah
00000001C006D850 cmovb ax, r12w ; if size < 0xa, cap = 0
00000001C006D855 cmp ax, cx
00000001C006D858 cmovnb ax, cx ; len = min(cap, len)
00000001C006D85C mov rcx, rsi ; src = ptr
00000001C006D85F movzx ebx, ax
00000001C006D862 mov r8d, ebx ; Size
00000001C006D865 call memmove
00000001C006D86A mov [rdi+rsi-2], bx
There is no EvaluateCurrentState call and no ByteCount - offset fallback in the patched routine.
Finding 2: AfdTLListenComplete spin-wait (feature-gated)
Patched build (@ 0x1C0060E90):
00000001C0060F0F lea rcx, g_Feature_3088522553_60513072_FeatureDescriptorDetails
00000001C0060F2E call EvaluateCurrentState
00000001C0060F33 test eax, eax
00000001C0060F35 jz short loc_1C0060F47 ; flag OFF -> skip wait (old behavior)
00000001C0060F39 pause ; loop body
00000001C0060F3B mov rax, [rbx+0F8h] ; read counter
00000001C0060F42 test rax, rax
00000001C0060F45 jnz short loc_1C0060F39 ; spin while counter != 0
00000001C0060F47 ... call __guard_dispatch_icall_fptr ; callback dispatch (same in both builds)
The unpatched AfdTLListenComplete (@ 0x1C005FC10) has no +0xf8 read and no EvaluateCurrentState; it dispatches the callback directly. Every +0xf8 increment/decrement in the other listed functions is guarded by the same flag with a jz that skips the counter when it is off.
Finding 3: AfdCreateConnection device-stack comparison
Unpatched build (@ 0x1C0055BF0), gate that can skip the check:
00000001C0055F67 lea rcx, g_Feature_1552108857_59735561_FeatureDescriptorDetails
00000001C0055F6E call EvaluateCurrentState
00000001C0055F73 test eax, eax
00000001C0055F75 jz loc_1C0056076 ; flag OFF -> jump PAST the whole comparison
00000001C0055FCC call IoGetDeviceAttachmentBaseRef
00000001C0055FED call IoGetDeviceAttachmentBaseRef
00000001C0055FFC cmp rsi, rax
00000001C0055FFF jz loc_1C005604C ; match -> proceed
00000001C0056042 mov eax, 0C000000Dh ; mismatch -> STATUS_INVALID_PARAMETER
Patched build (@ 0x1C0056280) performs the comparison unconditionally (only a handle != NULL null-check precedes it):
00000001C005662E call IoGetDeviceAttachmentBaseRef
00000001C005664F call IoGetDeviceAttachmentBaseRef
00000001C005665E cmp rdi, rax
00000001C0056661 jz loc_1C00566AE ; match
00000001C00566A4 mov eax, 0C000000Dh ; mismatch -> STATUS_INVALID_PARAMETER
Finding 4: AfdAddConnectedReference increment ordering
Unpatched (@ 0x1C0058F28): KeReleaseInStackQueuedSpinLock at 0x1C0058F62, then lock inc dword ptr [rbx+30h] at 0x1C0058F6E. Patched (@ 0x1C0059A48): EvaluateCurrentState(g_Feature_2476938553_60405306) at 0x1C0059A9B; when true, lock inc [rbx+30h] at 0x1C0059AA9 precedes the release at 0x1C0059AAD; when false, the release at 0x1C0059ABB precedes lock inc [rbx+30h] at 0x1C0059AC7. The increment is atomic in every path.
5. Trigger Conditions
To exercise the Finding 1 out-of-bounds write in AfdRestartSuperAcceptGetAddress on the unpatched build:
1. Open a listening socket and issue an AcceptEx / AFD_SUPER_ACCEPT operation on \Device\Afd, establishing the SuperAccept completion path.
2. Complete a connection so the transport returns address data; the completion routine runs with IoStatus.Information == 0x1a (checked at cmp dword ptr [rdx+38h], 1Ah) and the endpoint's address flag at [rdi+0xac] set.
3. The address buffer's length field at [mapped + offset + 8] must be large enough that, after the +0xa destination shift, the clamp to MdlByteCount - offset still allows the copy to reach past the buffer end (overshoot up to 10 bytes).
4. EvaluateCurrentState(g_Feature_298020153_59190810_FeatureDescriptorDetails) must return false, routing execution to the weak clamp at 0x1C006D937.
6. Exploit Primitive & Development Notes
- Provided Primitive: A bounded out-of-bounds write of up to 10 bytes of transport-supplied address data immediately past the end of the mapped transport-address buffer in kernel pool. There is no out-of-bounds read: the
memmovesource range stays within the buffer. - Constraints: The overshoot is capped at 10 bytes; the written bytes are the shifted tail of the address structure rather than fully arbitrary content; and the path is only reachable when the feature flag is off. Turning a 10-byte adjacent-pool overwrite into a broader primitive would depend on pool layout and the contents of the neighboring allocation, none of which is established here.
7. Debugger PoC Playbook
Attach a local kernel debugger to the unpatched target.
Breakpoints
bp afd_unpatched!AfdRestartSuperAcceptGetAddress
bp afd_unpatched+0x6d937 ".printf \"[!] weak clamp path\\n\"; r di"
bp afd_unpatched+0x6d960 ".printf \"[!] memmove size=%x src=%p dst=%p\\n\", @r8d, @rcx, @rdx"
(Offsets are relative to the image base; 0x1C006D937 and 0x1C006D960 are the file addresses.)
What to Inspect
- At
0x1C006D912(call EvaluateCurrentState): the return value ineax.eax == 0selects the weak clamp path. - At
0x1C006D937:diholds the transport-supplied length + 2 before the weak clamp. - At
0x1C006D960(memmove):r8dis the copy size,rcxthe source (ptr),rdxthe destination (ptr + 0xa). Ifrdx + r8dexceedsMappedSystemVa + MdlByteCount, the write is out of bounds.
Expected Observation
When the weak clamp is taken with a large length, rdx + r8d extends up to 10 bytes past MappedSystemVa + MdlByteCount. With special pool enabled on the affected allocation this surfaces as a pool-corruption or page-fault bugcheck at or after the memmove call.
8. Changed Functions — Full Triage
The security-relevant behavioral changes are documented above. The remaining changed functions are structural reorganization and offset shifts:
AfdSetInformation(unpatchedsub_1C005B800, 0.603 similarity): Operation-code dispatch restructured from cascaded compares into a bounded jump-table (cmp eax,0Eh/ja default) — a refactor with no unbounded index, so no out-of-bounds write. Also carries the feature-gated+0xf8counter (Finding 2).AfdCreateConnection(unpatchedsub_1C0055BF0, 0.9425 similarity): Removed the feature-flag gate that could skip device-stack validation (Finding 3).AfdCreateConnection_Old(sub_1C0007DE4, 0.9637 similarity): Same gate removal asAfdCreateConnection(Finding 3).AfdAddConnectedReference(unpatchedsub_1C0058F28, 0.468 similarity): Feature-gated reference-count increment ordering (Finding 4).AfdQueryCompartmentId(unpatchedsub_1C0036D94, 0.912 similarity): Feature-gated+0xf8counter around the TDI query callback (Finding 2). Thememsetzero-fill size is a fixed0x50in both builds; there is no buffer-size change.AfdTLPauseUnPause(unpatchedsub_1C0061A70, 0.8586 similarity): Feature-gated+0xf8counter around the TDI callback (Finding 2).AfdConnect(unpatchedsub_1C005A4F0, 0.9643 similarity): Register reallocation from recompilation; no confirmed security-relevant change.- Cosmetic & Restructuring Changes:
AfdCleanupCore(unpatchedsub_1C00571F0): Adjusted struct offsets (0x158 -> 0x160) reflecting the 8-byte endpoint growth, and re-addressed callees.AfdDoDatagramConnect(unpatchedsub_1C0061F80): Adjusted struct offsets, re-addressed callees, added anEvaluateCurrentStateconditional.AfdGetInformation(unpatchedsub_1C0038900-> patchedsub_1C00389B0) andAfdGetAddress(sub_1C0036320, in place): IOCTL info/address query handler reorganization; also carry the feature-gated+0xf8counter (Finding 2).AfdSetEventHandler(unpatchedsub_1C00410A0-> patchedsub_1C0041120) andAfdCloseRouteChangeNotifyHandles(unpatchedsub_1C004194C-> patchedsub_1C00419CC): shifted in memory layout.AfdGetAddressFileReference(unpatchedsub_1C006DDF4-> patchedsub_1C006DCF4) andAfdGetDeliveryStatus(unpatchedsub_1C0072140-> patchedsub_1C0072040): reordered in binary layout.AfdTLConnectEventHandler(sub_1C00549B0): Register and trace-data reference adjustments.
9. Unmatched Functions
No functions were exclusively added or removed; all changes were in-place modifications affecting the 18 matched-but-changed functions.
10. Confidence & Caveats
- Confidence Level: High for the direction and mechanism of all four findings, which are read directly from both disassemblies.
- Reachability of Finding 1: Whether unpatched systems are actually exposed depends on the runtime default of
g_Feature_298020153_59190810. The correct clamp is present in the unpatched build but gated; the patched build removes the gate and hardwires it. The value written is transport-supplied address data, and the overshoot is bounded to 10 bytes. - Findings 2 and 4 are feature-staged: the new behavior is gated behind a WIL feature flag with the original behavior retained in the else-branch, and no use-after-free or double-free is demonstrable in the unpatched build. They are reported as Low pending evidence of a concrete race.
- Manual Verification: Confirming Finding 1 in practice requires attaching a kernel debugger and identifying the AcceptEx/SuperAccept sequence that drives the completion routine with an address-length field large enough to overshoot after the
+0xashift.