Introduction

Attacks against Windows kernel mode software drivers, especially those published by third parties, have been popular with many threat groups for a number of years. Popular and well-documented examples of these vulnerabilities are the CAPCOM.sys arbitrary function execution, Win32k.sys local privilege escalation, and the EternalBlue pool corruption. Exploiting drivers offers interesting new perspectives not available to us in user mode, both through traditional exploit primitives and abusing legitimate driver functionalities.

As Windows security continues to evolve, exploits in kernel mode drivers will become more important to our offensive tradecraft. To aid in the research of these vulnerabilities, I felt it was important to demonstrate the kernel bug hunting methodology I have employed in my research to find interesting and abusable functionality.

In this post, I’ll first cover the most important pieces of prerequisite knowledge required to understand how drivers work and then we’ll jump into the disassembler to walk through finding the potentially vulnerable internal functions.

Note: This will include gross oversimplifications of complex topics. I will include links along the way to additional resources, but a full post on driver development and internals would be far too long and not immediately relevant.

Target Identification & Selection

The first thing I typically look for on an engagement is what drivers are loaded on the base workstation and server images. If a bug is found in these core drivers, most of the fleet will be affected. This also has the added bonus of not requiring a new driver to be dropped and loaded which could tip off defenders. To do this, I will either manually review drivers in the registry ( HKLM\System\ControlSet\Services\ where Type is 0x1 and ImagePath contains *.sys )or use tooling like DriverQuery to run through C2.

Target selection is a mixed bag because there isn’t one specific type of driver that is more vulnerable than others. That being said, I will typically look at drivers published by security vendors, anything published by the motherboard manufacturer, and performance monitoring software. I tend to exclude drivers published by Microsoft only because I usually don’t have the time required to really dig in.

Driver Internals Primer

Kernel mode software drivers seem far more complex than they truly are if you haven’t developed one before. There are 3 important concepts that you must first understand before we start reversing — DriverEntry, IRP handlers, and IOCTLs.

DriverEntry

Much like the main() function that you may be familiar with in C/C++ programming, a driver must specify an entry point, DriverEntry .

DriverEntry has many responsibilities, such as creating the device object and symbolic link used for communication with the driver and definitions of key functions (IRP handlers, unload functions, callback routines, etc.).

DriverEntry first creates the device object with a call to IoCreateDevice() or IoCreateDeviceSecure() , the latter typically being used to apply a security descriptor to the device object in order to restrict access to only local administrators and NT AUTHORITY\SYSTEM .

Next, DriverEntry uses IoCreateSymbolicLink() with the previously created device object to set up a symbolic link which will allow for user mode processes to communicate with the driver.

Here’s how this looks in code:

The last thing that DriverEntry does is defines the functions for IRP handlers.

IRP Handlers

Interrupt Request Packets (IRPs) are essentially just an instruction for the driver. These packets allow the driver to act on the specific major function by providing the relevant information required by the function. There are many major function codes but the most common ones are IRP_MJ_CREATE , IRP_MJ_CLOSE , and IRP_MJ_DEVICE_CONTROL . These correlate with user mode functions:

IRP_MJ_CREATE → CreateFile

→ IRP_MJ_CLOSE → CloseFile

→ IRP_MJ_DEVICE_CONTROL → DeviceIoControl

Definitions in DriverEntry may look like this:

DriverObject->MajorFunction[IRP_MJ_CREATE] = MyCreateCloseFunction;

DriverObject->MajorFunction[IRP_MJ_CLOSE] = MyCreateCloseFunction;

DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyDeviceControlFunction;

When the following code in user mode is executed, the driver will receive an IRP with the major function code IRP_MJ_CREATE and will execute the MyCreateCloseFunction function:

