This blogpost embarks on the initial stages of kernel exploitation. The content serves as an introduction, leading to an imminent and comprehensive whitepaper centered around this subject matter. Through this, a foundation is laid for understanding how kernel drivers are developed, as well as basic understanding around key concepts that will be instrumental to comprehending the paper itself.
Relevance of the research
Over the last few years, both offense and defense have made some advancements in the infosec space. With EDRs placing hooks in usermode, offensive tradecraft evolved from utilizing naïve (not a typo) WIN32 API calls to dynamic invocation to avoid Import Address Table entries to finally utilizing syscalls and indirect syscalls.
Now that offensive tradecraft effectively caught up, more and more security products are stepping away from userland hooks and are focusing more on telemetry collected from ETW, such as the ETWti provider, which used to be only accessible by defender for endpoint (at least officially, for a while) and kernel callbacks.
This has forced offensive tradecraft to take a look at these technologies and how to neutralize them. This blogpost is by no means anything new; research in this field has been done years ago, with popular tools such as mimikatz’s driver, cheeky blinder, KDU, telemetry sourcerer, etc…
These tools have one thing in common though: they require a driver to be loaded, which can be problematic as loading unsigned drivers is no longer possible thanks to Microsoft’s driver signing enforcement policy which simply disallows unsigned drivers being loaded into the kernel space. Although some of the tools out there make use of signed drivers (like the mimikatz driver), there is a high chance that these known malicious drivers are signatured and will trigger alerts. This means another way to tamper with these protections or another way to load (unsigned) drivers is needed.
Relatively recently, there was a resurfacing of interesting research published which hooks specific functions in the signtool from Microsoft, allowing drivers getting signed by outdated certs to be recognized as legitimate. This avenue has been there for a while, but flew under the radar for a lot of people until it got abused by threat actors; however, this method lacks some finesse, as it still involves dropping an unknown (self-created) driver to disk, which could still get blocked because there is no reputation attached to it.
Another approach is roaming publicly available cloud resources and/or git repositories in order to hunt for code signing certificates being leaked (typically in pfx format). Without much effort, it’s possible to find a variety of these pfx files, which can be brute-forced due to having a very weak password. This leads to an interesting Twitter debate considering the ethics around this approach.
The final approach, which will be the meat of the research paper and blogpost, is utilizing vulnerable drivers to do your bidding. Depending on the vulnerabilities found in the driver(s) on a system, there are a variety of things you can do:
- Load unsigned drivers by effectively removing the driver signing enforcement
- Remove or add PPL protections to processes
- Remove kernel callback signals
- Patch kernel functions such as NtTraceEvent
- Neuter ETWti
- Load rootkits
- Etc…
Drivers are still programs, developed by developers, who may or may not always be aware of secure coding principals. On top of that, kernel development is not a very straightforward feat.
This leads to vulnerabilities in various kernel drivers signed by reputable vendors. For a practical use case, I recommend checking out the KDU project on github; for a vulnerable driver list, I recommend the loldriver project.
User mode vs kernel mode in Windows
Without going to much off topic, it is worth understanding the difference between what is referred to as “user mode” vs what is referred to as “kernel mode”.
When you use your Windows computer, you are operating in “usermode”; this is true from both normal privileges as well as elevated (administrator) privileges. This mode is where regular programs — like games, web browsers, and music players — run. In this mode, programs have limited access to your computer’s resources and can’t do anything that might mess up the whole system. It’s like a (relatively) safe playground where you can have fun without worrying too much about causing serious damage. All processes running in usermode have their own virtual memory, which cannot be directly (= without a handle with appropriate permissions) accessed by other processes. To quote the official Microsoft Documentation:
· For a 32-bit process, the virtual address space is usually the 2-gigabyte range 0x00000000 through 0x7FFFFFFF.
· For a 64-bit process on 64-bit Windows, the virtual address space is the 128-terabyte range 0x000’00000000 through 0x7FFF’FFFFFFFF.
The virtual address space can be extended to 3-gigabyte with a switch, but the default and most commonly observed configuration is 2 gigs.
In Windows the high memory of every process (0x80000000 or 0xc0000000) all the way to 0xFFFFFFFF Is reserved for kernel code, user code cannot access these regions of memory, if it tries so an access violation exception will be thrown.
This hypothesis can be tested quite easily. For example, by leveraging the EnumDeviceDrivers Windows API call to retrieve an array of base addresses of all device drivers currently loaded in the system.
Another valuable alternative would be to check the loaded modules of the system process (ntoskrnl.exe) in Process Hacker2 or using a third-party program such as driverview. If the MSDN documententation is correct, all drivers should be loaded anywhere between 0x80000000 and 0xFFFFFFFF
Below is a small proof of concept that will attempt to read the magic header (MZ) value on these driver addresses, again, if MSDN documentation is correct this would result into access violations.
#include <iostream>
#include <Windows.h>
#include <psapi.h>
#define DRIVER_ARRAY_SIZE 1024
int main() {
DWORD cbNeeded = 0;
LPVOID drivers[DRIVER_ARRAY_SIZE] { nullptr };
DWORD numDrivers = 0;
// Call EnumDeviceDrivers to get the array of driver base addresses
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded)) {
// Calculate the number of drivers in the array
numDrivers = cbNeeded / sizeof(LPVOID);
// Iterate through the driver base addresses and print them
for (DWORD i = 0; i < numDrivers; i++) {
TCHAR driverName[MAX_PATH] = TEXT("");
if (GetDeviceDriverBaseName(drivers[i], driverName, MAX_PATH)) {
std::wcout << "Driver #" << i + 1 << " Base Address: " << drivers[i] << std::endl;
std::wcout << "Driver Name: " << driverName << std::endl;
std::wcout << "Attempting to Read the MZ bytes..." << std::endl;
char readBuffer[2] = {0};
size_t bytesRead = 0 ;
if (ReadProcessMemory((HANDLE)-1, drivers[i], &readBuffer, 2, &bytesRead))
{
std::wcout << "It worked! here they are: " << readBuffer << std::endl;
}
else
{
std::cerr << "Could not read the MZ values: Error code: " << GetLastError() << std::endl;
}
//comment the next 3 lines of code out if you want the program to continue, just another way of reading without the RPM API call
//USHORT mz;
//mz = *(USHORT*)drivers[i];
//std::wcout << mz << std::endl;
}
}
}
else {
// EnumDeviceDrivers failed, print error message
std::cerr << "EnumDeviceDrivers failed with error code: " << GetLastError() << std::endl;
}
return 0;
}
When this code runs, the executor should be greeted with an access violation. Let’s see if this is the case:
Upon execution, an unhandled access violation exception is thrown when the code tries to dereference the pointer to the memory location of the first driver base address (this is expected, and inline with what MSDN documentation claims).
If instead, the ReadProcessMemory API is used, error code 998 is thrown which according to the MSDN documentation means “Invalid Access to Memory Location”.
Let’s try the same exercise from kernel mode now.
Drivers 101: Basics of kernel development
If you want an excellent resource on how to start developing drivers, a reputable resource would be Pavel’s Windows Kernel Programming book. This book will obviously go a lot further than what this paper is going to touch upon, including setting up the actual environment to start developing kernel drivers in the first place, and how to debug them.
The first thing you need to know is that, much like usermode applications, drivers need an entrypoint to start the initial code flow. In usermode, this is typically referred to as the main method of an application (or dllmain in case of a dll). In the kernel DriverEntry is going to be the entrypoint of a driver.
A driver, in its bare minimum form, needs to have this function present, as well as an unload routine that would reverse all the effects the driver had on the system. Failure to have an unload routine can/will cause a leak, which is not handled until the next reboot. This can lead to some nasty consequences.
Here is code for a bare minimum driver, keeping in mind that the kernel does not have the C++ runtime. If you want to learn more about this, again, Pavel’s book is a goldmine.
void Unload(_In_ PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
}
extern "C"
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = Unload;
return STATUS_SUCCESS;
}
Now obviously, this driver is not really very exciting, but a closer look should be taken at the parameters required by the DriverEntry function: specifically, the DriverObject.
The DriverObject is primarily responsible for calling the appropriate functions within the driver itself based on what the driver was in fact designed to do. In driver development, functionality is passed through “major functions” as long as none of these major functions are implemented, there is no real way to communicate with the driver from usermode. There are several major functions available to driver developers, you can find them in the MSDN documentation if you are interested.
The most noteworthy major functions are summarized in Pavel’s book:
In order to receive instructions from usermode, a driver needs to create a device object and create a symbolic link to the object so that we can obtain a handle to it from usermode. This is typically done using the IoCreateDevice and IoCreateSymbolicLink API calls. Which instructions usermode can send to the kernel will depend on which major functions are implemented on the kernel side.
It is best practice to use the major read (IRP_MJ_READ) and write (IRP_MJ_WRITE) functions for “pure” read and write functionality. When “more” has to be done, the best practice dictate to use the device control major function (IRP_MJ_DEVICE_CONTROL).
Let’s take it step by step and start from just using the IRP_MJ_READ function for now.
In this minimalistic proof of concept, the bare minimum driver and usermode implementation to get back the base address and DOS header of our own kernel driver is showcased. As a reminder, kernel drivers do not have access to usermode API calls (and therefore finding the base address of a kernel driver is not as straightforward as one might think, but that’s not really the topic of this blog).
Driver code:
#include <ntddk.h>
//if you wanna learn more about driver IO modes, buffered,direct,neither, I recommend https://www.codeproject.com/Articles/9575/Driver-Development-Part-2-Introduction-to-Implemen
//another good resource for kernel base address finding: https://m0uk4.gitbook.io/notebooks/mouka/windowsinternal/find-kernel-module-address-todo#method-3-through-driver-name
typedef unsigned short WORD;
typedef struct _IMAGE_DOS_HEADER
{
WORD e_magic;
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew;
} IMAGE_DOS_HEADER, * PIMAGE_DOS_HEADER;
typedef struct _DRIVER_INFO {
IMAGE_DOS_HEADER DosHeader;
PVOID EntryPointAddress;
} DRIVER_INFO, * PDRIVER_INFO;
typedef enum _SYSTEM_INFORMATION_CLASS {
SystemBasicInformation,
SystemProcessorInformation,
SystemPerformanceInformation,
SystemTimeOfDayInformation,
SystemPathInformation,
SystemProcessInformation,
SystemCallCountInformation,
SystemDeviceInformation,
SystemProcessorPerformanceInformation,
SystemFlagsInformation,
SystemCallTimeInformation,
SystemModuleInformation,
SystemLocksInformation,
SystemStackTraceInformation,
SystemPagedPoolInformation,
SystemNonPagedPoolInformation,
SystemHandleInformation,
SystemObjectInformation,
SystemPageFileInformation,
SystemVdmInstemulInformation,
SystemVdmBopInformation,
SystemFileCacheInformation,
SystemPoolTagInformation,
SystemInterruptInformation,
SystemDpcBehaviorInformation,
SystemFullMemoryInformation,
SystemLoadGdiDriverInformation,
SystemUnloadGdiDriverInformation,
SystemTimeAdjustmentInformation,
SystemSummaryMemoryInformation,
SystemNextEventIdInformation,
SystemEventIdsInformation,
SystemCrashDumpInformation,
SystemExceptionInformation,
SystemCrashDumpStateInformation,
SystemKernelDebuggerInformation,
SystemContextSwitchInformation,
SystemRegistryQuotaInformation,
SystemExtendServiceTableInformation,
SystemPrioritySeperation,
SystemPlugPlayBusInformation,
SystemDockInformation,
SystemProcessorSpeedInformation,
SystemCurrentTimeZoneInformation,
SystemLookasideInformation
} SYSTEM_INFORMATION_CLASS, * PSYSTEM_INFORMATION_CLASS;
typedef struct _SYSTEM_MODULE_ENTRY
{
HANDLE Section;
PVOID MappedBase;
PVOID ImageBase;
ULONG ImageSize;
ULONG Flags;
USHORT LoadOrderIndex;
USHORT InitOrderIndex;
USHORT LoadCount;
USHORT OffsetToFileName;
UCHAR FullPathName[256];
} SYSTEM_MODULE_ENTRY, * PSYSTEM_MODULE_ENTRY;
typedef struct _SYSTEM_MODULE_INFORMATION
{
ULONG Count;
SYSTEM_MODULE_ENTRY Module[1];
} SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;
void Unload(_In_ PDRIVER_OBJECT DriverObject);
NTSTATUS CreateClose(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp);
NTSTATUS CompleteIrp(_Inout_ PIRP Irp, _Inout_ NTSTATUS status, _In_ ULONG_PTR info);
NTSTATUS ReadBuffer(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp);
extern "C"
{
extern POBJECT_TYPE* IoDriverObjectType;
NTSTATUS NTSYSAPI NTAPI ObReferenceObjectByName(_In_ PUNICODE_STRING ObjectPath,_In_ ULONG Attributes,_In_ PACCESS_STATE PassedAccessState,_In_ ACCESS_MASK DesiredAccess,_In_ POBJECT_TYPE ObjectType,_In_ KPROCESSOR_MODE AccessMode,_Inout_ PVOID ParseContext,_Out_ PVOID* ObjectPtr);
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
//create the device object
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\ReadMeDriver");
PDEVICE_OBJECT DeviceObject = NULL;
NTSTATUS status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
if (!NT_SUCCESS(status)) {
KdPrint(("Failed to create device object (0x%08X)\n", status));
return status;
}
//make it available in usermode
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\ReadDriver");
status = IoCreateSymbolicLink(&symLink, &devName);
if (!NT_SUCCESS(status)) {
KdPrint(("Failed to create symbolic link (0x%08X)\n", status));
IoDeleteDevice(DeviceObject);
return status;
}
//Buffered IO is simplest here, as Usermode and kernelmode are using same buffer
DeviceObject->Flags |= DO_BUFFERED_IO;
//major functions
DriverObject->DriverUnload = Unload;
DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_READ] = ReadBuffer;
return STATUS_SUCCESS;
}
}
_Use_decl_annotations_
VOID Unload(_In_ PDRIVER_OBJECT DriverObject) {
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\ReadDriver");
// delete symbolic link
IoDeleteSymbolicLink(&symLink);
// delete device object
IoDeleteDevice(DriverObject->DeviceObject);
}
_Use_decl_annotations_
NTSTATUS CompleteIrp(PIRP Irp, NTSTATUS status = STATUS_SUCCESS, ULONG_PTR info = 0) {
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = info;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
_Use_decl_annotations_
NTSTATUS CreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
return CompleteIrp(Irp);
}
PVOID GetDriverBaseAddress()
{
UNICODE_STRING targetDeviceName;
NTSTATUS status = STATUS_UNSUCCESSFUL;
PDRIVER_OBJECT pKbdDriverObject = NULL;
RtlInitUnicodeString(&targetDeviceName, L"\\Driver\\ReadMeDriver");
// Try to get the target device object
status = ObReferenceObjectByName(&targetDeviceName, OBJ_CASE_INSENSITIVE, NULL, 0, *IoDriverObjectType, KernelMode, NULL, (PVOID*)&pKbdDriverObject);
KdPrint(("READDRV: STATUS: 0x%08X\n", status));
if (!NT_SUCCESS(status))
{
return nullptr;
}
KdPrint(("READDRV: OBJ FOUND\n"));
//DbgPrint("DriverObject Attributes:\n");
//DbgPrint(" DriverStart: 0x%p\n", pKbdDriverObject->DriverStart);
//DbgPrint(" DriverSize: 0x%x\n", pKbdDriverObject->DriverSize);
//DbgPrint(" DriverSection: 0x%p\n", pKbdDriverObject->DriverSection);
PVOID baseAddress = pKbdDriverObject->DriverStart;
ObDereferenceObject(pKbdDriverObject);
return baseAddress;
}
_Use_decl_annotations_
NTSTATUS ReadBuffer(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
auto status = STATUS_SUCCESS;
auto stack = IoGetCurrentIrpStackLocation(Irp);
auto len = stack->Parameters.Read.Length;
auto buffer = Irp->AssociatedIrp.SystemBuffer;
if (len < sizeof(DRIVER_INFO))
return CompleteIrp(Irp, STATUS_INVALID_BUFFER_SIZE);
if (!buffer)
return CompleteIrp(Irp, STATUS_INSUFFICIENT_RESOURCES);
PDRIVER_INFO driverInfo = (PDRIVER_INFO)buffer;
PVOID baseAddress = GetDriverBaseAddress();
RtlCopyMemory(&(driverInfo->DosHeader), baseAddress, sizeof(IMAGE_DOS_HEADER));
driverInfo->EntryPointAddress = baseAddress;
DbgPrint(" READDRV: BaseAddr: 0x%p\n", baseAddress);
DbgPrint(" READDRV: DOS_MAGIC: 0x%X \n", driverInfo->DosHeader.e_magic);
return CompleteIrp(Irp, status, sizeof(DRIVER_INFO));
}
Corresponding usermode application:
#include <Windows.h>
#include <iostream>
typedef struct _DRIVER_INFO {
IMAGE_DOS_HEADER DosHeader;
PVOID EntryPointAddress;
} DRIVER_INFO, * PDRIVER_INFO;
int Error(const char* msg) {
printf("%s: error=%u\n", msg, ::GetLastError());
return 1;
}
int main()
{
HANDLE hDevice = CreateFile(L"\\\\.\\ReadDriver", GENERIC_READ,0, nullptr, OPEN_EXISTING, 0, nullptr);
if (hDevice == INVALID_HANDLE_VALUE) {
return Error("Failed to open device");
}
//BYTE buffer[sizeof DRIVER_INFO] = {0};
DRIVER_INFO driverInfo = {0};
DWORD bytes = 0 ;
BOOL ok = ReadFile(hDevice, &driverInfo, sizeof(DRIVER_INFO), &bytes, nullptr);
if (!ok)
return Error("failed to read");
if (bytes != sizeof(DRIVER_INFO))
printf("Wrong number of bytes\n");
printf("Driver Base Address 0x%p\n",driverInfo.EntryPointAddress);
printf("Magic MZ value in hex (expecting 0x5A4D (MZ)): 0x%X\n", driverInfo.DosHeader.e_magic);
return 0;
}
Unsurprisingly after creating a service for the kernel driver and starting it and running the usermode application to interface with the driver, we get the expected loaded address of the driver alongside with the expected magic byte value — indicating that, indeed, kernel drivers can of course access kernel space addresses.
Drivers 201: IOCTL codes
Now that there is a basic understanding of major functions, particularly the read function, it should become clear that the read function does just that: read. As a user, you do not get to give the kernel driver any other instructions other than “please perform whatever the developer has implemented as the major read function.”
In order for users to get to pass more data to the driver, and thus get more freedom (not just read, but also write or even combinations of the two), there is the concept of IO control functions.
This time the usermode application is not simply calling the ReadFile api call but instead is using an API call called DeviceIoControl. This API call sends a specific “control code” to the driver, which, in case the control code is implemented, is going to perform a specific operation.
In order to fully understand these functions, a macro needs to be discussed that is accessible for kernel developers. This macro is designed to make sense of control codes so there is somewhat of a logic to it. This is crucial to understand, as this will form the basis of IOCTL fuzzing which will be covered in the research paper.
#define CTL_CODE( DeviceType, Function, Method, Access )
(((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
Let’s attempt to break down the macro, as this is going to help understand IOCTL fuzzing later on in this paper.
The first parameter is device type, this can be one of the devices that have already been defined by Microsoft such as a keyboard or a mouse, but it could also be software based types such as a mailslot or a named pipe. According to msdn documentation values of less than 0x8000 are reserved by Microsoft.
The second parameter is the function, this is the identifier of the function that needs to be resolved by the kernel (this is going to be the bit that makes this IOCTL code unique, because there is a possibility all other bits might be the same). According to msdn documentation, Values of less than 0x800 are reserved by Microsoft.
The third parameter is the Transfertype, this is going to determine how data is passed to and from the driver. The options are:
- METHOD_BUFFERED
- METHOD_IN_DIRECT / METHOD_OUT_DIRECT
- METHOD_NEITHER
It’s recommended to read the msdn documentation and/or Pavel’s book for a detailed description of how this works.
It is important to note that according to the msdn documentation:
METHOD_BUFFERED is the primary transfer type method for intermediate drivers (which will typically be the drivers that are interesting for attacks).
From a high level point of view, buffered means that input and output buffers are the same, whereas DirectIO has two distinct buffers:
- the Irp->AssociatedIrp.SystemBuffer as input buffer
- the Irp->MdlAddress that can be used as additional input buffer or output buffer.
Lastly is the FileAccess parameter your options are:
- FILE_READ
- FILE_WRITE
- ANY
Most kernel drivers use the ANY file access to make sure that any usermode application would not break in case the kernel driver gets new functionalities that suddenly are going to be using Read Write primitives.
To the astute reader of this section, one can deduce that there is a high probability that custom IOCTL codes are going to be in the 0x8000 range for the device, 0x800 range for the function and then have 0 (METHOD_BUFFERED) and then have another 0 (FILE_ANY_ACCESS), if we take the bit shifting into account that would mean custom IOCTL codes are typically going to be found in the range of
0x80012004 (CTL_CODE(0x8001,0x801,0,0))
and
0x89992264(CTL_CODE(0x8999,0x899,0,0))
To further strengthen the concept of IOCTL codes, a PoC can be found bleow that implements an IOCTL code with METHOD_BUFFERED and FILE_ANY_ACCESS access which accepts a structure from usermode that was inspired by a popular vulnerability in the MSI RTCORE64.SYS driver.
Driver code:
#include <ntddk.h>
#define ReadDriverCode 0x8069
#define IOCTL_ReadDriverMemory CTL_CODE(ReadDriverCode,0x869,METHOD_BUFFERED,FILE_ANY_ACCESS)
typedef unsigned short WORD;
typedef unsigned char BYTE;
typedef ULONGLONG DWORD64;
typedef ULONG DWORD;
//inspired by MSI RTCORE64.sys vuln
typedef struct _MEMORY_READ {
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[8];
DWORD ReadSize;
DWORD Value;
BYTE Pad3[16];
}MEMORY_READ, * PMEMORY_READ;
void Unload(_In_ PDRIVER_OBJECT DriverObject);
NTSTATUS CreateClose(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp);
NTSTATUS CompleteIrp(_InOut_ PIRP Irp, _InOut_ NTSTATUS status, _In_ ULONG_PTR info);
NTSTATUS ReadNotimplemented(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp);
NTSTATUS ReadKernelMemory(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp);
extern "C"
{
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
//create the device object
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\ReadDriverIOCTL");
PDEVICE_OBJECT DeviceObject = NULL;
NTSTATUS status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
if (!NT_SUCCESS(status)) {
KdPrint(("Failed to create device object (0x%08X)\n", status));
return status;
}
//make it available in usermode
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\ReadDriverIOCTL");
status = IoCreateSymbolicLink(&symLink, &devName);
if (!NT_SUCCESS(status)) {
KdPrint(("Failed to create symbolic link (0x%08X)\n", status));
IoDeleteDevice(DeviceObject);
return status;
}
//Buffered IO is simplest here, as Usermode and kernelmode are using same buffer
DeviceObject->Flags |= DO_BUFFERED_IO;
//major functions
DriverObject->DriverUnload = Unload;
DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_READ] = ReadNotimplemented;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = ReadKernelMemory;
return STATUS_SUCCESS;
}
}
_Use_decl_annotations_
NTSTATUS CompleteIrp(PIRP Irp, NTSTATUS status = STATUS_SUCCESS, ULONG_PTR info = 0) {
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = info;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
_Use_decl_annotations_
NTSTATUS ReadNotimplemented(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
KdPrint(("IRP_MJ_READ is not implemented in this driver, use IOCTL instead\n"));
return CompleteIrp(Irp);
}
_Use_decl_annotations_
NTSTATUS ReadKernelMemory(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
auto stack = IoGetCurrentIrpStackLocation(Irp);
auto ctlcode = stack->Parameters.DeviceIoControl;
NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST;
ULONG_PTR totalWritten = 0;
switch (ctlcode.IoControlCode)
{
case IOCTL_ReadDriverMemory:
{
if (ctlcode.OutputBufferLength < sizeof(MEMORY_READ)) {
KdPrint(("IOCTLDRV: buffer does not seem to be correct length.\n"));
status = STATUS_BUFFER_TOO_SMALL;
break;
}
auto buffer = Irp->AssociatedIrp.SystemBuffer;
if (buffer == nullptr) {
KdPrint(("IOCTLDRV: buffer does not seem to point to anything.\n"));
status = STATUS_INVALID_PARAMETER;
break;
}
PMEMORY_READ memread = (PMEMORY_READ)buffer;
KdPrint(("IOCTLDRV: IOCTL ReadDriverMemory Called Successfully \nMemread Struct:\n"));
KdPrint(("IOCTLDRV: memread Address: 0x%X \n", memread->Address));
KdPrint(("IOCTLDRV: memread Size: %d \n", memread->ReadSize));
RtlCopyMemory(&(memread->Value), (void*)memread->Address, memread->ReadSize);
KdPrint(("IOCTLDRV: memread Value: 0x%X \n", memread->Value));
status = STATUS_SUCCESS;
totalWritten = sizeof(MEMORY_READ);
KdPrint(("IOCTLDRV: struct value to return: Value: %llu \n", sizeof(totalWritten)));
break;
}
}
return CompleteIrp(Irp, status,totalWritten);
}
_Use_decl_annotations_
void Unload(PDRIVER_OBJECT DriverObject) {
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\ReadDriverIOCTL");
// delete symbolic link
IoDeleteSymbolicLink(&symLink);
// delete device object
IoDeleteDevice(DriverObject->DeviceObject);
}
_Use_decl_annotations_
NTSTATUS CreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
return CompleteIrp(Irp);
}
Corresponding user application
#include <Windows.h>
#include <iostream>
#include <iomanip>
#include <string>
#define ReadDriverCode 0x8069
#define IOCTL_ReadDriverMemory CTL_CODE(ReadDriverCode,0x869,METHOD_BUFFERED,FILE_ANY_ACCESS)
//inspired by MSI RTCORE64.sys vuln
typedef struct _MEMORY_READ {
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[8];
DWORD ReadSize;
DWORD Value;
BYTE Pad3[16];
}MEMORY_READ, * PMEMORY_READ;
int Error(const char* msg) {
printf("%s: error=%u\n", msg, ::GetLastError());
return 1;
}
int main()
{
HANDLE hDevice = CreateFile(L"\\\\.\\ReadDriverIOCTL", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (hDevice == INVALID_HANDLE_VALUE) {
return Error("Failed to open device");
}
MEMORY_READ memread{ 0 };
while (true)
{
std::string addressStr;
std::cout << "Enter an address - you WILL bugcheck if its not a valid start of a kernel address! (or press Ctrl+C to exit): ";
std::getline(std::cin, addressStr);
LPVOID address = nullptr;
try
{
size_t startPos = 0;
if (addressStr.length() >= 2 && addressStr.substr(0, 2) == "0x")
{
startPos = 2; // Skip "0x" prefix
}
address = reinterpret_cast<LPVOID>(std::stoull(addressStr.substr(startPos), 0, 16));
}
catch (const std::invalid_argument& ex)
{ std::cout << "Invalid input: " << ex.what() << std::endl;
continue;
}
memread.Address = (DWORD64)address;
memread.ReadSize = 2;
DWORD bytes;
std::cout << std::endl;
if (!DeviceIoControl(hDevice, IOCTL_ReadDriverMemory, &memread,sizeof(memread), &memread,sizeof(memread), &bytes, nullptr))
return Error("failed in DeviceIoControl");
printf("First bytes at location 0x%p: 0x%X\n",(LPVOID)memread.Address, memread.Value);
}
return 0;
}
When executing the user application, users can now specify an arbitrary address in the kernel virtual address space and get back the bytes at that specific location. This could form the basis of an offensive driver that for example enumerates PPL protection.
Conclusion
This article provides a basic understanding of how userland programs interact with kernel. With this understanding comes comprehension of potential attack paths, development of offensive drivers, or even research into exploitation of kernel driver vulnerabilities.
What’s Next?
An in-depth whitepaper will be published soon, walking through finding and exploiting kernel driver vulnerabilities. Stay tuned.
Further reading
Some interesting references in case you can’t wait to start your kernel exploitation journey:
About the Author
Jean-Francois Maes is a Red Teamer and researcher currently working with Neuvik Solution’s Advanced Assessments team. On top of his work with Neuvik, he is also a SANS author and instructor for the SEC565: Red Team Operations and SEC699: Purple Team Tactics courses.
You can find Jean on social media here:
- Twitter: https://twitter.com/jean_maes_1994
- LinkedIn: https://www.linkedin.com/in/jean-francois-maes/
You can learn more about Neuvik here:
- Website: Neuvik | Cybersecurity Expertise
- Twitter: https://twitter.com/Neuvik
- LinkedIn: https://www.linkedin.com/company/neuvik-solutions/