🐳
Swayam's Blog
LinkedinGithub
  • 🫚root@Swayam's Blog
  • 🕺About Me
  • 🛠️Projects
    • CurveLock
    • ShadowChain
  • 🐞Malware Analysis
    • Basic Malware Analysis
      • LAB Network Setup
      • Basic Static Analysis
      • Basic Dynamic Analysis
      • Advanced Dynamic Analysis
      • Advanced Static Analysis
      • Identifying Anti analysis techniques
      • Binary Patching
      • Shellcode Analysis
      • Malware.unknown.exe.Malz
      • Challenge-Sillyputty
      • Bind_shell RAT Analysis
      • Malicious Powershell Script
      • Malicious HTA(HTML Applications)
      • Phishing Excel Embedded Malware
      • Reversing Csharp And DotNET Framework
      • YARA rules
      • Automating Malware Analysis
    • MASM 64 Bit Assembly
      • Hello World Of Assembly Language
      • Computer Data Representation and Operations
      • Memory Access And Organization
      • Constants, Variables And Data Types
      • Procedures
  • 👨‍💻Malware/Exploit Development
    • Driver Development
      • Driver 101
      • Kernel Calbacks
      • Process Protection
      • Process Token Privilege
  • 📖Notes And Cheatsheets
    • OSCP / Application Security
      • OS stuff
        • Footprinting
        • Nmap
        • Shells
        • Metasploit
        • Windows Buffer Overflow
        • Windows
        • Windows Privilege Escalation
        • Linux Commands
        • Linux Privilege Escalation
        • Password Cracking
        • Pivoting And Tunneling
        • Macos
      • General Introduction
        • Basic Tools
        • Basic Networking
      • WebApps
        • Attacking Common Applications
        • Attacking Common Services
        • Broken Authentication
        • Burp Proxy
        • Common Apps
        • Command Injection
        • ffuf Fuzzing
        • File Inclusion
        • File Transfer
        • File Upload
        • Javascript Deobfuscation
        • Password Attacks
        • SQLi
        • Web attacks
        • Web Information Gathering
        • Wordpress
        • Brute Forcing
        • HTTP Curl
      • Active Directory
    • Wireless Attacks
    • Red Teaming
    • BloodHound
    • Pentesting
    • ADCS
  • 🚩CTFs
    • Google CTF
Powered by GitBook
On this page
  • What is a Driver?
  • Configure your Development Environment
  • Driver Entry
  • Printing Debug Messages
  • Loading and Running the Driver
  • Driver Unload
  • Dispatch Routines
  • Client-Side Code
  • Dispatch Device Control
  • Sending Data to the Driver
  • Returning Data from the Driver
  • Kernel Debugging
  1. Malware/Exploit Development
  2. Driver Development

Driver 101

PreviousDriver DevelopmentNextKernel Calbacks

Last updated 9 days ago

What is a Driver?

A driver's most typical use case is to allow an operating system to talk to a hardware device. Most of the drivers you will have running on your computer facilitate communications between the OS and your computer hardware (motherboard, hard drives, graphics cards, etc). Often when we buy new hardware, we're also required to install software from the manufacturer. That software often includes a driver, which allows your computer to talk to said hardware.

For this reason, a driver operates in kernel mode.

A driver doesn't need to control hardware, and pure software drivers do also exist. The purpose of those will vary - one example of such a driver is anti-virus, where the driver is designed to help protect the computer against malicious actions.

Using drivers for offensive operations is very risky. If a normal user-mode application crashes, the worst that happens is that it loses any unsaved data it had in its memory space. The operating system can step in, collect a crash report, and release any resources (CPU, memory, etc) that the application was consuming. Nothing else is impacted, the damage is "contained" to that process only.

Because drivers operate in kernel mode, when they go wrong, they really go wrong. When a user-mode application closes, it's the kernel that ensures any resources are freed. However, if we have memory leaks in a driver, the kernel will not clean those up for us. Any resources leaked by a driver cannot be freed until the system is rebooted. Unsafe actions can also result in the good old "" (BSOD). These are generally caused under one of two conditions:

  1. Some corruption has occurred where the operating system simply cannot (or would be unsafe to) continue running.

  2. Kernel Patch Protection (KPP) detects a violation and triggers the routine.

