Reverse engineering

Writing windows kernel mode driver [Updated 2019]

Dejan Lukan
August 31, 2019 by
Dejan Lukan

In this tutorial, we're going to use the Windows Driver Mode (WDM) which provides us greater flexibility than other modes while being harder to use. We'll take a look at how to create our first kernel mode driver for the Windows operating system.

Become a certified reverse engineer!

Become a certified reverse engineer!

Get live, hands-on malware analysis training from anywhere, and become a Certified Reverse Engineering Analyst.

We know that Windows works with PE executables because it knows how to execute them. But how does an operating system know that? To understand that, we have to talk about a subsystem, which is used together with the PE header to load the executable and run it. Let's take a look at different subsystems in the Visual Studio project's properties:

Notice that there are multiple subsystems, which are specified below:

  • CONSOLE
  • WINDOWS
  • NATIVE
  • EFI_APPLICATION
  • EFI_BOOK_SERVICE_DRIVER
  • EFI_ROM
  • EFI_RUNTIME_DRIVER
  • WINDOWSCE
  • POSIX

If we're using a console application, then we're using a CONSOLE subsystem and our program should have the main function. Also, when using a CONSOLE subsystem, Windows will automatically create a console window for the program to use. But if we're using a GUI program, then we're using the WINDOWS subsystem and our program should implement the WinMain function. When using the WINDOWS subsystem, Windows won't create the console window, because the program creates its own window for user interaction. Remember that the /SUBSYSTEM option is used to tell the operating system how to run the executable file.

Because we'll be programming a kernel driver, we have to use the NATIVE subsystem. When using NATIVE subsystem, we must implement the NtProcessStartup function, in the same way that we have to implement the main function when the CONSOLE subsystem is in use.

The best practice when developing a Windows kernel driver is to use the DriverEntry entry function. But since the NtProcessStartup is the default, we need to change that by passing the "-entry:DriverEntry" to the linker. Since DLLs are compiled by using the WINDOWS subsystem, we also have to use the /DRIVER:WDM, which uses NATIVE subsystem instead of WINDOWS, which is what we need. The ending executable can be loaded in various ways, such as loading an exe with a loader, a DLL with a LoadLibrary function call, etc. Since we're programming a driver, we must load it appropriately.

The kernel mode driver consists of three functions:

  • DriverEntry: initialization code that is run after the driver is loaded, which usually happens when certain service is started.
  • DriverDispatcher handles messages sent to the driver and is usually used to serve messages from the user mode applications that request some action to be done in kernel mode.
  • DriverUnload: deinitialization code that cleans after the driver when it's no longer needed, which usually happens when a certain service is stopped.

Interrupt ReQuest Level (IRQL)

Every computer today uses interrupts to stop a processor to make it do something else. There are various kinds of interrupts that can do this. Sometimes we would like to disable some of the interrupts for a certain amount of time. The interrupts can be disabled through the IF (Interrupt Flag) bit in the EFLAGS register. If the IF bit is set to 1, the maskable interrupts will be handled by the system, otherwise they will be ignored. The IF flag doesn't affect the non-maskable interrupts, software interrupts or exceptions: they are all still handled by the system. The IF flag can be enabled with the sti instruction and it can be disabled by the cli instruction.

Because of the limitations mentioned above, the IRQL was introduced. The IRQL gives us a way to arbitrarily disable the interrupts in the system. Interrupts are sometimes called Interrupt ReQuests (IRQ) and their priority is a level (IRQL) [4]. The picture below presents the IRQL as defined in the Windows NT (picture taken from [4]):

