afd.sys — Feature-gated state guard blocks queuing accept/receive IRPs onto a cancelled AFD SAN endpoint (CWE-416) fixed
KB5078885
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | afd_unpatched.sys |
| Patched binary | afd_patched.sys |
| Overall similarity | 0.9806 |
| Matched functions | 1228 |
| Changed functions | 16 |
| Identical functions | 1212 |
| Unmatched (unpatched) | 0 |
| Unmatched (patched) | 0 |
Verdict: The patch adds a new, feature-staged endpoint state byte at offset +0x91 in the AFD SAN endpoint structure. When an endpoint's accept has been cancelled/drained (AfdSanCancelAccept), the byte is set, and the accept/receive redirect handlers then complete an incoming request IRP with STATUS_CANCELLED instead of linking it onto the endpoint's pending-IRP list at +0x70. This closes a window where a request IRP could be (re)queued onto an endpoint whose accept processing has already been torn down — a dangling pending-IRP / use-after-free class condition. The change is entirely gated behind the WIL feature Feature_623580472__private_IsEnabledDeviceUsage, which exists only in the patched binary, so this is a staged rollout with the pre-patch behavior retained in the feature-off branch. A concrete, reachable memory-corruption primitive is not demonstrable from the two binaries; severity is therefore Low. A secondary, also feature-gated hardening tightens a TDI device-name blocklist from exact-match to substring-match.
2. Vulnerability Summary
Finding #1 — Low — Missing post-cancel state check in the AFD accept-redirect IRP handler
- Function (unpatched):
AfdSanRedirectRequest@0x1C0080198 - Function (patched):
AfdSanRedirectRequest@0x1C0082338 - Class: Missing state validation guarding a dangling pending-IRP / use-after-free condition (CWE-416, as the defended class)
- Entry point:
NtDeviceIoControlFile(\Device\Afd, …)accept-side request handled by the AFD SAN dispatch
What actually changed. In both builds the handler runs under the endpoint spinlock (KeAcquireInStackQueuedSpinLock on [ep+0x30]), verifies the endpoint signature 0x1AFD ([ep+0x00]), state 4 ([ep+0x02]), and status 0x103/0xC0000016 ([ep+0x8c]), sets the IRP Information ([irp+0x78]) and CancelRoutine ([irp+0x68] = AfdSanCancelRequest), then checks IRP->Cancel ([irp+0x44]). If the IRP is not cancelled, it links the IRP into the endpoint's pending list at [ep+0x70] and returns STATUS_PENDING.
The patched build inserts one additional gate after the IRP->Cancel test: it calls Feature_623580472__private_IsEnabledDeviceUsage(); if the feature is off it queues exactly as before, and if the feature is on it also reads the new byte [ep+0x91]. If that byte is non-zero, the handler skips queuing and instead completes the IRP with STATUS_CANCELLED (0xC0000120).
What +0x91 means. The byte is new to the patched build (it is referenced nowhere in the unpatched binary). AfdSanInitEndpoint zero-initializes it (only when the feature is on), and AfdSanCancelAccept sets it to 1 when it cancels an endpoint's accept and drains the pending list. So [ep+0x91] != 0 marks "this endpoint's accept has been cancelled/drained; do not queue new request IRPs onto it." The guard prevents a request IRP from being linked onto the pending list of an endpoint whose accept teardown has already run — the condition that could otherwise leave an IRP referencing an endpoint that is being torn down.
Direction / staging. The change makes the patched build strictly more conservative (adds a guard). It is entirely feature-gated; with the feature off, the patched code path is identical to the unpatched one, and the pre-patch queuing behavior is retained in that branch.
Finding #2 — Low — Same guard on the AFD fast-complete (receive/accept) IRP handler
- Function (unpatched):
AfdSanFastCompleteRequest@0x1C007EF30 - Function (patched):
AfdSanFastCompleteRequest@0x1C0081030 - Class: Same as Finding #1
- Entry point:
NtDeviceIoControlFile(\Device\Afd, …)receive/accept-side request handled by the AFD SAN dispatch
The same guard is added here. Under the endpoint spinlock, after setting the cancel routine and checking IRP->Cancel ([irp+0x44]), the patched build calls Feature_623580472__private_IsEnabledDeviceUsage() and, when enabled, checks [ep+0x91] (cmp byte [r15+0x91], 0 at 0x1C0081445) before linking the IRP into [ep+0x70]. If the byte is set, it releases the lock and takes the cancel path instead of queuing. The unpatched handler has neither the feature call nor the +0x91 check.
The same +0x91 read guard is also present in a third consumer, AfdSanAcquireContext (patched 0x1C007E830, at 0x1C007EE79 and 0x1C007EEB4), which the changed-function triage originally mislabeled (see §8).
Finding #3 — Low — TDI device-name blocklist tightened from exact-match to substring-match
- Function (unpatched / patched):
AfdIsValidTdiDeviceName@0x1C000896C(same address in both builds) - Class: Incomplete list of disallowed inputs (CWE-184)
- Entry point: socket/transport creation path that validates a caller-supplied TDI device name against
AfdTdiInvalidDeviceNames
The unpatched function compares the caller-supplied name against the one-entry blocklist AfdTdiInvalidDeviceNames using exact RtlEqualUnicodeString (case-insensitive). The patched function adds a Feature_2480880955__private_IsEnabledDeviceUsage() gate: when the feature is off it keeps the exact RtlEqualUnicodeString comparison; when on it uses RtlFindUnicodeSubstring(callerName, blockedName) instead, so a caller-supplied name that merely contains the blocked name is now rejected. This closes a bypass where the blocked device name is embedded inside a longer string. Impact is limited to reaching a name that should have been rejected; no memory-safety impact. Also feature-gated.
3. Pseudocode Diff
Finding #1 — Accept-redirect handler
// ===== UNPATCHED AfdSanRedirectRequest @ 0x1C0080198 =====
// (under endpoint spinlock; signature/state/status already checked)
Irp->Information = 0; // [irp+0x78]
Irp->CancelRoutine = AfdSanCancelRequest; // [irp+0x68]
if (!Irp->Cancel) { // [irp+0x44] -- the ONLY gate
// link IRP into endpoint pending list at [ep+0x70]
return STATUS_PENDING; // 0x103
}
// else: pull cancel routine, complete with STATUS_CANCELLED
// ===== PATCHED AfdSanRedirectRequest @ 0x1C0082338 =====
Irp->Information = 0;
Irp->CancelRoutine = AfdSanCancelRequest;
if (Irp->Cancel) goto cancel_path; // [irp+0x44]
if (Feature_623580472__private_IsEnabledDeviceUsage() // NEW feature gate
&& *(BYTE*)(ep + 0x91) != 0) // NEW post-cancel state check
goto cancel_path; // endpoint accept cancelled -> refuse
// else: link IRP into pending list at [ep+0x70], return STATUS_PENDING
cancel_path:
Irp->IoStatus.Status = STATUS_CANCELLED; // 0xC0000120
IofCompleteRequest(Irp, AfdPriorityBoost);
The new +0x91 byte — producer and initializer
// ===== NEW AfdSanInitEndpoint @ 0x1C0082020 =====
// zero-inits endpoint fields; the +0x91 write is feature-gated
*(ep + 0x68) = 0; *(DWORD*)(ep + 0x88) = 0; *(DWORD*)(ep + 0x8c) = 0;
*(ep + 0x70) = *(ep + 0x78) = &ep[0x70]; // self-linked list anchor
*(ep + 0x50) = arg1; *(ep + 0x58) = arg2; *(ep + 0x60) = arg3;
*(DWORD*)(ep + 0x84) = 1;
*(BYTE*)(ep + 0x90) = 0; // unconditional
if (Feature_623580472__private_IsEnabledDeviceUsage())
*(BYTE*)(ep + 0x91) = 0; // feature-gated
*(WORD*)(ep + 0x00) = 0x1AFD;
*(BYTE*)(ep + 0x48) = 1;
// ===== AfdSanCancelAccept sets the byte (patched @ 0x1C007F140) =====
// unpatched @ 0x1C007D130 has NO +0x91 write and does not drain [ep+0x70]
if (Feature_623580472__private_IsEnabledDeviceUsage()) {
*(BYTE*)(ep + 0x91) = 1; // mark accept cancelled
// then drain any IRPs already pending on [ep+0x70]
}
Finding #2 — Fast-complete handler
// ===== UNPATCHED AfdSanFastCompleteRequest @ 0x1C007EF30 =====
if (!Irp->Cancel) { /* link IRP into [ep+0x70] */ }
// ===== PATCHED AfdSanFastCompleteRequest @ 0x1C0081030 =====
if (Irp->Cancel) goto cancel_path; // [irp+0x44]
if (Feature_623580472__private_IsEnabledDeviceUsage()
&& *(BYTE*)(ep + 0x91) != 0) // NEW post-cancel state check
goto cancel_path;
/* link IRP into [ep+0x70] */
Finding #3 — TDI device-name validation
// ===== UNPATCHED AfdIsValidTdiDeviceName @ 0x1C000896C =====
RtlInitUnicodeString(&blocked, AfdTdiInvalidDeviceNames[0]);
if (RtlEqualUnicodeString(&blocked, callerName, /*CaseInsensitive*/1))
return FALSE; // blocked (exact match only)
// ===== PATCHED AfdIsValidTdiDeviceName @ 0x1C000896C =====
RtlInitUnicodeString(&blocked, AfdTdiInvalidDeviceNames[0]);
if (Feature_2480880955__private_IsEnabledDeviceUsage())
hit = RtlFindUnicodeSubstring(callerName, &blocked, 1) != NULL; // substring
else
hit = RtlEqualUnicodeString(&blocked, callerName, 1); // exact
if (hit) return FALSE; // blocked
4. Assembly Analysis
Finding #1 — AfdSanRedirectRequest (unpatched, 0x1C0080198)
The queuing path, as it exists in the unpatched binary:
0x1C00801D3 call KeAcquireInStackQueuedSpinLock ; lock [ep+0x30]
0x1C00801DF mov eax, 1AFDh
0x1C00801E4 cmp [rsi], ax ; ep signature == 0x1AFD?
0x1C00801E7 jnz loc_1C0080387
0x1C00801ED cmp byte ptr [rsi+2], 4 ; ep state == 4?
0x1C00801F1 jnz loc_1C0080387
0x1C00801F7 mov ebx, [rsi+8Ch] ; ep status
0x1C00801FD cmp ebx, 103h ; STATUS_PENDING?
0x1C0080203 jz short loc_1C0080263
0x1C0080205 cmp ebx, 0C0000016h ; STATUS_MORE_PROCESSING_REQUIRED?
0x1C008020B jz short loc_1C0080263
; ...
0x1C008026B mov [rdi+78h], rbx ; IRP->Information = 0
0x1C008026F lea rax, AfdSanCancelRequest
0x1C0080276 xchg rax, [rdi+68h] ; IRP->CancelRoutine = AfdSanCancelRequest
0x1C008027A cmp byte ptr [rdi+44h], 0 ; IRP->Cancel? (the ONLY gate)
0x1C008027E lea rcx, [rdi+0A8h]
0x1C0080285 jz short loc_1C00802D6 ; not cancelled -> queue
; ... cancel path completes with STATUS_CANCELLED ...
0x1C00802D6 mov rax, [rdi+0B8h]
0x1C00802DD or byte ptr [rax+3], 1
0x1C00802E1 lea rax, [rsi+70h] ; &ep pending list
0x1C00802E5 mov rdx, [rax+8]
0x1C00802E9 cmp [rdx], rax ; list integrity
0x1C00802EC jz short loc_1C00802F5
0x1C00802EE mov ecx, 3
0x1C00802F3 int 29h ; RtlFailFast if list corrupt
0x1C00802F5 mov [rcx], rax ; link IRP into pending list
0x1C00802F8 mov [rcx+8], rdx
0x1C00802FC mov [rdx], rcx
0x1C00802FF mov [rax+8], rcx
0x1C0080308 call KeReleaseInStackQueuedSpinLock
0x1C0080380 mov eax, 103h ; return STATUS_PENDING
There is no read of [rsi+0x91] anywhere in this build — the offset does not exist in the unpatched code.
Patched side (the added gate), AfdSanRedirectRequest @ 0x1C0082338
0x1C008241A cmp byte ptr [rbx+44h], 0 ; IRP->Cancel
0x1C008241E jnz loc_1C00824F5 ; cancelled -> cancel path
0x1C0082424 call Feature_623580472__private_IsEnabledDeviceUsage ; NEW feature gate
0x1C0082429 test eax, eax
0x1C008242B jz short loc_1C008243A ; feature off -> queue (legacy)
0x1C008242D cmp byte ptr [rsi+91h], 0 ; NEW post-cancel state check
0x1C0082434 jnz loc_1C00824F5 ; byte set -> cancel path
0x1C008243A ; ... link IRP into [rsi+0x70] pending list, return STATUS_PENDING ...
0x1C00824F5 ; cancel path: pull cancel routine, ...
0x1C008253E mov edi, 0C0000120h ; STATUS_CANCELLED
0x1C0082564 mov [rbx+30h], edi
0x1C0082567 call IofCompleteRequest
The producer — AfdSanCancelAccept @ 0x1C007F140 (patched)
0x1C007F1EE call Feature_623580472__private_IsEnabledDeviceUsage
0x1C007F1F3 test eax, eax
0x1C007F1F5 jz short loc_1C007F235
0x1C007F1F7 mov byte ptr [rdi+91h], 1 ; NEW: mark accept cancelled
0x1C007F1FE lea rcx, [rdi+70h] ; then drain the pending list
0x1C007F202 mov rax, [rcx]
; ... unlink loop over [ep+0x70] entries ...
The unpatched AfdSanCancelAccept @ 0x1C007D130 neither writes [ep+0x91] nor drains [ep+0x70]; it unlinks only the single cancelling IRP and completes it with STATUS_CANCELLED.
Finding #3 — AfdIsValidTdiDeviceName @ 0x1C000896C
; --- unpatched ---
0x1C00089AE call RtlEqualUnicodeString ; exact match only
; --- patched ---
0x1C00089A3 call Feature_2480880955__private_IsEnabledDeviceUsage
0x1C00089AD jz short loc_1C00089C8 ; feature off -> exact match
0x1C00089B7 call RtlFindUnicodeSubstring ; feature on -> substring match
; ...
0x1C00089D0 call RtlEqualUnicodeString ; (feature-off branch)
5. Trigger Conditions
The behavioral difference between the two builds is observable only when Feature_623580472__private_IsEnabledDeviceUsage() returns non-zero (the feature is absent from the unpatched binary entirely). Under that condition:
- Create an AFD SAN endpoint via the standard Winsock/
\Device\Afdpath (socket create, bind/listen). - Drive the endpoint's accept to be cancelled/torn down so that
AfdSanCancelAcceptruns. In the patched build this sets[ep+0x91] = 1and drains[ep+0x70]; in the unpatched build it does neither. - Cause a further accept/receive request IRP to reach
AfdSanRedirectRequest/AfdSanFastCompleteRequestfor the same endpoint while its signature is still0x1AFD.
In the patched build the incoming IRP is completed with STATUS_CANCELLED rather than linked onto [ep+0x70]. In the unpatched build the IRP is linked onto the pending list of an endpoint whose accept was already cancelled.
Note that the endpoint spinlock ([ep+0x30]) serializes the cancel-accept drain and the queuing paths in both builds, and pending IRPs always carry a cancel routine (AfdSanCancelRequest). Whether the unpatched ordering can be driven to an actual free-then-use of the endpoint is not demonstrable from these two binaries; see §6 and §10.
For Finding #3, supply a TDI device name that contains a blocklist entry as a substring but is not byte-equal to it; it is accepted by the unpatched build and (with the feature on) rejected by the patched build.
6. Exploit Primitive & Development Notes
No exploit primitive is demonstrable from the two binaries. The patched change is a state-validation guard added in the accept/receive redirect path; the pre-patch code lacks that guard but the material required to prove a reachable, controllable memory-corruption primitive (an actual free of the endpoint while a stranded IRP still references it, followed by an attacker-controlled reclaim and use) is not present in the disassembly or decompilation. The endpoint queuing and the cancel-accept drain both execute under the endpoint spinlock, and pending IRPs carry a cancel routine, so the ordering the guard defends against does not, on the evidence here, establish a controllable use-after-free. Any pool-grooming, information-leak, or token-manipulation follow-on would be speculation and is not asserted.
7. Debugger Notes
For a follow-on analyst with a kernel debugger attached, the verifiable observation points are:
Finding #1 / #2 — the added guard
bp afd_patched!AfdSanRedirectRequest+... ; at 0x1C0082424 (the Feature_623580472 call before queuing)
bp afd_patched!AfdSanFastCompleteRequest+... ; at 0x1C008143C (same call in the receive/accept handler)
bp afd_patched!AfdSanCancelAccept+... ; at 0x1C007F1F7 (the byte [ep+0x91] = 1 write)
- At the guard,
rsi/r15is the AFD endpoint (signature0x1AFDat[ep+0x00]); inspect[ep+0x91]. When the feature is on and the byte is non-zero, execution takes theSTATUS_CANCELLEDpath instead of linking the IRP into[ep+0x70]. - The unpatched binary has no
[ep+0x91]reference at all; confirm by disassemblingAfdSanRedirectRequest@0x1C0080198and observing that the only pre-queue gate iscmp byte [rdi+0x44], 0at0x1C008027A.
Finding #3 — name validation
bp afd_patched!AfdIsValidTdiDeviceName ; 0x1C000896C
- Observe the
Feature_2480880955__private_IsEnabledDeviceUsagecall at0x1C00089A3selectingRtlFindUnicodeSubstring(feature on) versusRtlEqualUnicodeString(feature off). The unpatched function calls onlyRtlEqualUnicodeString.
Endpoint offsets referenced
| Offset | Field |
|---|---|
+0x00 |
Signature (0x1AFD; 0xAFD0 after full teardown) |
+0x02 |
State (4 = accepting/listening) |
+0x30 |
Endpoint spinlock |
+0x50/+0x58/+0x60 |
Parent/context pointers (set by init) |
+0x70/+0x78 |
Pending-IRP list anchor |
+0x84 |
Sequence counter (init to 1) |
+0x8c |
Status (0x103, 0xC0000016) |
+0x90 |
New byte, unconditionally zero-inited (patched) |
+0x91 |
New state byte; zero-inited feature-gated, set to 1 by AfdSanCancelAccept (patched only) |
8. Changed Functions — Triage
Delivered state-guard change (feature-staged behind Feature_623580472__private_IsEnabledDeviceUsage):
AfdSanRedirectRequest(0x1C0080198→0x1C0082338) (sim 0.9456) — accept-redirect handler; adds the[ep+0x91]gate before queuing. Finding #1.AfdSanFastCompleteRequest(0x1C007EF30→0x1C0081030) (sim 0.9609) — receive/accept fast-complete handler; adds the same[ep+0x91]gate. Finding #2.AfdSanAcquireContext(unpatched0x1C007C860→ patched0x1C007E830) (listed in the diff assub_1C007C860, sim 0.3398) — a third consumer of the guard; reads[ep+0x91]at0x1C007EE79and0x1C007EEB4. This is a real security-relevant change, not a cosmetic register reshuffle.AfdSanCancelAccept(unpatched0x1C007D130→ patched0x1C007F140) (listed in the diff assub_1C007D130, sim 0.2588) — the producer: sets[ep+0x91] = 1and drains the[ep+0x70]pending list on accept cancel. The diff's matched patched counterpart (sub_1c0048800, described as a TDI transmit/receive handler) is a same-address mismatch; the correct counterpart isAfdSanCancelAccept@0x1C007F140.AfdSanAcceptCore(0x1C007C0CC→0x1C007E0CC) (sim 0.9912) — TDI accept path; inline endpoint init replaced by a call to the newAfdSanInitEndpoint, which also zero-inits+0x90/+0x91.AfdSanFastCementEndpoint(0x1C007E6B0→0x1C0080800) (sim 0.9898) — connect/listen path; same migration toAfdSanInitEndpoint.
Name-validation change (feature-staged behind Feature_2480880955__private_IsEnabledDeviceUsage):
AfdIsValidTdiDeviceName(0x1C000896C) (sim 0.7674) — exact-match blocklist gains a substring-match path. Finding #3.
Feature-staged connection-creation dispatch (secondary; not a titled finding):
sub_1C0059BA0(sim 0.0622) — the old connection-creation body at this address is, in the patched build, a small feature-check stub (data_1c002b9a8 & 0x10) that dispatches between the legacy path and a new path (sub_1c005ae64). The new path is reported to setOptionsincludingIO_FORCE_ACCESS_CHECKand a differentDesiredAccess. This is a feature-staged dispatch (old path retained), consistent with a defense-in-depth access-check tightening; no reachable memory-safety impact is asserted.sub_1C00403C4(sim 0.7562),sub_1C004F090(sim 0.9248),sub_1C00598CC(sim 0.9171) — add thesub_1c0009c20()feature-gate to select the same legacy-vs-new connection-creation path, plus SLIST/reference updates. Behavioral but feature-staged; no independent reachable impact identified.
Cosmetic / structural (no semantic security change identified):
sub_1C0047620(sim 0.3417),sub_1C0058BA0(sim 0.9226),sub_1C005B6B0(sim 0.9238),sub_1C0038DB0(sim 0.9478),sub_1C007D550(sim 0.9601) — reorganization, register/stack-slot renaming, and address updates. The low similarity onsub_1C0047620reflects structural reordering rather than logic change.
9. Unmatched Functions
The diff reports zero unmatched functions in either direction. The new initializer AfdSanInitEndpoint (0x1C0082020) is reached by call-rewriting inside AfdSanAcceptCore and AfdSanFastCementEndpoint and is not surfaced as a standalone added function in the diff output. The +0x91 state logic is therefore distributed across the initializer, the producer (AfdSanCancelAccept), and the three consumers (AfdSanRedirectRequest, AfdSanFastCompleteRequest, AfdSanAcquireContext), all present in the changed set once the two mismatched entries in §8 are corrected.
10. Confidence & Caveats
Confidence in the mechanism: high. Confidence in exploitable impact: low.
Verified directly against both binaries:
- Offset +0x91 is referenced nowhere in the unpatched build and is introduced only in the patched build.
- The full set of +0x91 sites in the patched build is: the feature-gated zero-init in AfdSanInitEndpoint (0x1C0082079), the set-to-1 in AfdSanCancelAccept (0x1C007F1F7), and the read guards in AfdSanRedirectRequest (0x1C008242D), AfdSanFastCompleteRequest (0x1C0081445), and AfdSanAcquireContext (0x1C007EE79, 0x1C007EEB4).
- Both the queuing paths and the cancel-accept drain run under the endpoint spinlock at [ep+0x30] in both builds.
- The whole change is gated behind Feature_623580472__private_IsEnabledDeviceUsage(), which has no counterpart in the unpatched binary — a staged rollout with the pre-patch behavior retained in the feature-off branch.
Why impact is rated Low and not High:
- There is no uninitialized-memory read in the unpatched build: +0x91 simply does not exist there. This is a new state flag, not an uninitialized field, so a "read of stale pool residue" characterization does not apply.
- The guard defends against a request IRP being queued onto an endpoint whose accept was cancelled (a dangling pending-IRP / use-after-free class window), but the two binaries do not establish that this ordering is reachable and drivable into a controlled free-then-use. The endpoint spinlock serialization and the always-present cancel routine argue against a straightforward controllable primitive.
- No information leak, pool-grooming, or privilege-escalation chain is demonstrable, and none is claimed.
Items a follow-on analyst would confirm on a live target: the runtime default of Feature_623580472__private_IsEnabledDeviceUsage(); whether the cancel-accept-then-requeue ordering is reachable from a stock Winsock sequence; and, if so, whether the endpoint can actually be freed while a stranded IRP is linked. Absent those, this is a Low-severity, feature-staged state-validation hardening in the AFD SAN accept/receive path, plus a Low-severity feature-staged blocklist tightening in TDI device-name validation.
DONE