hDevice = CreateFile(L"\\\\.\\MyDevice", GENERIC_WRITE|GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

The most important major function for us in almost all cases will be IRP_MJ_DEVICE_CONTROL as it is used to send requests to perform a specific internal function from user mode. These requests include an IO Control Code which tells the driver exactly what to do, as well as a buffer to send data to and receive data from the driver.

IOCTLs

IO Control Codes (IOCTLs) are our primary search target as they include numerous important details we need to know. They are represented as DWORDs but each of the 32 bits represent a detail about the request — Device Type, Required Access, Function Code, and Transfer Type. Microsoft created a visual diagram to break these fields down:

Transfer Type - Defines the way that data will be passed to the driver. These can either be METHOD_BUFFERED , METHOD_IN_DIRECT , METHOD_OUT_DIRECT , or METHOD_NEITHER .

- Defines the way that data will be passed to the driver. These can either be , , , or . Function Code - The internal function to be executed by the driver. These are supposed to start at 0x800 but you will see many starting at 0x0 in practice. The Custom bit is used for vendor-assigned values.

- The internal function to be executed by the driver. These are supposed to start at 0x800 but you will see many starting at 0x0 in practice. The Custom bit is used for vendor-assigned values. Device Type - The type of the driver’s device object specified during IoCreateDevice(Secure)(). There are many device types defined in Wdm.h and Ntddk.h, but one of the most common to see for software drivers is FILE_DEVICE_UNKNOWN (0x22) . The Common bit is used for vendor-assigned values.

An example of what these are defined as in the driver’s headers is:

#define MYDRIVER_IOCTL_DOSOMETHING CTL_CODE(0x8000, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

It is entirely possible to decode these values yourself, but if you’re feeling lazy like I often am, OSR has their online decoder and the !ioctldecode Windbg extension has worked great for me in a pinch. These specifics will become more important when we write the application to interface with the target driver. In the disassembler, they will still be represented in hex.

Putting it all Together

I know it’s like drinking from the firehose, but you can simplify it and think about it like sending a network packet. You craft the packet with whatever details you need, send it to the server for processing, it does something with it or ignores you, and then gives you back something. Here’s an oversimplified diagram of how IOCTLs are sent and processed:

User mode application gets a handle on the symlink. User mode application uses DeviceIoControl() to send the required IOCTL and input/output buffers to the symlink. The symlink points to the driver’s device object and allows the driver to receive the user mode application’s packet (IRP) The driver sees that the packet came from DeviceIoControl() so it passes it to the defined internal function, MyCtlFunction() . The MyCtlFunction() maps the function code, 0x800 , to the internal function SomeFunction() . SomeFunction() executes. The IRP is completed and the status is passed back to the user along with anything the driver has for the user in the output buffer supplied by the user mode application.

Note: I didn’t talk about IRP completion, but just know that those can/will happen once SomeFunction() and will include the status code returned by the function and will mark the end of the action.

Disassembling the Driver

Now that we understand the key structures we’re going to be looking for, it’s time to start digging into the target driver. I’ll be showing how to do this in Ghidra as I’m more comfortable in it, but the exact same methodology works in IDA.

Once we have the driver that we’d like to target downloaded on our analysis system, it’s time to start looking for the IRP handlers that will point us to the potentially interesting functions.

The Setup

Since Ghidra doesn’t include many of the symbols we need for analyzing drivers at the time of writing this post (although it once did 🤔), we’ll need to find a way to get those imported somehow. Thankfully, this process is relatively simple thanks to some great work done by 0x6d696368 (Mich).

Ghidra supports datatypes in the Ghidra Data Type Archive (GDT) format, which are packed binary files containing symbols derived from the chosen headers, whether those be custom or Microsoft-supplied. There isn’t any great documentation about generating these and it does require some manual tinkering, but thankfully Mich took care of all of that for us.

On their GitHub project is a precompiled GDT for Ntddk.h and Wdm.h, ntddk_64.gdt. Download that file on the system you’re going to run Ghidra on.

To import and begin using the GDT file, open the driver you want to start analyzing, click the Down Arrow (▼) in the Data Type Manager and select “Open File Archive.”

Then select the ntddk_64.gdt file you downloaded earlier and open it.

In your Data Type Manager window, you’ll now have a new item, “ntddk_64.” Right-click on it and choose “Apply Function Data Types.” This will update the decompiler and you’ll see a change in many of the function signatures.

Finding DriverEntry

Now that’ we’ve got our datatypes sorted, we next need to identify the driver object. This is trivial to find as it is the first parameter in DriverEntry . First, open the driver in Ghidra and do the initial auto analysis. Under the Symbol Tree window, expand the Exports item and there will be a function called entry .

Note: There may be a GsDriverEntry function in some cases that will look like a call to 2 unnamed functions. This is a result of the developer using the /GS compiler flags and sets up the stack cookies. One of the functions is the real driver entry, so check either for the longer of the 2.

Finding the IRP Handlers

The first thing we are going to need to look for are a series of offsets from the driver object. These are related to the attributes of the nt!_DRIVER_OBJECT structure. The one that we are most interested in is the MajorFunction table ( +0x70 ).

This becomes a lot easier with our newly-applied symbols. Since we know that the first parameter of DriverEntry is a pointer to a driver object, we can click the parameter in the decompiler and press CTRL+L to bring up the Data Type Chooser. Search for PDRIVER_OBJECT and click OK. This will change the type of the parameter to match its true type.

Note: I like to change the name of the parameter to DriverObject to help me while walking the function.To do this yourself, click the parameter, press “L”, and type in the name you want to use.

Now that we have the appropriate type, it’s time to start looking for the offset to the MajorFunction table. Sometimes you may see this right in the DriverEntry function, but other times you’ll see the driver object being passed as a parameter to another internal function.

Start looking for occurrences of the DriverObject variable. This is really easy if you have a mouse. Just click the mouse wheel over the variable to highlight all instances of the variable in the decompiler. In the example I am working with, I don’t see references to offsets from the driver object, but I do see it being passed to another function.

Jump into this function, FUN_00011060 , and retype the first parameter to a PDRIVER_OBJECT since we know that’s what it DriverEntry shows as its only parameter.

Then again start searching for references to offsets from the DriverObject variable. Here’s what we’re looking for:

In vanilla Ghidra, we’d see these as less detailed offsets from the DriverObject but since we applied the NTDDK datatypes, its a lot cleaner. So now that we’ve found the offsets from DriverObject marking the MajorFunction table, what are at the indexes ( 0 , 2 , 0xe )? These offsets are defined in the WDM headers (wdm.h) and represent the IRP major function codes.

In our example the driver handles 3 major function codes — IRP_MJ_CREATE , IRP_MJ_CLOSE , and IRP_MJ_DEVICE_CONTROL . The first 2 aren’t really of interest to us, but IRP_MJ_DEVICE_CONTROL is very important. This is because the function defined at that offset ( 0x104bc ) is what processes requests made from usermode using DeviceIoControl and its included I/O Control Codes (IOCTLs).

Let’s dig into this function. Double-click the offset for MajorFunction[0xe] . This will take you to the function at offset 0x104bc in the driver. The second parameter of this function, and all device I/O control IRP handlers, is a pointer to an IRP. We can again use the CTRL+L to retype the second parameter to PIRP (and optionally rename it).

The IRP structure is incredibly complex and even with the help of our new type definitions, we still won’t be able to pinpoint everything. The first and most important thing we are going to want to look for is IOCTLs. These will be represented as DWORDs inside the decompiler, but we need to know which variable they’re assigned to. To figure that out, we’ll need to rely on our old friend WinDbg.

The first offset from our IRP that we can see is IRP->Tail + 0x40 .

Let’s dig into the IRP structure a bit.

We can see that Tail begins at offset +0x78 but what is 0x40 bytes beyond that? Using WinDbg, we can see that CurrentStackLocation is at offset +0x40 from Irp->Tail , but it only shows as a pointer.

Microsoft gives us a hint that this is a pointer to a _IO_STACK_LOCATION structure. So in our decompiler, we can rename lVar2 to CurrentStackLocation .

Following this new variable, we want to find a reference to offset +0x18 , which is the IOCTL.

Rename this variable to something memorable if you’d like.

Now that we’ve found the variable holding the IOCTL, we should be able to see it being compared to a whole bunch of DWORDs.

These comparisons are the driver checking for the IOCTLs that it can handle. After each comparison will most likely be an internal function call. These are what will be executed when that specific IOCTL is sent to the driver from user mode! In the above example, when the driver receives IOCTL 0x8000204c , it will execute FUN_0000944c (some type of printing function) and FUN_000100d0 .

The Short Story

That was a large amount of information, but in practice it is very simple. My workflow is:

Follow the first parameter of DriverEntry , the driver object, until I find offsets indicating the MajorFunction table. Look for an offset at MajorFunction[0xe] , marking the DeviceIoControl IRP handler. Follow the second parameter of this function, PIRP , until I find PIRP->Tail +0x40 , marking the CurrentStackLocation . Find offset +0x18 from CurrentStackLocation , which will be the IOCTLs

In a lot of cases, I will just skip steps 3 and 4 and just look through the decompiler for a long chain of DWORD comparisons. If I’m really feeling lazy, I’ll look for calls to IofCompleteRequest and just scroll up from the calls looking for the DWORD comparisons 🤐

Function Reversing

Now that we know which functions will execute internally when the driver receives an IOCTL, we can begin reversing those functions to find interesting functionalities. Because this differs so much between drivers, there’s no real sense in covering this process (there’s also books written about this stuff).

My typical workflow at this point is to look for interesting API calls inside of these functions, determine what they require for input, and then use a simple user mode client (I use a generic template that I copy and modify depending on the target) to send the IRPs. When analyzing EDR drivers, I also like to look through the capabilities they’ve baked in, such as process object handler callbacks. There are some great driver bug walkthroughs that can help spark some ideas (this is one of my favorites).

The one important thing of note, especially when working with Ghidra, is this variable declaration:

If you were to look at this in WinDbg, you’d see that at this offset is a pointer to MasterIrp .

What you’re actually seeing is a union with IRP->SystemBuffer and this variable is actually the METHOD_BUFFERED data structure. This is why you’ll see this often times being passed into internal functions as parameters. Make sure to treat this as an input/output buffer while reversing internal functions.

Good luck and happy hunting 😈