KeBugCheck (referred to hereon in as "bug check") forces a BSOD with a relevant error code. A friendly, readable(ish) version of the error code is shown on the BSOD so the user has a chance (albeit marginal) of understanding what went wrong. The kernel is such a fragile place, it's like walking on eggshells. Even trying to read memory at an incorrect location can trigger a bug check. None of this matters when it's just you in your development environment, but as soon as you start using drivers offensively in penetration testing and red team engagements, the risk of crashing a client's system should be a significant concern.

KPP (aka PatchGuard) is a feature present in Windows designed to protect the kernel against unauthorised modifications. It works by periodically checking structures that Microsoft deem sensitive and if a change is detected, it will trigger a bug check and crash the system. When using drivers to circumvent certain kernel-level protection, we are going to stamp over KPP-protected regions. One "weakness" of KPP is that because the checks are expensive (computationally), it's not constantly checking protected regions. This introduces a type of race condition where we can modify a protected region and change it back without KPP noticing.

However, because you don't know when KPP will perform its next check, it's a little like playing Russian roulette. We can reduce the risk by narrowing the window of time a protected region stays modified, but this doesn't eliminate it. A final note - when Windows is put into test signing and kernel debug mode (shown in the next lesson), KPP is effectively disabled. You therefore won't see Windows bug check due to KPP during development.

Configure your Development Environment

The best methodology for building and testing a driver is using a two-machine setup - one to act as the dev machine (where you will write and build the code) and the other as the test machine (where you will load, run and debug your driver). Because I use Windows as my physical host OS, I typically dev on my host and use a VM for testing. You can use a VM as your dev machine if that is your preference, or if you run another host OS such as Linux or macOS.

Development Machine

The primary tools that you'll need on your dev machine are Visual Studio 2022, the Windows Driver Kit (WDK), and WinDbg. Visual Studio can be downloaded . Any version, including the free community edition can be used. During installation, select the Desktop development with C++ workload option.

Under Individual components, ensure the latest Windows 10 SDK version is selected (your versions may differ to those shown here).

Then find and select the latest MSVC x64/x86 Spectre-mitigation libs.

This will launch a further VSIX installer which will add new driver project templates to Visual Studio. If it does not, install it manually by browsing to C:\Program Files (x86)\Windows Kits\10\Vsix\VS2022\10.x and double-clicking on WDK.vsix.

Test Machine

This will install kdnet under C:\Program Files (x86)\Windows Kits\10\Debuggers\x64. Launch a Command Prompt as a local admin and enable test signing mode. This will allow the VM to load drivers that have not been signed with a valid code-signing certificate.

C:\Windows\system32>bcdedit /set testsigning on
The operation completed successfully.

Next, open regedit (also in an elevated context) and navigate to HKLM\SYSTEM\CurrentControlSet\Control\Session Manager. Create a new Key called Debug Print Filter and within that, a new DWORD value. Give it the name DEFAULT and a value of 8. This will allow Windows to generate kernel debug messages, which are disabled by default.

Finally, enable kernel debugging with kdnet.exe <ip> <port>. Where <ip> is the IP address of your dev machine and <port> is a random port.

C:\Windows\system32>"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\kdnet.exe" 192.168.0.154 50000

Enabling network debugging on Network debugging is supported by this Microsoft Hypervisor Virtual Machine.

To debug this vm, run the following command on your debugger host machine.windbg -k net:port=50000,key=1zeaj689h01ba.2p51dbdh81yzl.2ghcs6uei0g73.1mba0f4ju3ykv Then restart this VM by running shutdown -r -t 0 from this command prompt.

Before restarting the test machine, launch WinDbg on the dev machine, go to File > Attach to kernel and enter the key output from kdnet.

Click OK and it will wait for a connection.

Microsoft (R) Windows Debugger Version 10.0.25877.1004 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

Using NET for debugging
Opened WinSock 2.0
Waiting to reconnect...

Reboot the test VM and it will connect to the debugger during startup.

Windows 10 Kernel Version 19041 MP (1 procs) Free x64
Edition build lab: 19041.1.amd64fre.vb_release.191206-1406
Kernel base = 0xfffff801`4d000000 PsLoadedModuleList = 0xfffff801`4dc2a2d0
System Uptime: 0 days 0:00:00.378