From the picture above, we can see that the code of the user thread will be executed with IRQL PASSIVE_LEVEL. When a processor is executing code in a particular IRQL level, that code can be interrupted only by those with higher IRQL levels on the same processor. Interrupts with smaller IRQL levels are temporarily disabled. Because each processor can execute its own code, each processor also has its own temporary IRQL level that it's currently executing. When writing a kernel driver, we need to be aware of the following IRQL levels:

  • PASSIVE_LEVEL: lowest IRQL where no interrupts are disabled
  • APC_LEVEL: APC level interrupts are masked
  • DISPATCH_LEVEL: DPC level interrupts and lower are masked
  • DIRQL: all interrupts at this level or lower are masked

The MSDN documentation specifies for each function at which IRQL level we need to be running in order to be able to execute that function. If we're currently executing at high IRQL level, we won't be able to execute some functions that are normally available with lower IRQL level. Let's take a look at the NtOpenFile function accessible at http://msdn.microsoft.com/en-us/library/bb432381(v=vs.85).aspx. At the bottom of the documentation we can find a note that the callers of ZwCreateFile must be running at IRQL = PASSIVE_LEVEL, which can also be seen on the picture below:

However, the IrqlKeWaitForMultipleObjects accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/hh127618(v=vs.85).aspx specifies that callers of specific routines must be running at IRQL APC_LEVEL or DISPATCH_LEVEL as can be seen on the picture below:

Since we're writing a kernel driver, we're particularly interested in the DriverEntry routine accessible at http://msdn.microsoft.com/en-us/library/windows/hardware/ff544113(v=vs.85).aspx, which specifies that routine must be called at PASSIVE_LEVEL as seen on the picture below:

I/O Request Packet (IRP)

The IRP packet is a kernel structure that is used by Windows Driver Model (WDM) to communicate with each other and with the operating system. The IRP data structure is used to describe I/O requests. We're not passing arguments down the stack of drivers; rather, we're passing a single pointer to the IRP data structure to each driver. The IRP pointer can be put into the I/O queue if it can't be handled immediately [5]. When the IRP request has been processed, the driver must report back to the I/O manager by calling the IoCompleteRequest function.

The IRP request packets are a way to tell a particular driver that it has to do something. Every IRP contains all of the information needed for any driver to be able to process a request and return the result. Let's take a look at the IRP data structure format, which is presented at [6]:

We can see that the IRP data structure is very complicated, so we won't be describing it in detail. If you want to know more, consult the MSDN documentation.

The DriverEntry routine

We already mentioned that whenever we write a Windows kernel driver, we have to implement the DriverEntry function, which has the following syntax (picture taken from [7]):

The DriverObject is a pointer to the DRIVER_OBJECT structure, while the RegistryPath is a pointer to the path in the registry that stores the information about the driver. The function must return STATUS_SUCCESS if it succeeds, otherwise it must return one of the error messages. The DriverObject has the following members accessible to drivers [7]:

  • PDEVICE_OBJECT DeviceObject: pointer to device object created by the driver. This field is updated upon the successful IoCreateDevice function call.
  • PDRIVER_EXTENSION DriverExtension: pointer to the driver extension, which has only the DriverExtension->AddDevice member accessible.
  • PUNICODE_STRING HardwareDatabase: pointer to the RegistryMachineHardware path, which stores hardware configuration information in registry.

  • PFAST_IO_DISPATCH FastIoDispatch: pointer to a structure that defines the driver's I/O entry points.
  • PDRIVER_INITIALIZE DriverInit: entry point for the DriverEntry routine, which is set by the I/O manager.
  • PDRIVER_STARTIO DriverStartIo: entry point for the StartIo routine, which is set by the DriverEntry routine.
  • PDRIVER_UNLOAD DriverUnload: entry point for the Unload routine, which is set by the DriverEntry routine.
  • PDRIVER_DISPATCH MajorFunction: dispatch table consisting of an array of entry points for the driver's Dispatch* routines.

The first thing we must do is create the device. Normally, the kernel driver is associated with a hardware component, but this won't be the case here, since we only want to write a simple kernel driver. Newer versions of Windows use the Plug and Play driver model, which supports dynamic adoption to addition/removal of hardware components which are instantly detected. The major benefit of the plug and play model is that the operating system automatically contacts the driver about the presence of the hardware device.

