Friday, February 20, 2026

Use-After-Free in afd.sys (CVE-2026-21241)

This post is the first entry in a series where I discuss some of my recent findings while auditing Windows 11 for vulnerabilities.

The first finding I cover is a use-after-free affecting the Ancillary Function Driver for WinSock (afd.sys) that I reported to Microsoft and that was fixed as CVE-2026-21241 in the February 2026 Patch Tuesday cycle.

PoC || GTFO: Skip to the end for a link to the PoC.

To stay updated on the rest of this series, you can follow me on X: @Dark_Puzzle

Background


My story with this bug started while auditing Worker Factories, the kernel component behind user-mode thread pools. After spending considerable time without uncovering any vulnerabilities, I decided to move on. What I did take away from reversing the implementation however was something called an I/O mini-completion packet.

An I/O mini-completion packet is a kernel structure that represents a completion tied to an I/O completion port. It stores information such as the completion status, key and so on. When a thread posts a completion to an I/O port through the PostQueuedCompletionStatus user API for example, the eventual call in kernel-mode to IoSetIoCompletionEx enqueues the operation for completion via a mini-completion packet.

The snippet above shows that the implementation allows kernel-mode drivers to supply a completion packet when posting an I/O completion. Otherwise, the kernel allocates one for the operation.

If a driver supplies its own packet, it is responsible for allocating the structure from the kernel pool and initializing it via IoInitializeMiniCompletionPacket.

Drivers may choose to supply their own completion packets so they can provide a custom callback and an associated context. When a completion packet is eventually dequeued in the kernel by IoRemoveIoCompletion, the kernel checks whether a callback was defined and invokes it with whatever was provided in the Context field. In the driver's callback, it may synchronize subsequent I/O completion operations, update internal fields and so on. A dequeue can be initiated by a call to the user API GetQueuedCompletionStatus.

A mini-completion packet is typically valid for a single I/O completion and must be freed/discarded at the end. The catch is that the driver must be careful not to free the completion packet before it is dequeued by IoRemoveIoCompletion; otherwise, a use-after-free can occur at dequeue when IoRemoveIoCompletion accesses a freed packet.

To allow drivers to cancel I/O completion requests, the kernel exports IoCancelMiniCompletionPacket which dequeues the packet so it can be safely released.

After recognizing a potential for vulnerabilities in mini-completion packet implementations, I began examining other kernel components that relied on this mechanism, eventually landing on afd.sys.

Vulnerability


The Ancillary Function Driver for WinSock (afd.sys) is a Windows kernel-mode driver that implements core socket functionality for the Winsock API. The vulnerability occurs due to improper lifetime management of I/O completion packets in relation to the lifetime of sockets. Through a race, a state can be achieved where during socket cleanup, an I/O mini-completion packet is freed while still being enqueued to the I/O completion port.

The socket state notifications API is a feature supported starting with Windows 10 Build 20348 that allows user-mode programs to receive notifications about socket state changes. To receive notifications for one or multiple sockets, a user-mode program calls the ProcessSocketNotifications API with an existing I/O completion port handle, the socket(s) to be notified on, the desired event types and a timeout.

Invoking ProcessSocketNotifications translates into IOCTL 0x12127 in the afd.sys driver, implemented in the AfdNotifySock kernel function. The function registers the provided I/O completion port for notifications and may also automatically handle them by queuing/dequeuing I/O mini-completion packets to/from the completion port i.e. IoSetIoCompletionEx/IoRemoveIoCompletion. The documentation for ProcessSocketNotifications explicitly states this.

"To reduce system call overhead, you can register for notifications and retrieve them in a single call to ProcessSocketNotifications. Alternatively, you can retrieve them explicitly by calling the usual I/O completion port functions, such as GetQueuedCompletionStatus. Notifications retrieved using ProcessSocketNotifications are the same as those retrieved using GetQueuedCompletionStatusEx, which might include notification packets other than socket state changes."

In the AfdNotifySock call stack, the packet is queued in AfdNotifyPostEvents (snippet below). The mini-completion packet structure is extended with additional fields, most importantly a field I named NotifyStatus, which acts as an additional status code for the operation. If it is nonzero, the code attempts to cancel the packet; otherwise, it proceeds to queue it.

With the I/O completion packet enqueued, I looked for places where a race could be achieved to free it. One such place was during the dispatch of IRP_MJ_CLEANUP.

IRP_MJ_CLEANUP is sent to drivers when the last handle to a file object is closed; in our case, that file object represents the socket. Drivers must handle this IRP carefully since the file object may still be referenced by ongoing I/O. Only after all I/O requests have been completed or canceled does the driver receive IRP_MJ_CLOSE.

During IRP_MJ_CLEANUP (easily triggered by closing all user-mode handles to a socket), the afd.sys driver may invoke the AfdNotifyDestroyContext function, shown below.

This routine only cancels the I/O completion if the aforementioned NotifyStatus is non-zero, otherwise it proceeds to free the mini-completion packet. In AfdNotifyPostEvents however, it is possible for NotifyStatus to be zero in a queued packet due to IoStatusInformation==0, causing it to be freed by AfdNotifyDestroyContext while still enqueued to the I/O completion port.

With the right event types supplied to ProcessSocketNotifications, a user-mode program can get the driver to enqueue notifications to a fresh, non-bound, non-connected socket. AfdNotifySock would try to dequeue notifications via AfdNotifyRemoveIoCompletion before returning to user-mode. That allows for the following race to trigger the use-after-free:
  • Thread #1: Create a socket and an I/O completion port.
  • Thread #1: Call ProcessSocketNotifications, leading to AfdNotifySock in kernel-mode.
  • Thread #1: AfdNotifyPostEvents queues I/O completion packet to completion port with NotifyStatus == 0.
  • Thread #2: Close the socket, triggering IRP_MJ_CLEANUP while Thread #1 is still processing the IOCTL request in the AfdNotifySock call stack.
  • Thread #1: AfdNotifySock dequeues freed I/O packet in AfdNotifyRemoveIoCompletion.
Although the above represents the minimal race, reliability can be improved by adding a third thread that continuously issues GetQueuedCompletionStatus calls to attempt dequeuing a freed packet from the I/O completion port.

Below is an example crash triggered by the PoC. Different bugcheck codes may be observed since the kernel would operate on a freed or re-purposed pool allocation.


The PoC is available on GitHub: Here

Timeline

  • Oct 17, 2025 - Report submitted and acknowledged by MSRC.
  • Oct 24, 2025 - Vulnerability confirmed by Microsoft.
  • Feb 10, 2026 - Fix released as CVE-2026-21241.

No comments:

Post a Comment