Getting Started

Taken from Zero-Point Security's Offensive Driver Development Course

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:

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

Two aspects to note:

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

  2. 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.

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.

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:

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.

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

You can then start the driver using sc start.

Another popular tool is the OSR Driver Loader. 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.

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:

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

Then update your main.cpp code:

In simple terms, we are allocating a pool of memory using ExAllocatePool2 when the driver is loaded, and then freeing it afterwards with ExFreePoolWithTag 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.

When starting the driver, WinDbg will show:

Then when stopping the driver:

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:

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

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

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:

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

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:

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

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

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

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:

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:

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.

We use IoGetCurrentIrpStackLocation 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. IoCompleteRequest 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:


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.

To call IRP_MJ_DEVICE_CONTROL, we use the DeviceIoControl 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.

The output from the console should look like this:

And the output from WinDbg is like this:

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.

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:

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.

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.

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

The complete case block should look something like this:

We should now see the following in WinDbg:

Returning Data from the Driver

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

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

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

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

Last updated