You can stop the kernel by selecting Break (in the top-left).

Break instruction exception - code 80000003 (first chance)
*******************************************************************************
*                                                                             *
*   You are seeing this message because you pressed either                    *
*       CTRL+C (if you run console kernel debugger) or,                       *
*       CTRL+BREAK (if you run GUI kernel debugger),                          *
*   on your debugger machine's keyboard.                                      *
*                                                                             *
*                   THIS IS NOT A BUG OR A SYSTEM CRASH                       *
*                                                                             *
* If you did not intend to break into the debugger, press the "g" key, then   *
* press the "Enter" key now.  This message might immediately reappear.  If it *
* does, press "g" and "Enter" again.                                          *
*                                                                             *
*******************************************************************************
nt!DbgBreakPointWithStatus:
fffff801`4d405660 cc              int     3

You will notice the entire VM freeze, which is why you cannot fully debug a local kernel - the whole machine state is suspended by the debugger. To allow the system to continue, select Go or press F5. After which, the VM will start responding again.

Driver Entry

To create your first driver, open Visual Studio and create a new project. From the project template selection, find the Kernel Mode Driver, Empty (KMDF).

There is a skeleton KMDF project template that provides more boilerplate code, but we want to start from scratch to ensure we understand the basic anatomy of a driver.

Create a new file under Source Files called main.cpp. The first thing a driver requires is a DriverEntry - think of this as the "main" function in userland executables. The prototype for this method is:

NTSTATUS DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath)

We need to return a status code, so let's just return STATUS_SUCCESS for now.

#include <ntddk.h>

extern "C"
NTSTATUS
DriverEntry(
	_In_ PDRIVER_OBJECT DriverObject,
	_In_ PUNICODE_STRING RegistryPath
)
{
	return STATUS_SUCCESS;
}

Two aspects to note:

  1. extern "C" is required to provide C-linkage, which is not the default for C++ compilation.

However, if we try and build this it will fail with two "unreferenced parameter" warnings. The project is configured with "treat warnings as errors" and therefore refuses to compile.

1>C:\Users\Daniel\source\repos\DriverDev\Driver\main.cpp(7,23): error C2220: the following warning is treated as an error
1>C:\Users\Daniel\source\repos\DriverDev\Driver\main.cpp(7,23): warning C4100: 'RegistryPath': unreferenced formal parameter
1>C:\Users\Daniel\source\repos\DriverDev\Driver\main.cpp(6,22): warning C4100: 'DriverObject': unreferenced formal parameter
1>Done building project "Driver.vcxproj" -- FAILED.

It's important when creating drivers not to disable this setting and to deal with the warnings as they come up. If we ignore errors, we run the risk of them causing issues such as memory leaks, which in turn may lead to system crashes.

To get address this, we can use the UNREFERENCED_PARAMETER macro.

#include <ntddk.h>

extern "C"
NTSTATUS
DriverEntry(
	_In_ PDRIVER_OBJECT DriverObject,
	_In_ PUNICODE_STRING RegistryPath
)
{
	UNREFERENCED_PARAMETER(DriverObject);
	UNREFERENCED_PARAMETER(RegistryPath);

	return STATUS_SUCCESS;
}

The driver will now build, huzzah.

Printing Debug Messages

The KdPrint macro can be used to send messages to the kernel debugger, which can be helpful when debugging your driver. These messages can be captured from inside WinDbg or other tools such as Dbgview from SysInternals. It can be used like printf where you can send a simple string message, or include other data using format strings.

Here are two examples:

// basic message
KdPrint(("[+] Hello from DriverEntry\n"));

// print failed status
if (!NT_SUCCESS(status)) {
	KdPrint(("[!] Failed with status 0x%08X\n", status));
}

Loading and Running the Driver

After building the driver, copy the output file, C:\Users\Daniel\source\repos\DriverDev\x64\Debug\Driver.sys in my case, to the test VM. I am putting it in the path C:\MyDriver\Driver.sys. A service is required to run a driver, which can be created using the native sc.exe command-line tool.

C:\>sc create MyDriver type= kernel binPath= C:\MyDriver\Driver.sys
[SC] CreateService SUCCESS

C:\>sc qc MyDriver
[SC] QueryServiceConfig SUCCESS

SERVICE_NAME: MyDriver
        TYPE               : 1  KERNEL_DRIVER
        START_TYPE         : 3   DEMAND_START
        ERROR_CONTROL      : 1   NORMAL
        BINARY_PATH_NAME   : \??\C:\MyDriver\Driver.sys
        LOAD_ORDER_GROUP   :
        TAG                : 0
        DISPLAY_NAME       : MyDriver
        DEPENDENCIES       :
        SERVICE_START_NAME :

It will also be registered under HKLM\SYSTEM\CurrentControlSet\Services\MyDriver.

You can then start the driver using sc start.

C:\Windows\system32>sc start MyDriver

SERVICE_NAME: MyDriver
        TYPE               : 1  KERNEL_DRIVER
        STATE              : 4  RUNNING
                                (STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x0
        PID                : 0
        FLAGS              :

When testing new versions of the driver, it's not necessary to fully delete the associated service. Simply stop the service, replace the .sys file and start the service again.

Driver Unload

When a driver unloads, any resources that it's holding must be freed to prevent leaks. A pointer to a "cleanup" function should be provided in the DriverEntry by setting the DriverUnload member of the DriverObject. The prototype is:

void DriverUnload(PDRIVER_OBJECT DriverObject);

Create a new header file in your project called driver.h and add the following code:

#pragma once
#include <ntddk.h>

constexpr auto MY_DRIVER_TAG = '1GAT';

void Cleanup(PDRIVER_OBJECT DriverObject);

Then update your main.cpp code:

#include "driver.h"

PVOID g_myMemory;

extern "C"
NTSTATUS
DriverEntry(
	_In_ PDRIVER_OBJECT DriverObject,
	_In_ PUNICODE_STRING RegistryPath
)
{
	UNREFERENCED_PARAMETER(RegistryPath);
	KdPrint(("[+] Hello from DriverEntry\n"));

	// point DriverUnload to Cleanup function
	DriverObject->DriverUnload = Cleanup;

	// allocate some memory
	g_myMemory = ExAllocatePool2(
		POOL_FLAG_PAGED,
		1024,
		MY_DRIVER_TAG
	);

	KdPrint(("[+] Memory allocated at 0x%08p\n", g_myMemory));

	return STATUS_SUCCESS;
}

void Cleanup(
	PDRIVER_OBJECT DriverObject
)
{
	UNREFERENCED_PARAMETER(DriverObject);
	
	KdPrint(("[+] Hello from DriverUnload\n"));
	KdPrint(("[+] Freeing memory at 0x%08p\n", g_myMemory));

	// free the allocated memory
	ExFreePoolWithTag(
		g_myMemory,
		MY_DRIVER_TAG
	);
}

When starting the driver, WinDbg will show:

[+] Hello from DriverEntry
[+] Memory allocated at 0xFFFFAE81785E4B30

Then when stopping the driver:

[+] Hello from DriverUnload
[+] Freeing memory at 0xFFFFAE81785E4B30

Dispatch Routines

As well as DriverUnload, there is the MajorFunction member of the DRIVER_OBJECT. This is an array of pointers that specifies operations that the driver supports. Without these, a caller cannot interact with the driver. Each major function is referenced with an IRP_MJ_ prefix, where IRP is short for "I/O Request Packet". Common functions include:

  • IRP_MJ_CREATE

  • IRP_MJ_CLOSE

  • IRP_MJ_READ

  • IRP_MJ_WRITE

  • IRP_MJ_DEVICE_CONTROL

A driver would likely need to support at least IRP_MJ_CREATE and IRP_MJ_CLOSE, as these allow a calling client to open (and subsequently close) handles to the driver. The prototype for a dispatch routine is:

NTSTATUS SomeMethod(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp);

For now, let's create a simple implementation that returns a success status.

NTSTATUS CreateClose(
	_In_ PDEVICE_OBJECT DeviceObject,
	_In_ PIRP Irp
)
{
	UNREFERENCED_PARAMETER(DeviceObject);
	KdPrint(("[+] Hello from CreateClose\n"));

	Irp->IoStatus.Status = STATUS_SUCCESS;
	Irp->IoStatus.Information = 0;

	IoCompleteRequest(Irp, IO_NO_INCREMENT);

	return STATUS_SUCCESS;
}

We can then point the create and close major functions at this routine.

DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE]  = CreateClose;

To test this, we need to create a userland application capable of opening and closing a handle to the driver. To facilitate that, the driver first needs an associated device object and symlink. First, add the following to driver.h:

#pragma once
#include <ntdef.h>

UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\MyDriver");
#include "driver.h"

extern "C"
NTSTATUS
DriverEntry(
	_In_ PDRIVER_OBJECT DriverObject,
	_In_ PUNICODE_STRING RegistryPath
)
{
	NTSTATUS		status;
	PDEVICE_OBJECT	deviceObject;

	UNREFERENCED_PARAMETER(RegistryPath);
	KdPrint(("[+] Hello from DriverEntry\n"));

	DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
	DriverObject->MajorFunction[IRP_MJ_CLOSE]  = CreateClose;
	DriverObject->DriverUnload = Cleanup;

	// create device object
	status = IoCreateDevice(
		DriverObject,
		0,
		&deviceName,
		FILE_DEVICE_UNKNOWN,
		0,
		FALSE,
		&deviceObject
	);

	if (!NT_SUCCESS(status)) {
		KdPrint(("[!] IoCreateDevice failed: 0x%08X\n", status));
		return status;
	}

	// create symlink
	status = IoCreateSymbolicLink(
		&symLink,
		&deviceName);

	if (!NT_SUCCESS(status)) {
		KdPrint(("[!] IoCreateSymbolicLink failed: 0x%08X\n", status));

		// delete device object before returning
		IoDeleteDevice(deviceObject);

		return status;
	}

	return STATUS_SUCCESS;
}

NTSTATUS CreateClose(
	_In_ PDEVICE_OBJECT DeviceObject,
	_In_ PIRP Irp
)
{
	UNREFERENCED_PARAMETER(DeviceObject);
	KdPrint(("[+] Hello from CreateClose\n"));

	Irp->IoStatus.Status = STATUS_SUCCESS;
	Irp->IoStatus.Information = 0;

	IoCompleteRequest(Irp, IO_NO_INCREMENT);

	return STATUS_SUCCESS;
}

void Cleanup(
	PDRIVER_OBJECT DriverObject
)
{
	// delete symlink
	IoDeleteSymbolicLink(&symLink);
	
	// delete device object
	IoDeleteDevice(DriverObject->DeviceObject);
}

It's worth noting that if we don't return a success status from DriverEntry, then DriverUnload is not called afterwards. For that reason, we have to ensure that we free any resources that we've made inside DriverEntry up to the point of failure. And of course, we still have to free them from the DriverUnload for cases where the driver did load successfully.

Client-Side Code

To create an application that can interact with the driver from userland, create a new console application in the Visual Studio solution. Mine looks like this:

#include <Windows.h>
#include <stdio.h>

int main()
{
    HANDLE  hDriver;

    // open handle
    printf("[+] Opening handle to driver\n");
    
    hDriver = CreateFile(
        L"\\\\.\\MyDriver",
        GENERIC_WRITE,
        FILE_SHARE_WRITE,
        nullptr,
        OPEN_EXISTING,
        0,
        nullptr);

    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[!] Failed to open handle: %d", GetLastError());
        return 1;
    }

    // little sleep
    printf("[+] Sleeping...\n");
    Sleep(3000);

    // close handle
    printf("[+] Closing handle\n");
    CloseHandle(hDriver);
}

When we run this, it should print to the console.

C:\MyDriver>Client.exe
[+] Opening handle to driver
[+] Sleeping...
[+] Closing handle

And two corresponding messages in WinDbg. The first when the handle is opened, the second when it's closed.

[+] Hello from CreateClose
[+] Hello from CreateClose

Dispatch Device Control

Now that we have a driver and a client that can connect to it, the next step is to expose some functionality in the driver that the client can call. For that, we can use the IRP_MJ_DEVICE_CONTROL major function. The method signature for which should look like this:

NTSTATUS DeviceControl(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp);

Because we can define multiple functions in a driver, we need a way for the client to specify which one it wants. We do that with "Device Input and Output Controls", or IOCTL's. Create a new header file in the driver project called ioctl.h, then add the following:

#define MY_DRIVER_DEVICE	0x8000
#define MY_DRIVER_IOCTL_TEST	CTL_CODE(MY_DRIVER_DEVICE, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS)

The control codes should be built with the CTL_CODE macro. Here's a quick overview of the parameters:

  • The first parameter is a DeviceType - you can technically provide any value, but the Microsoft documentation states that 3rd party drivers start from 0x8000.

  • The second parameter is a Function value - as with DeviceType's, Microsoft says that 3rd party codes should start from 0x800. Each IOCTL in a driver must have a unique function value, so they're commonly just incremented (0x800, 0x801 etc).

  • The next parameter defines how input and output buffers are passed to the driver. METHOD_NEITHER tells the I/O manager not to provide any system buffers, meaning the IRP supplies the user-mode virtual address of the I/O buffers directly to the driver. In this case, the input buffer can be found at Parameters.DeviceIoControl.Type3InputBuffer of the PIO_STACK_LOCATION; and the output buffer at Irp->UserBuffer. There are risks associated with this, such as cases where the caller frees their buffer before the driver tries to write to it.

  • The final parameter indicates whether this operation is to the driver, from the driver, or both ways.

Let's add an implementation to just print a debug message.

NTSTATUS
DeviceControl(
	_In_ PDEVICE_OBJECT DeviceObject,
	_In_ PIRP Irp
)
{
	UNREFERENCED_PARAMETER(DeviceObject);

	// initialise return values
	ULONG_PTR length = 0;
	NTSTATUS status  = STATUS_SUCCESS;
	
	// get the caller's I/O stack location
	PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);

	// switch based on the provided IOCTL
	switch (stack->Parameters.DeviceIoControl.IoControlCode)
	{
	case MY_DRIVER_IOCTL_TEST:
		KdPrint(("[+] Hello from MY_DRIVER_IOCTL_TEST\n"));
		break;

	default:
		status = STATUS_INVALID_DEVICE_REQUEST;
		KdPrint(("[!] Unknown IOCTL code!\n"));
		break;
	}

	// set return information
	Irp->IoStatus.Status = status;
	Irp->IoStatus.Information = length;

	IoCompleteRequest(Irp, IO_NO_INCREMENT);

	return status;
}
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceControl;

Now we need to update the client so that it can call this new IOCTL. For ease, reference the IOCTL header file from the driver project by adding #include "..\MyDriver\ioctl.h" (the path may vary depending on your VS solution structure) to Client.cpp.

#include <Windows.h>
#include <stdio.h>
#include "..\Driver\ioctl.h"

int main()
{
    HANDLE  hDriver;
    BOOL    success;

    // open handle
    printf("[+] Opening handle to driver\n");
    
    hDriver = CreateFile(
        L"\\\\.\\MyDriver",
        GENERIC_WRITE,
        FILE_SHARE_WRITE,
        nullptr,
        OPEN_EXISTING,
        0,
        nullptr);

    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[!] Failed to open handle: %d", GetLastError());
        return 1;
    }

    // call MY_DRIVER_IOCTL_TEST
    printf("[+] Calling MY_DRIVER_IOCTL_TEST...");
    success = DeviceIoControl(
        hDriver,
        MY_DRIVER_IOCTL_TEST,
        nullptr,
        0,
        nullptr,
        0,
        nullptr,
        nullptr);

    if (success) {
        printf("success\n");
    }
    else {
        printf("failed: %d\n", GetLastError());
    }

    // close handle
    printf("[+] Closing handle\n");
    CloseHandle(hDriver);
}

The output from the console should look like this:

C:\MyDriver>Client.exe
[+] Opening handle to driver
[+] Calling MY_DRIVER_IOCTL_TEST...success
[+] Closing handle

And the output from WinDbg is like this:

[+] Hello from DriverEntry
[+] Hello from CreateClose
[+] Hello from MY_DRIVER_IOCTL_TEST
[+] Hello from CreateClose

Sending Data to the Driver

To send some data to the driver, we can provide a buffer to DeviceIoControl. Here's a basic example. Create a new header file in the driver project called common.h, and add the following structure.

typedef struct _THE_QUESTION {
    int FirstNumber;
    int SecondNumber;
} THE_QUESTION, *PTHE_QUESTION;

For demonstration purposes, we'll re-use the current IOCTL. So, to create an instance of this struct and send it to the driver, we can do:

PTHE_QUESTION question = new THE_QUESTION { 6, 9 };

success = DeviceIoControl(
    hDriver,
    MY_DRIVER_IOCTL_TEST,
    question,              // pointer to the data
    sizeof(THE_QUESTION),  // the size of the data
    nullptr,
    0,
    nullptr,
    nullptr);

To handle this in the driver, (within the switch case for MY_DRIVER_IOCTL_TEST), we first need to check that the expected buffer size is large enough.

// check that the input buffer length is
// large enough to hold the expected struct
if (stack->Parameters.DeviceIoControl.InputBufferLength < sizeof(THE_QUESTION))
{
	KdPrint(("[!] Buffer too small to hold THE_QUESTION\n"));
	status = STATUS_BUFFER_TOO_SMALL;
	break;
}

We can then cast the data to a new pointer to TheQuestion, but we still need to check that it's not null. Otherwise, the machine will BSOD if we try to deference a null pointer.

PTHE_QUESTION question = (PTHE_QUESTION)stack->Parameters.DeviceIoControl.Type3InputBuffer;

if (question == nullptr)
{
	KdPrint(("[+] PTHE_QUESTION was null\n"));
	status = STATUS_INVALID_PARAMETER;
	break;
}

We're not returning data from the driver yet, so let's just print the values.

KdPrint(("[+] THE_QUESTION, first number: %d", question->FirstNumber));
KdPrint(("[+] THE_QUESTION, second number: %d", question->SecondNumber));

The complete case block should look something like this:

case MY_DRIVER_IOCTL_TEST:
{
	KdPrint(("[+] Hello from MY_DRIVER_IOCTL_TEST\n"));

	// check that the input buffer length is
	// large enough to hold the expected struct
	if (stack->Parameters.DeviceIoControl.InputBufferLength < sizeof(THE_QUESTION))
	{
		KdPrint(("[!] Buffer too small to hold THE_QUESTION\n"));
		status = STATUS_BUFFER_TOO_SMALL;
		break;
	}

	PTHE_QUESTION question = (PTHE_QUESTION)stack->Parameters.DeviceIoControl.Type3InputBuffer;

	if (question == nullptr)
	{
		KdPrint(("[+] PTHE_QUESTION was null\n"));
		status = STATUS_INVALID_PARAMETER;
		break;
	}

	KdPrint(("[+] THE_QUESTION, first number: %d\n", question->FirstNumber));
	KdPrint(("[+] THE_QUESTION, second number: %d\n", question->SecondNumber));

	break;
}

We should now see the following in WinDbg:

[+] Hello from DriverEntry
[+] Hello from CreateClose
[+] Hello from MY_DRIVER_IOCTL_TEST
[+] THE_QUESTION, first number: 6
[+] THE_QUESTION, second number: 9
[+] Hello from CreateClose

Returning Data from the Driver

Instead of just printing the integers, let's return something back the caller. We'll call it "the answer".

typedef struct _THE_ANSWER {
    int Answer;
} THE_ANSWER, * PTHE_ANSWER;

The client needs to create an output buffer large enough to accommodate the response and pass a pointer to it via DeviceIoControl.

PTHE_QUESTION question = new THE_QUESTION { 6, 9 };
PTHE_ANSWER answer = new THE_ANSWER();
DWORD bytesReceived = 0;

success = DeviceIoControl(
    hDriver,
    MY_DRIVER_IOCTL_TEST,
    question,           // pointer to question
    sizeof(question),   // the size of question
    answer,             // pointer to answer
    sizeof(answer),     // the size of answer
    &bytesReceived,     // tells us the actual amount of data received
    nullptr);

If the call was successful, we should be able to print the answer.

if (success) {
    printf("success\n");
    printf("[+] THE_ANSWER: %d\n", answer->Answer);
}

On the driver-side, we can check that the output buffer is large enough and then write the response data into it.

// check that the output buffer length is
// large enough to hold THE_ANSWER
if (stack->Parameters.DeviceIoControl.OutputBufferLength < sizeof(THE_ANSWER))
{
	KdPrint(("[!] Buffer too small to hold THE_ANSWER\n"));
	status = STATUS_BUFFER_TOO_SMALL;
	break;
}

// cast the output buffer
PTHE_ANSWER answer = (PTHE_ANSWER)Irp->UserBuffer;

// write the value
answer->Answer = 42;

// assign the return length
length = sizeof(THE_ANSWER);
C:\MyDriver>Client.exe
[+] Opening handle to driver
[+] Calling MY_DRIVER_IOCTL_TEST...success
[+] THE_ANSWER: 42
[+] Closing handle

Kernel Debugging

One of the coolest features of WinDbg is the ability to load your source code and debug symbols, and debug a driver in the same way you might experience in an IDE. If you already have WinDbg attached to the VM kernel, click the Break button. Then go to File > Settings > Debugging settings.

Under source path, add the path to the driver source code. For me, that's C:\Users\Daniel\source\repos\DriverDev\Driver.

Under symbol path, add the directory that contains Driver.pdb. For me, that's C:\Users\Daniel\source\repos\DriverDev\x64\Debug.

Click Ok to save the changes.

To set a breakpoint, use the bp command. For example:

kd> bp Driver!DeviceControl

The bl command will list all current breakpoints.

kd> bl
     0 e Disable Clear  fffff806`6d291090  [C:\Users\Daniel\source\repos\DriverDev\Driver\main.cpp @ 61]     0001 (0001) Driver!DeviceControl

