Reversing CVE-2026-21241 - Use After Free in AFD.sys
UPDATE 2/20/2026: Souhail has posted his writeup going through the vulnerability research process - here. His post also includes a more efficient proof of concept than the one I came up with - here.
This vulnerability was found and reported by Souhail Hammou (@Dark_Puzzle).
I have been trying to get into the habit of reversing windows patches to work on my reverse engineering and exploit development skills. I saw this month’s patch Tuesday included a fix for a Use After Free vulnerability in the Windows Ancillary Function Driver (Afd.sys). Because I have looked at this driver in the past, I figured this would be a great candidate to develop a proof of concept for.
The first thing I did was download the pre-patch and post-patch versions of the binary from winbindex. Winbindex is an amazing resource created by m417z and has saved me a lot of time when looking at Windows patches. With both versions of the binary downloaded, I imported them into binary ninja, exported them with the BinExport plugin, and diffed the BinExport files in Bindiff:

February patch Tuesday fixed several vulnerabilities in AFD.sys, so there were quite a few functions changed. I started by trying to find all of the changed functions that had to do with freeing memory to see if any checks had been added to them. I could see that code was added to AfdCloseCore, AfdNotifyDestroyContext, and AfdCleanupCore to mitigate the UAF. I started by looking at the code added to the patched AfdNotifyDestroyContext function:

The unpatched version contains a call to ObfDereferenceObject and a call to ExFreePoolWithTag:

In the patched version, the calls to ObfDereferenceObject remain, but ExFreePoolWithTag is now gated behind a feature flag. If the Feature_447951161 flag is set, the function returns early. This a clear indication of where the “free” in the Use After Free is occurring:

And there is a cross reference showing AfdNotifyDestroyContext is called by AfdNotifyPostEvents and AfdCleanupCore, two of the functions that have been patched:

Taking a look at the changes added to AfdNotifyPostEvents, we can see there was some code added:

From this bindiff disassembly alone it’s pretty tough to tell what’s going on, but taking a look in binary ninja clears things up. The unpatched function releases the spinlock and then calls AfdNotifyDestroyContext, which contains the free identified earlier:

The patched version also calls AfdNotifyDestroyContext, but moves the ExFreePoolWithTag call into AfdNotifyPostEvents if the Feature_447951161 flag is set:

With these patches in mind, we can begin looking closer at the unpatched versions of the functions to understand the flow of the vulnerability. AfdNotifyPostEvents takes a pointer to an “endpoint”, or kernel-side representation of a Winsock socket, as its first parameter. At offset 0x178 the endpoint stores a notification context struct. AfdNotifyPostEvents saves a pointer to the notification context struct into RBX and then proceeds to use that saved pointer across spinlock release/reacquire cycles:

The endpoint notification struct is entirely undocumented from what I can tell. It gets allocated in
AfdNotifyProcessRegistrationwith a size of 0x70 bytes. It gets allocated when a user registers a socket for notifications viaProcessSocketNotifications()and appears to store information the driver needs to deliver events for the socket, such as a pointer to I/O Completion Ports(IOCP). It lives atendpoint+0x178for the lifetime of the registration and is freed during the socket close.
Later on in the function, we can see a spinlock being released in order to call IoSetIoCompletionEx3, opening up a race window:

When the spinlock is released, a concurrent AfdNotifyDestroyContext call could free the context leaving AfdNotifyPostEvents with a stale pointer to freed memory. We observed a cross reference from AfdNotifyDestroyContext to AfdCleanupCore earlier, but here is what the actual call looks like in the unpatched code:

With an open race window, we can quickly call closesocket which will call AfdCleanupCore -> AfdNotifyDestroyContext and free the endpoint context. The complete UAF race looks like this:
- Create loopback socket pair
- Register notifications:
ProcessSocketNotifications()on the client socket with an IOCP - AFD callsAfdNotifyProcessRegistration, allocates a 0x70-byte context at endpoint+0x178 - Call
send()on the accepted socket to generate events -> TCP loopback delivers data to the client -> AFD callsAfdNotifyPostEventson the client endpoint AfdNotifyPostEventscaches the context pointerAfdNotifyPostEventsreleases the endpoint spinlock to callIoSetIoCompletionEx3- Close the client socket:
closesocket()->AfdCleanupCoreacquires spinlock AfdNotifyDestroyContextcallsExFreePoolWithTag- the context is freedAfdNotifyPostEventsreacquires spinlock, loops back, reads from stale pointer and uses freed allocation
Writing and testing the proof of concept against the unpatched driver results in a BSOD, verifying our observations. The kernel throws a REFERENCE_BY_POINTER error because AfdNotifyPostEvents still holds a cached pointer to the notification context after it has been freed. The memory at that address now contains garbage, so when AfdNotifyPostEvents reads it and passes it to ObfDereferenceObject, the kernel detects an invalid reference count:

Proof of concept code - https://github.com/Bad-Jubies/Exploits/blob/main/CVE-2026-21241.c
References
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-21241
https://recon.cx/2015/slides/recon2015-20-steven-vittitoe-Reverse-Engineering-Windows-AFD-sys.pdf
https://web.archive.org/web/20230317073226/https://www.x86matthew.com/view_post?id=ntsockets
https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports
https://learn.microsoft.com/en-us/windows/win32/api/winsock2/ns-winsock2-sock_notify_registration
https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-processsocketnotifications