We can choose between different kinds of drivers, but all in all, we must be aware of the fact that there are multiple drivers on the stack handling the IRP requests. We must also be aware of the fact that almost every hardware component needs some kind of driver, like network card driver, file system driver, printer driver, etc. Every component has multiple drivers on the stack and the IRP request is split into multiple simpler requests at each element in the stack. The first driver on the stack is the one communicating with the user in user-mode, and the last driver on the stack is the one communicating with the hardware component.

Let's talk a little bit about different types of drivers based on their roles in a driver stack. We can classify drivers into the following types [9]:

  • Function Driver: manages communication with the hardware device and provides an interface to other drives on the stack.
  • Filter Driver: provides a special service in the behavior of standard drivers (like encryption/decryption).
  • Bus Driver: enumerates devices on the bus and provides access to it.

Device drivers can also be classified into the following categories [9]:

  • Windows Driver Foundation (WDF): the new driver model that is easier to use than the old driver model WDM and has two implementations, the KMDF (in kernel mode) and UMDF (in user mode).
  • Windows Driver Model (WDM): the old driver model, which doesn't abstract anything from the developer.
  • Legacy Driver: the same as WDM, but without support for P&P and power management.
  • File System Driver: provides access to the files on the device.
  • Storage Miniport Driver: drivers for SCSI and ATA.
  • Network Driver
  • Printer Driver
  • Graphics Driver
  • Kernel Streaming Driver

Driver development environment

If we would like to program a Windows kernel driver, which we probably would, if we're reading this guide, we need to install the WDK build environment. To do that, we can download the WDK from the URL http://msdn.microsoft.com/en-us/library/windows/hardware/gg487428.aspx, where we can choose between WDK 8 and WDK 7.1.0. The WDK version 7.1.0 supports the following operating systems: Windows 7, Windows Vista, Windows XP, Windows Server 2008 R2, Windows Server 2008, and Windows Server 2003, while the WDK version 8 supports only the following operating systems: Windows 8, Windows Server 2012, Windows 7, Windows Vista SP1 and Windows Server 2008.

In our case I downloaded the WDK version 7.1.0, which can be installed on the Windows XP system. The WDK environment can then be run from the Start Menu, by selecting the build environment as seen on the picture below:

When we click on the "x86 Checked Build Environment," the following command prompt will open:

The WDK provides us with the whole build environment needed to develop kernel drivers, like a compiler, linker, documentation, WinDbg debugger, etc. We can search around in the C:WinDDK7600.16385.1 directory to find all the stuff that's available.

When developing a driver, it's often the case that the driver doesn't work on the first try, so we will crash our whole system. In the best case scenario, only a reboot is required, but in the worst case scenario, we can cripple our system so that it won't boot anymore. This is the reason why we need to have at least two Windows operating systems available. In the first one, we're developing the code which we're applying to the second system. If a crash occurs, we won't lose any data or code, since it's the second system that crashed. Nowadays we can use virtual machines to develop a driver, but at the end of the development and testing phase, we should also try the driver on a real non-virtualized machine just to check if everything is working.

Become a certified reverse engineer!

Become a certified reverse engineer!

Get live, hands-on malware analysis training from anywhere, and become a Certified Reverse Engineering Analyst.

Sources

Dejan Lukan
Dejan Lukan

Dejan Lukan is a security researcher for InfoSec Institute and penetration tester from Slovenia. He is very interested in finding new bugs in real world software products with source code analysis, fuzzing and reverse engineering. He also has a great passion for developing his own simple scripts for security related problems and learning about new hacking techniques. He knows a great deal about programming languages, as he can write in couple of dozen of them. His passion is also Antivirus bypassing techniques, malware research and operating systems, mainly Linux, Windows and BSD. He also has his own blog available here: http://www.proteansec.com/.