
Proof-of-concept exploit for CVE-2023-21768, a Windows Ancillary Function Driver (AFD.sys) arbitrary kernel write vulnerability enabling local privilege escalation via I/O ring.
According to the detailed description of CVE-2023-21768 published by the Microsoft Security Response Center (MSRC), the vulnerability exists in the Ancillary Function Driver (AFD), whose system file is afd.sys. The AFD module is the kernel entry point for the WinSock API. In this analysis, I will use it to exploit privilege escalation on Windows 11.
Download two versions of afd.sys from Winbindex: one version just before the patch, and one version after the patch. Then use Bindiff to compare these two versions.

Comparing the overall two versions, we see that only one function differs: AfdNotifyRemoveIoCompletion. Look at the details of the differences in this function between the two versions.

There are not many differences between the two versions. In the post-patch version, additional assembly instructions have been added to set parameters and call the ProbeForWrite function. According to Microsoft documentation, this function checks whether an address truly belongs to user-mode, has write permission, and is correctly aligned. Let's analyze this code in more detail:
pre-patch afd.sys version 10.0.22621.608

post-patch afd.sys version 10.0.22621.1105

Both check the value of r15_1; if it is 0, write the value of var_304 to the pointer specified in a field of struct_1. If it is non-zero, ProbeForWrite is called to ensure the pointer points to a valid address. In the pre-patch version, the value at var_304 is then written to the pointer, but this check is missing. From this patch, we can guess that we can call this code with a controlled value of arg3_1->field_18. If we can set a kernel address at field_18, we can write var_304 to a kernel memory address.
=> bug type: arbitrary kernel Write-Where
Now we need to find a way to trigger the bug. The function AfdNotifyRemoveIoCompletion is called directly in the function AfdNotifySock.

Similarly, searching for cross references of AfdNotifySock shows that it is not called directly from any other function, but the function address is stored at an address in .rdata

This address is located just before AfdIrpCallDispatch.

To trigger the bug, I will call DeviceIoControl with IOCTL_AFD_NOTIFY_SOCK, and AfdNotifySock will be called.
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
);
For each driver, a DRIVER_OBJECT object is created in the kernel, defined as follows:
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
The last member MajorFunction is an array of the driver's dispatch functions to handle communication between kernel and usermode. The dispatch function corresponding to calling DeviceIoControl is stored at MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
From the DriverEntry function of afd.sys, we can see that the driver created the device object "\Device\Afd":

It sets MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl, so when calling DeviceIoControl to communicate with the kernel, this function is called.

In AFD, there are two dispatch tables: AfdIrpCallDispatch and AfdImmediateCallDispatch.

It is easy to see that AfdDispatchDeviceIoControl computes the subscript via the IoControlCode and retrieves the corresponding value from AfdIoctlTable to verify against the IoControlCode.

From the distance between the start address of AfdImmediateCallDispatch and the address storing AfdNotifySock, we calculate the index as 73, with control code 0x12127

int main() {
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));
char outBuf[100];
DWORD bytesRet;
DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
DeviceIoControl((HANDLE)s, 0x12127, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
return 0;
}
it works!

As mentioned earlier, the vulnerability occurs when we can pass an unvalidated pointer through a struct. This struct is passed directly from usermode via lpInBuffer of DeviceIoControl. It is then passed to AfdNotifySock as the 4th parameter and then to AfdNotifyRemoveIoCompletion as the 3rd parameter.

Since I don't know what the struct contains, I let IDA automatically create the struct. Now I need to find a way to pass data into this struct and bypass the necessary checks to reach the vulnerable code. Start from the AfdNotifySock function:

First, the size of the struct must be 0x30 bytes.

The following values must be non-zero:

Another thing: during debugging, I saw it jumps to fail at the UserBuffer check earlier, so when calling DeviceIoControl, set this value to NULL. After setting the above values, I passed check2.

Next check to bypass:

ObReferenceObjectByHandle must return STATUS_SUCCESS to pass this check. That means I need to pass a valid handle. I searched and found no documentation on how to create an IoCompletionObjectType. So I followed the analysis at https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. Use the NtCreateIoCompletion function to create an IoCompletionObjectType and pass its handle to ObReferenceObjectByHandle.
After bypassing this check, the program flow enters a loop. In this loop, there is no place leading to a fail path, so I simply set the value at dword20 to 0x1 to exit the loop.

After exiting the loop, the program calls AfdNotifyRemoveIoCompletion. Continue analyzing the AfdNotifyRemoveIoCompletion function:

First, it checks another field of the struct; it must be non-zero. Then it is multiplied by 0x20, and used as a parameter to call ProbeForWrite along with another field of the struct. Here, we just need to use a user-mode address with write permission and dwLen = 1. The final check before triggering the vulnerability is that the return value of calling IoRemoveCompletion must be STATUS_SUCCESS. After searching, I found that the NtRemoveIoCompletion function, when called, invokes IoRemoveCompletion. According to this document, the NtRemoveIoCompletion function acts as a "waiting call" and completes when at least one completion record exists in a specified Io Completion Object. A record is added when I/O completes.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
Additionally, there is an optional parameter Timeout; when the timeout value is reached, the function returns. However, just setting timeout = 0 is not enough for the function to return successfully; it returns a timeout error code. We can use the NtSetIoCompletion function to increment the count of pending IO operations in the IoCompletionObjectType by 1 and cause the NtRemoveIoCompletion to finish before timeout. After multiple attempts, I found that the value written is always 0x1.
With the ability to write the value 0x1 to a kernel-mode address, we can use this vulnerability to gain full arbitrary read/write ability by leveraging I/O ring (a new I/O mechanism introduced by Microsoft). Yarden Shafir wrote a very detailed analysis of this method; you can read it here. One of the operations an application can perform is to allocate all buffers for its future I/O operations, then register them with the I/O ring. Pre-registered buffers are referenced through an I/O object:
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;
ULONG InSubmit;
ULONG64 CompletionLock;
ULONG64 SubmitCount;
ULONG64 CompletionCount;
ULONG64 CompletionWaitUntil;
KEVENT CompletionEvent;
UCHAR SignalCompletionEvent;
PKEVENT CompletionUserEvent;
ULONG RegBuffersCount;
PVOID RegBuffers;
ULONG RegFilesCount;
PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;
If a security vulnerability, like the one discussed in this article, allows you to update/modify the RegBuffersCount and RegBuffers fields, then standard I/O ring API can be used to read and write kernel memory. However, using the NtQuerySystemInformation function requires Medium IL privilege. To LPE from Low IL, another method to leak a kernel address is needed.
After IoRing->RegBuffers points to a user-controlled fakeBuffer, we can use standard I/O ring operations to create read and write to any address we want by specifying an index into the fake buffer:
For a better understanding, you can read Yarden Shafir's analysis at the link above.
After trying to create an IO Ring object and write using the above POC code, Windows crashed after calling DeviceIOControl /_ . So I used a method that directly calls Nt functions (˘・_・˘)
ProbeForWrite