Writing a Windows Kernel Driver
Introduction
If you haven't read the first two articles yet, here and here, then you probably should, because they are essential to understanding what we'll be talking about here.
Become a certified reverse engineer!
Before we actually take a look at the code and explain it in detail, I would like to first explain the whole concept that we're going to use, just to better understand it. Let's take a look at the picture below:
The green elements are the ones we have to write: app.exe is a user mode application that calls the kernel mode driver (also a green element). All the purple elements are already provided by the operating system.
In any user mode application, we're using the ntdll.dll library which calls into the kernel mode with the use of the sysenter instruction. That in turn calls into the kernel mode through the KiFastCallEntry function. Whenever we're calling into the kernel driver, we must use the I/O manager that passes the IRP request to it. The kernel driver will process the IRP request and perform some actions based on the IRP request action that was requested by the app.exe.
We need to be aware that the I/O Manager actually passes the IRP request through a driver stack, which means that the same IRP request is given to multiple kernel drivers. The kernel drivers must handle the request and report back to the I/O Manager, which in turn reports back to the app.exe application about the action being done, either successfully or unsuccessfully. What actually happens is that the I/O Manager creates the IRP structure (request) and passes the pointer to that structure to drivers until the request has been processed. This is why the kernel driver must be able to access the IRP request and process it.
Besides processing IRP requests, the kernel driver in turn uses hal.dll library as an interface to the actual hardware component. The kernel driver can also use the hardware directly, but that isn't usually the case, because we normally don't want to bypass HAL.
Let's also take a look at another picture:
Here we can see that when the kernel driver is being loaded, its DriverEntry function is called, and when it's being unloaded, its DriverUnload function is called. It's the responsibility of the I/O Manager to call the driver's DriverEntry function and pass it to the DRIVER_OBJECT structure, which contains the metadata about the driver itself. This data structure is used by the I/O Manager to store relevant information about every driver in the system.
The I/O Manager must populate the DriverInit member in the DRIVER_OBJECT structure to store the pointer to the DriverEntry routine; this is also the pointer that is used when the driver is loaded into kernel. The other important element in the DRIVER_OBJECT structure is the MajorFunction array, which stores the addresses of the functions that will be called on specific IRP requests to handle the IRP request in question.
In this tutorial, we'll write the kernel driver, which is presented as the green element in the kernel mode as seen on the picture above.
Writing the Driver
To start, we'll take a look at a driver code example from [3]. Let's first review the complete example below that contains the DriverEntry function:
[cpp]
#define _X86_
#include <wdm.h>
VOID Example_Unload(PDRIVER_OBJECT DriverObject);
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
#pragma alloc_text(INIT, DriverEntry)
#pragma alloc_text(PAGE, Example_Unload)
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
UINT uiIndex = 0;
PDEVICE_OBJECT pDeviceObject = NULL;
DbgPrint("DriverEntry Called rn");
RtlInitUnicodeString(&usDriverName, L"DeviceExample");
RtlInitUnicodeString(&usDosDeviceName, L"DosDevicesExample");
NtStatus = IoCreateDevice(pDriverObject, 0, &usDriverName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &pDeviceObject);
if(NtStatus == STATUS_SUCCESS)
{
for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex++)
pDriverObject->MajorFunction[uiIndex] = Example_UnSupportedFunction;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = Example_Close;
pDriverObject->MajorFunction[IRP_MJ_CREATE] = Example_Create;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = Example_IoControl;
pDriverObject->MajorFunction[IRP_MJ_READ] = Example_Read;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = USE_WRITE_FUNCTION;
pDriverObject->DriverUnload = Example_Unload;
pDeviceObject->Flags |= IO_TYPE;
pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);
IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);
return NtStatus;
}
VOID Example_Unload(PDRIVER_OBJECT DriverObject)
{
UNICODE_STRING usDosDeviceName;
DbgPrint("Example_Unload Called rn");
RtlInitUnicodeString(&usDosDeviceName, L"DosDevicesExample");
IoDeleteSymbolicLink(&usDosDeviceName);
IoDeleteDevice(DriverObject->DeviceObject);
}
Let's explain the above program to understand what's going on. We can see that it's using DriverEntry function, which is the initialization function of every driver. The syntax of that function can be seen on the picture below, which was taken from [7]:
The DriverEntry function takes two parameters as inputs. We've already discussed those two parameters in detail in the previous article, so we won't repeat ourselves.
In the beginning of the function, we're initializing the structures that we'll need later. The NtStatus variable is the one the function will return. Remember that the function must return STATUS_SUCCESS if it succeeds, otherwise it must return one of the error messages. We initialize the NtStatus variable as STATUS_SUCCESS in the beginning, so if nothing bad happens during the function execution that would change the value of NtStatus, then the DriverEntry function will succeed.
[cpp]
NTSTATUS NtStatus = STATUS_SUCCESS;
UINT uiIndex = 0;
PDEVICE_OBJECT pDeviceObject = NULL;
UNICODE_STRING usDriverName, usDosDeviceName;
After that, we print the message "DriverEntry Called" with the DbgPrint function.
[cpp]
DbgPrint("DriverEntry Called rn");
The DbgPrint function behaves exactly the same as the printf function, except it doesn't print messages to stdout, but to the debugger output window. We can download the tool DbgView.exe from SysInternals which can display those messages. The tool with no messages can be seen below:
Next, we use the function RtlInitUnicodeString to construct the Unicode version of the string and save those strings into the usDriverName and usDosDeviceName variables. We talked about this in detail here [LUKAN_NTDLL_Call_Functions.doc]. To summarize, the RtlInitUnicodeString creates the UNICODE_STRING data structure.
[cpp]
RtlInitUnicodeString(&usDriverName, L"DeviceExample");
RtlInitUnicodeString(&usDosDeviceName, L"DosDevicesExample");
Keep in mind that the UNICODE_STRING data structure contains three elements as seen on the picture below:
Because it also contains the Length of the string, the actual string need not be null terminated. We need to keep this in mind when writing the kernel driver.
Next, we call the IoCreateDevice function, with a syntax like this (taken from [12]):
[cpp]
NtStatus = IoCreateDevice(pDriverObject, 0, &usDriverName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &pDeviceObject);
Let's break it down based on the arguments passed to the IoCreateDevice function:
- DriverObject: pointer to the driver object
- DeviceExtensionSize: number of bytes for the device extension
- DeviceName: pointer to a null-terminated Unicode string with the name of the device
- DeviceType: a constant that specifies the type of device, should be one of the constants defined here
- DeviceCharacteristics : a constant that provides additional information about the driver's device, should be one of the constants defined here
- Exclusive: is either TRUE or FALSE specifying if the device is exclusive
- DeviceObject: pointer to a variable that receives a pointer to the created DEVICE_OBJECT structure
The IoCreateDevice function creates a device object for use by a driver and saves a pointer to it in the DeviceObject variable specified as the 7th parameter. In our case, we're creating an unknown non-exclusive device object named "DeviceExample". The pointer to the device object is then saved into the pDeviceObject variable and IoCreateDevice returns STATUS_SUCCESS if the function call succeeded. As a result, we've just registered a new device with the name "DeviceExample" to the operating system with the IoCreateDevice function call.
Each driver must create a device object in order to receive any IRPs for the device to handle; therefore, the device object receives all IRPs for the device. Next, we'll be using DRIVER_OBJECT structure, so I guess it's best if we first present its members. We can see the DRIVER_OBJECT structure on the picture below (taken from [14]):
After the creation of the device driver "DeviceExample", whose handle is stored in pDeviceObject, we need to instruct the device object to call into the device driver upon IRP requests being made. We use MajorFunction in the next piece of code, which points to the array of entry points for the driver's DispatchXXX routines. Each index value to the MajorFunction array is the IRP_MJ_XXX value representing the IRP major function code [14]. We must set appropriate entry points to specify which IRP_MJ_XXX values our driver will handle.
[cpp]
for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex++)
pDriverObject->MajorFunction[uiIndex] = Example_UnSupportedFunction;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = Example_Close;
pDriverObject->MajorFunction[IRP_MJ_CREATE] = Example_Create;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = Example_IoControl;
pDriverObject->MajorFunction[IRP_MJ_READ] = Example_Read;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = USE_WRITE_FUNCTION;
In the code above, we specify the action the underlying device driver should do in order to process the I/O request. Our drivers can handle IRPs that have the following major function codes set:
- IRP_MJ_CLOSE: driver must handle close requests. For example, when we're calling CloseHandle, the underlying system actually sends the IRP_MJ_CLOSE request to the driver to close the handle.
- IRP_MJ_CREATE: driver must handle create requests, which can be sent when we want to open a file handle or device handle. For example, when we're calling CreateFile function, the operating system actually sends the IRP_MJ_CREATE request to do the requested action.
- IRP_MJ_DEVICE_CONTROL: each driver in a driver stack must support this request, which is used to handle arbitrary actions. If we don't actually have a hardware device, we should use the function specified with this constant that should provide the code to satisfy and process the requests.
- IRP_MJ_READ: driver must handle read requests when it transfers data from its device to the system that requested the data. Usually, the I/O Manager creates a buffer inside the IRP where the kernel driver will write the requested data to.
- IRP_MJ_WRITE: driver must handle write requests when it transfers data from the system that sent the data to its device.
We should also mention that Example_UnSupportedFunction is first used to overwrite every entry in the MajorFunction array. This is the function defined in functions.c that doesn't actually do anything; it just prints that the action is not supported. We can see the whole code below:
[cpp]
NTSTATUS Example_UnSupportedFunction(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS NtStatus = STATUS_NOT_SUPPORTED;
return NtStatus;
}
It's because of the pointers in the MajorFunction element array that the kernel driver can accept IRP requests from the I/O Manager and process them. We specified which of the following functions will be called when a specific IRP comes in: Example_Close, Example_Create, Example_IoControl and Example_Read. When the I/O Manager has an IRP request, it will pass the pointer to the IRP to those functions. The IRP_MJ_XXX constants are used to specify what kind of operation the certain function will perform when it passes the IRP request. All of the IRP_MJ_XXX constants are defined in the wdm.h header file as seen below:
If we open that file and search for "IRP_MJ", we'll find the following constants that can be used:
The next line of code specifies the driver unload function, which allows the system to unload the driver by itself; otherwise, we would have to do it manually.
[cpp]
pDriverObject->DriverUnload = Example_Unload;
So far we've been using the DRIVER_OBJECT structure to set the required elements, but we must also use the DEVICE_OBJECT structure to set appropriate values. The structure can be seen below, taken from [15]:
The next piece of code sets the Flags data member in the DEVICE_OBJECT data structure:
[cpp]
pDeviceObject->Flags |= IO_TYPE;
pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);
The IO_TYPE constant is defined in the example.h header file as follows:
[cpp]
#ifdef __USE_DIRECT__
#define IO_TYPE DO_DIRECT_IO
#ifdef __USE_BUFFERED__
#define IO_TYPE DO_BUFFERED_IO
#endif
The DO_DEVICE_INITIALIZING is a flag telling the system that the device is being initialized.
The next piece of code uses the IoCreateSymbolicLink function which sets a symbolic link between a device object name and a user visible name for the device. The syntax for the function is shown below [16]:
[cpp]
IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);
We can verify with winobj.exe that the symbolic link "DosDevicesExample" has been created and that it points to the "DeviceExample" device. The device and the symbol link are only valid until we unload the driver, because the driver's Example_Unload function takes care of it, or until we reboot the machine.
The entry.c source code file also holds the Example_Unload function, which is presented below:
[cpp]
VOID Example_Unload(PDRIVER_OBJECT DriverObject) {
UNICODE_STRING usDosDeviceName;
DbgPrint("Example_Unload Called rn");
RtlInitUnicodeString(&usDosDeviceName, L"DosDevicesExample");
IoDeleteSymbolicLink(&usDosDeviceName);
IoDeleteDevice(DriverObject->DeviceObject);
}
Previously, we instructed the DriverUnload member of the DRIVER_OBJECT structure to point to the Example_Unload function:
[cpp]
pDriverObject->DriverUnload = Example_Unload;
We did this, because we wanted the system to be able to automatically unload the driver, so we don't have to do it manually. Now it becomes clear that Example_Unload is actually a function, which does something. We can see that the instructions in the Example_Unload routine are to first create the Unicode string "DosDevicesExample" and then the symbolic link in the DriverEntry function, as well as to delete the device.
Usually, the DriverUnload function is used to clean up after the driver before it is unloaded from the kernel.
Conclusion
In this tutorial, we've taken a look at a basic kernel driver. We've seen that each kernel driver must contain a DriverEntry function so that the operating system will be able to load the driver automatically. The driver's DriverEntry function must set the entry points in the driver's object, which declare the IRP_MJ_XXX requests the driver will be able to handle. The driver's DriverEntry function must also set the DriverUnload member in the DRIVER_OBJECT, so the system can unload the driver automatically.
References:
[1] Kernel Mode Driver Tutorial: Part I: The Skeleton KMD, accessible at http://www.reverse-engineering.info/SystemCoding/SkeletonKMD_Tutorial.htm.
[2] Creating a New Software Driver, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/hh454833(v=vs.85).aspx.
[3] Driver Development Part 1: Introduction to Drivers, accessible at http://www.codeproject.com/Articles/9504/Driver-Development-Part-1-Introduction-to-Drivers.
[4] What is IRQL?, accessible at http://blogs.msdn.com/b/doronh/archive/2010/02/02/what-is-irql.aspx.
[5] I/O request packet, accessible at http://en.wikipedia.org/wiki/I/O_request_packet.
[6] IRP, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff550694(v=vs.85).aspx.
[7] DriverEntry routine, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff544113(v=vs.85).aspx.
[8] DRIVER_OBJECT, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff544174(v=vs.85).aspx.
[9] Don Burn, Getting Started with the Windows Driver Development Environment, Microsoft MVP, Windows Driver Kit, Windows Filesystem and Driver Consulting – windrvr.com.
[10] TARGETTYPE, accessible at http://msdn.microsoft.com/en-us/library/ff552920.aspx.
[11] using WDK/DDK build environment for drivers and non-drivers, accessible at http://randomlearningnotes.wordpress.com/2009/04/20/using-wdkddk-build-environment-for-drivers-and-non-drivers/.
[12] IoCreateDevice routine, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff548397(v=vs.85).aspx.
[13] IRP Major Function Codes, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff550710(v=vs.85).aspx.
[14] struct DRIVER_OBJECT, accessible at http://www.nirsoft.net/kernel_struct/vista/DRIVER_OBJECT.html.
[15] DEVICE_OBJECT structure, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff543147(v=vs.85).aspx.
Become a certified reverse engineer!
[16] IoCreateSymbolicLink routine, accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff549043(v=vs.85).aspx.