Breakpoints can be removed using the bc command. bc <index> will delete a single breakpoint at the given index and bc * will delete them all. You can also use bd to temporary disable breakpoints without deleting them, and be to enable them again.

Resume the debugger by pressing Go or using the g command, then run the client app. Once it sends the IOCTL, WinDbg will break and automatically displays our source code.

The step out/into/over buttons can then be used to step through execution flow as with any managed debugger. You can also set additional breakpoints by clicking in the little gutter on the left-hand side.

Once installation is complete, download and run the . At the end of the installation, ensure checkbox for installing the Visual Studio extension is selected before closing the installer window.

The old, "classic" version of WinDbg should already be installed under C:\Program Files (x86)\Windows Kits\10\Debuggers\x64, but a more modern version is available for download (it's the full release of the WinDbg Preview version found in the Windows Store).

The two most popular methods of exposing kernel debugging from a VM are via and . The former uses a virtual COM port on the VM and the latter a virtual NIC. Microsoft recommend KDNET for its better performance and compatibility between hypervisors. Begin by downloading the standalone Windows 10 SDK installer from . During setup, uncheck every option except for Debugging Tools.

is the main kernel header and is required to reference structures such as DRIVER_OBJECT.

Another popular tool is the . It does the same thing as sc but in a nice GUI. Once the driver has started, you should see the appropriate output in WinDbg.

In simple terms, we are allocating a pool of memory using when the driver is loaded, and then freeing it afterwards with when the driver is unloaded. If we failed to free this memory, it would cause a kernel memory leak each time the driver is started and stopped.

We then need to call and which will expose the driver's handle to userland. Update main.cpp to:

To open a handle to the driver, a client can use the API, where the 'filename' is the symlink to the driver device.

We use to get a pointer to the caller's stack location, then from that, access the specific IOCTL that the caller has specified. We can then do a switch in our code to send execution flow to the correct driver function. is used to tell the caller that the driver has completed all I/O operations. We must then link this function to the device control major function using:

To call IRP_MJ_DEVICE_CONTROL, we use the API. In this case we are not providing any data to the driver, nor expecting any back, so most of the parameters can be 0/null.

👨‍💻
blue screen of death
KeBugCheck
here
WDK installer
here
KDCOM
KDNET
here
ntddk
OSR Driver Loader
ExAllocatePool2
ExFreePoolWithTag
IoCreateDevice
IoCreateSymbolicLink
CreateFile
IoGetCurrentIrpStackLocation
IoCompleteRequest
DeviceIoControl