blank Skip to main content

Writing a Windows Driver Model Driver: A Step-by-Step Guide

While applications with user-friendly interfaces run in user mode, some protected data can only be accessed in kernel mode. To securely and efficiently work with user data, applications rely on software drivers that process user mode requests and deliver results back to the application.

In this article, we provide a practical example of writing a Windows Driver Model (WDM) driver for encrypting a virtual disk that stores user data. Most of the steps described will also work for other types of drivers. This article will be useful for development teams and project leaders who are considering developing a custom software driver.

Contents:

What do you need to build a Windows software driver?

Encryption disk architecture

Building a WDM driver

     Creating a device object

     Registering callback methods

     Implementing callback methods

     Implementing read/write operations for a virtual disk

     Mounting the disk

     Unmounting the disk

Encrypting the disk

Running a demo

Debugging the disk

Conclusion

What do you need to build a Windows software driver?

In the article How to Develop a Virtual Disk Driver for Windows: A No-Nonsense Guide, we discuss in detail how to build a basic virtual disk driver for Windows. This time, even though we’re once again working with a virtual disk driver, we are building a software driver for Windows that won’t directly interact with any physical devices or hardware. The driver is part of a project written in C, so we also cover some language-related limitations.

In this guide, we show you how to develop a Windows driver for creating an encrypted virtual disk — a file that operates similarly to a physical disk. You’ll be able to see the virtual disk in File Explorer and perform operations in it such as creating, saving, and deleting data. The driver’s key task is to process requests received from the operating system and send them to the virtual disk instead of a real device.

To follow along with our driver development guide, you’ll need the following tools:

  1. Visual Studio (we used Visual Studio 2019)
  2. Windows Software Development Kit (SDK) (for versions older than Visual Studio 2017; we used SDK version 10.0.19041.0.)
  3. Windows Driver Kit (WDK) (we used WDK version 10.0.19041.685)
  4. VMWare (to create a virtual machine for driver debugging)

Note: Starting with Visual Studio 2017, Windows SDK is installed automatically when you select the Desktop development with C++ option when installing Visual Studio. WDK, however, still must be downloaded separately.

If WDK was installed properly, you’ll see driver project templates in the dialog window for creating new Visual Studio projects:

Screenshot 1: Visual Studio 2019 interface for creating driver projects

Working with WDK

Note that your approach to working with WDK depends on the version(s) of Windows you want your driver to be compatible with.

Before Visual Studio 2012 (WDK 8.0), Windows Driver Kit was a standalone solution that had its own compiler and other tools needed for developing a Windows driver. Driver assembly used to consist of two main steps:

  1. Call the setenv.bat script to set build options
  2. Call the build.exe utility from WDK

To build a basic driver for Windows, you needed two files:

1. The Makefile file, containing a directive to include the final makefile from the WDK. This directive was always standard:

!include $(NTMAKEENV)\makefile.def

2. The Sources file, containing build options and a list of driver sources.

WDK is currently integrated into Visual Studio via an extension that gets installed automatically. Therefore, you no longer need to work with Sources and Makefile, as you can select the project type, WDK version, and libraries to link to your driver in project settings.

When you use older versions of WDK to build drivers supporting Windows OS, your driver should also be able to support newer versions of Windows. However, to enable support for older Windows versions, you might need to use older versions of WDK when building your driver.

For example, to build a driver supporting Windows XP, you’ll need to use WDK version 7.1.0 (7600.16385.1) or older. This driver will also work under Windows versions 7, 8, and 10. At the same time, with WDK version 10.0.19041.685 (used in our guide), you can build a driver that supports Windows 7 or later, but it won’t work properly on Windows XP.

To learn more about writing drivers for different Windows versions, read Microsoft’s recommendations.

Overall, if you’re working on a new driver that doesn’t need to support older versions of Windows, it’s best to use the latest WDK version and create the project directly in Visual Studio.

With that in mind, let’s move to the actual process of Windows driver development.

Related services

Kernel and Driver Development

Preparing to develop a Windows driver

Microsoft explains in detail the nature and purpose of drivers in Windows. Below, we summarize the core information you need to know to follow our guide.

First, let’s outline what a driver is and what its tasks are. A driver is a portable executable (PE) file that has a .sys extension. The format of this file is similar to the format of any .exe file, but the driver file links to the kernel (ntoskrnl.exe) and other system drivers (although this capability won’t be used in our example):

Screenshot 2: List of a driver’s linked modules with imported APIs

Drivers also have a different entry point — the GsDriverEntry function and the /SUBSYSTEM – NATIVE parameter.

Usually, drivers are written in C. However, as Microsoft doesn’t provide things like a C runtime for the kernel or a standard C++ library, you can’t use common C++ features like new/delete, C++ exceptions, or global class object initialization in the kernel. You can still write your code in a .cpp file and state a class, and the compiler will understand it; but full-scale support for C++ is absent.

Also, Microsoft recommends not using standard C++ functions like memcpy and strcpy for kernel driver development, even though they can be exported by the kernel. Instead, they recommend using safe implementations designed specifically for the kernel, such as the RtlCopyMemory and RtlCopyString functions.

To work with a project that relies on C, you can use a C++ runtime (cppLib) and an STL library (STLport) ported to the kernel. To simplify our example, we don’t use either of those and work with almost pure C.

To make it easier to write drivers, Microsoft created the Windows Driver Framework (WDF). This framework can help you significantly shorten the code for interacting with Plug and Play (PnP) devices and power management.

WDF is based on the Windows Driver Model. The choice of a driver model depends on many factors, including the type of driver under development.

With WDF being more suitable for building device drivers, in this article, we build our software driver with WDM.

Read also:
How to Develop a Windows Driver Using a QEMU Virtual Device

Encryption disk architecture

To build a properly functioning WDM driver, we need to create two components:

  • wdmDrv.sys — a driver that implements a virtual disk in Windows
  • wdmDrvTest.exe — a user application that manages the driver

First, we need to mount our disk so it appears in the system and is accessible from File Explorer. To do this, we need a separate user application — wdmDrvTest.exe — that will send commands to the driver to mount and unmount the disk. We can also use the Windows registry to specify mount options for the disk (disk letter, path to the virtual disk file, disk size, etc.) and read those options when the driver is loaded into the system.

Then, for reasons of data security, we need to make sure the data is encrypted before it’s written to the disk and decrypted only after it’s read from the disk. As a result, data stored on the disk will always be encrypted.

Our driver is responsible for performing read/write operations on our virtual disk. The WdmDrvTest.exe application is responsible for encrypting and decrypting data. This application can also read and write data to and from the file where our virtual disk is stored. Therefore, our driver will delegate the processing of read/write operations to wdmDrvTest.exe.

Read also:
Driver Matching with I/O Kit: Building a USB Device Filter Driver

Building a WDM driver

Any Windows driver starts with the DriverEntry function, which is called by the Windows operating system when the driver is loaded:
NTSTATUS DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath)

In our case, this function is responsible for:

  1. Creating a device object that will receive control commands.
  1. Registering callback methods that Windows calls for the created device objects. For example, in the case of a request to read data from the disk, the system will call a read callback in our driver.
  1. Registering the DriverUnload method, which Windows calls to unload the driver and free the allocated resources.

Basically, the DriverEntry function works similarly to the Main function in user applications.

However, while the Main function executes a certain algorithm and then returns control to the operating system, a driver remains loaded in memory and waits for Windows to call the appropriate handler in response to a new event even after execution of the DriverEntry function.

The following diagram shows the execution of a process in user mode and the work of a driver as part of a system process:

Here, the input/output (I/O) manager should be seen as a set of functions called to process I/O events rather than a separate module in the system.

Read also:
Controlling and Monitoring a Network with User Mode and Driver Mode Techniques: Overview, Pros and Cons, WFP Implementation

Creating a device object

To create a device object, use the IoCreateDevice and IoCreateDeviceSecure functions:

NTSTATUS IoCreateDeviceSecure(
    [in]             PDRIVER_OBJECT     DriverObject,
    [in]             ULONG              DeviceExtensionSize,
    [in, optional]   PUNICODE_STRING    DeviceName,
    [in]             DEVICE_TYPE        DeviceType,
    [in]             ULONG              DeviceCharacteristics,
    [in]             BOOLEAN            Exclusive,
    [in]             PCUNICODE_STRING   DefaultSDDLString,
    [in, optional]   LPCGUID            DeviceClassGuid,
    [out]            PDEVICE_OBJECT     *DeviceObject
);

IoCreateDeviceSecure allows us to additionally specify the parameters for the DefaultSDDLString security descriptor. For example, we can specify which user groups can write to the device. Starting from Windows 7, this parameter must be used to, for instance, successfully format a disk.

The DeviceName parameter is the name of the device, which might look like \Device\MyDevice. This parameter is created in the Device directory, which is a standard directory where all devices in the system are listed. To view this directory, we can use the winobj utility. Here is our virtual disk in the Device directory:

Screenshot 3: Our virtual disk in the Device directory opened with the winobj utility

However, the devices in this directory can’t be accessed from user mode. Therefore, if we try to open a device by calling the CreateFile function, we’ll get the INVALID_HANDLE_VALUE value with the ERROR_FILE_NOT_FOUND error.

To make a device accessible from user mode, we need to create a symbolic link to it in the GLOBAL?? directory. To do that in the kernel, we call the IoCreateSymbolicLink function:

Screenshot 4: A symbolic link to our device object in the GLOBAL?? directory

Now, using the name of our device, any user application can open the device object and send requests to it:

CreateFileW(L"\\.\CoreMnt", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);

To learn more on the topic of naming device objects, you can read the guidelines from Microsoft or the Naming Devices section in the book Programming the Microsoft Windows Driver Model by Wolter Oney.

Continuing with our guide, we need to create two devices:

  1. A control device for interacting with the user application: particularly, for performing mount/unmount commands on our disk. This device is created in DriverEntry.
  1. A disk device for receiving and processing I/O operations. The control device creates a disk device when it receives the disk mount command.

Now, let’s look closer at the rest of the IoCreateDeviceSecure parameters:

DriverObject is a structure created by Windows and passed to the DriverEntry function. This structure contains a list of all device objects created for the driver.

Here’s how we can find a DriverObject by the driver name and see the entire structure using WinDbg (see the Debugging the disk section below) and the !drvobj command:

Screenshot 5: The DriverObject structure view

DeviceExtensionSize is the parameter defining the size of the structure that we want to store in the created device object. This parameter is specified in the DeviceExtension field.

Windows will allocate memory for this structure and save the pointer to it in the created device object. This structure is where we will store everything we need to process requests: the I/O request queue, disk size, etc. In terms of C++ classes, this structure can be seen as the device object’s data members.

DeviceType is one of the numerical values ​​defined in wdm.h, such as FILE_DEVICE_DISK, FILE_DEVICE_PRINTER, and FILE_DEVICE_UNKNOWN. Based on this parameter, the system assigns a default security descriptor to the device object of the virtual disk.

DeviceCharacteristics defines additional device characteristics such as FILE_READ_ONLY_DEVICE. In our case, we pass 0 as the value of this parameter.

Exclusive is a parameter that prohibits the opening of more than one device handler (HANDLE). In our case, we set the value of this parameter to FALSE.

DeviceObject is the address of the created device object.

The next important step in creating a WDM driver is registering its callback methods. Let’s take a look at this process.

Registering callback methods

Windows uses the I/O request packet (IRP) structure to handle various I/O events. For solutions where we have two different drivers interacting with each other, we can create and send IRPs to the second driver ourselves. But in our case, the operating system is responsible for both creating this structure and passing it to the driver.

Let’s see how an I/O event is usually handled, using the ReadFile function as an example. When the ReadFile function is called, this is what happens in the user application:

1. The ReadFile function calls the ntdll!NtReadFile function, which switches to kernel mode and calls the corresponding nt!NtReadFile function in the kernel (ntoskrnl.exe).

2. Using the passed HANDLE for the nt!NtReadFile file, the nt!NtReadFile function:

  1. Finds the device corresponding to the file. For example, this can be done through calls to ObReferenceObjectByHandle or IoGetRelatedDeviceObject functions.
  2. Creates an IRP structure using, for example, a call to the IoAllocateIrp function.
  3. Calls the appropriate device driver by passing the created IRP structure to it. This can be done by, for example, calling the nt!IoCallDriver function.

Note that the HANDLE is stored in the table of process objects and therefore only exists within a specific process.

3. The nt!IoCallDriver function takes a pointer to the driver object from the structure describing the device and passes the received IRP structure to the appropriate handler. The pseudocode for this process looks like this:

NTSTATUS IoCallDriver(PDEVICE_OBJECT device, PIRP irp)
{
// get FunctionIdx from irp
PDRIVER_OBJECT driver = device->DriverObject;
return (*driver->MajorFunction[FunctionIdx])(device, irp);
}

4. The execution flow is then transferred to the IRP_MJ_READ (0x03) handler of the corresponding driver.

Note that there’s a function similar to NtReadFile — the ZwReadFile function. ZwReadFile can be exported by both the kernel and ntdll. If we need to call a function from the user mode, we can work with either NtReadFile or ZwReadFile. But if we need to call a function from the kernel mode, then it’s best to use ZwReadFile, as it allows us to avoid errors when passing the data buffer from the user mode. You can learn more about working with these two functions in Microsoft documentation.

To process various control events or I/O events, the driver needs to register the appropriate handlers in the DriverObject->MajorFunction array. The full list of supported functions can be found in the wdm.h file.

For example, to process read/write requests, we need to provide implementations of functions with the IRP_MJ_READ and IRP_MJ_WRITE indexes:

NTSTATUS IrpHandler(IN PDEVICE_OBJECT fdo, IN PIRP pIrp){...}
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, ...)
{
DriverObject->MajorFunction[IRP_MJ_READ] = IrpHandler;
DriverObject->MajorFunction[IRP_MJ_WRITE] = IrpHandler;
}

However, we don’t have to register all possible handlers. The system provides a default implementation that reports failed request processing. We can also see all handlers registered for a particular driver:

Screenshot 6: The list of handlers registered for a driver

Note: It’s impossible to unload a driver without registering the DriverUnload method. Antivirus software and similar products often leverage this feature to ensure that no one can unload their drivers from the system.

Implementing callback methods

When creating the IRP structure, the I/O manager allocates an array of IO_STACK_LOCATION structures for it. This array is located at the very end of the IRP structure.

The array size is specified in the DEVICE_OBJECT structure of the device that will receive this IRP first; specifically, in the StackSize field of this structure. The MajorFunction code is also located in this structure and not in the IRP itself. As a result, the operation code may change when the IRP structure is passed from one device to another. For example, when passing a request to read to a USB device, the operation code may change from IRP_MJ_READ to something like IRP_MJ_INTERNAL_DEVICE_CONTROL.

To access the IO_STACK_LOCATION structure of the current device, we need to call the IoGetCurrentIrpStackLocation function. Then, depending on the value of MajorFunction, we need to perform the corresponding operation:

PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(irp);
switch (ioStack->MajorFunction)
{
case IRP_MJ_CREATE:
...
case IRP_MJ_WRITE:
...
default:
return CompleteIrp(irp, STATUS_INVALID_DEVICE_REQUEST, 0);

Overall, there are three possible scenarios for request processing:

1. Process the IRP request immediately. For example, if it’s a request for disk size (IOCTL_DISK_GET_LENGTH_INFO), we can return the result immediately, as this information is known:

irp->IoStatus.Status = STATUS_SUCCESS;
irp->IoStatus.Information = 0x1000000;
IoCompleteRequest(irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;

In case of failed request processing, the Information field usually has a 0 value in it. If a request is processed successfully, this field will contain the size of the data to be sent.

The IO_NO_INCREMENT (0) value means there’s no need to boost the priority of the threads waiting for IRP completion. If we were building a sound card driver, we could pass the IO_SOUND_INCREMENT (8) value, where 8 would represent the priority level corresponding to the driver type.

2. Pass the IRP request to the next driver in the device stack. While we don’t do this in our example, it can be done with the following command:

IoSkipCurrentIrpStackLocation(irp);
return IoCallDriver(lowerDeviceObject, irp);

This scenario can be used, for instance, when we need to process requests in a minifilter driver.

Read also:
How to Develop a Windows Minifilter Driver to Back Up Data

3. Save the IRP request to be further processed by the driver and mark it as pending. Add this structure to the list stored in the device’s extension and return STATUS_PENDING (see the example below). This scenario is often applied for read/write operations and can be used when, for example, there’s a call to a real device that needs to be processed after processing the initial request:

IoMarkIrpPending(irp);
ExInterlockedInsertTailList(list, &irp->Tail.Overlay.ListEntry, listLock);
KeSetEvent(&requestEvent, (KPRIORITY)0, FALSE);
return STATUS_PENDING;

Next, in the case of a synchronous call, the user mode application that called the Read/WriteFile function will call NtWaitForSingleObject for the HANDLE file to wait for the operation’s completion. Later, our driver will complete the stored IRP request by calling the IoCompleteRequest function. Calling this function will cause the event inside nt!_FILE_OBJECT to be set, which, in turn, will end the waiting for the HANDLE file.

Here’s how you can put a breakpoint in the code above to see the IRP request:

Screenshot 7: IRP request details

Now, let’s move to implementing read/write operations for a virtual disk.

Implementing read/write operations for a virtual disk

As we mentioned earlier, read/write requests are saved to a list stored in the device extension. Here’s a common way to process such requests:

  1. Create a thread in the kernel where we subtract requests from this list in a loop.
  2. Execute the corresponding operation of reading or writing to the file.
  3. For read operations, copy read data to the irp->MdlAddress buffer.
  4. Complete the request by calling the IoCompleteRequest function.

With this approach, requests are fully processed within the driver. However, as we need to work with the virtual disk file on the user application’s side, we need to apply a different approach where:

  1. The wdmDrvTest.exe application creates a thread in which it requests data on the last saved request from the driver.
  2. Based on the data received after Step 1, wdmDrvTest.exe reads data from and writes data to the disk file.
  3. The application sends the result back to the driver.
  4. In the case of a read operation, the driver copies the read data to IRP.
  5. The driver completes the request by calling the IoCompleteRequest function.

When compared to processing requests directly in the driver, this approach has several advantages. It enables us to:

  • Use various libraries available in user mode
  • Use programming languages other than C/C++
  • Place the logic in DLLs, which we can dynamically load or replace without unloading the entire driver or rebooting the system
  • Simplify code testing
  • Increase overall system stability, as even if an error occurs, it doesn’t lead to a system shutdown.

However, this approach also has disadvantages — the increased complexity of and time needed for request processing.

Let’s see how this approach works in practice.

To request the saved IRP, we need to send a special control code (ioctl) from the wdmDrvTest.exe application. To do this, we call the DeviceIoControl function so the driver receives the IRP_MJ_DEVICE_CONTROL request.

First, we wait for the setting of the event, indicating that the request has been added to the list (see the previous section). Then we get the object of the saved IRP.

KeWaitForSingleObject(requestEvent, Executive, KernelMode, FALSE, NULL);
PLIST_ENTRY request;
if ((request = ExInterlockedRemoveHeadList(list, listLock)) == NULL)
{
// the list is empty, the event is set during unmounting
return STATUS_SUCCESS;
}
PIRP lastIrp = CONTAINING_RECORD(request, IRP, Tail.Overlay.ListEntry);

From the IRP, we get data such as the offset on the disk for read/write operations, the size of the data, and the data buffer for write operations. Then we copy this data to irp->AssociatedIrp.SystemBuffer and complete the IRP_MJ_DEVICE_CONTROL request. After that, we can perform steps two through five.

Working with the lpOverlapped parameter

When implementing read/write operations, it’s crucial to properly configure the lpOverlapped parameter when calling the DeviceIoControl function.

lpOverlapped is a pointer to the OVERLAPPED structure for making asynchronous calls. If you don’t pass this parameter to the DeviceIoControl function, the call to this function will be executed synchronously, which may cause wdmDrvTest to hang when sending other ioctl calls.

Here is what can happen when this disk unmounting scenario takes place:

  1. Thread 1 sends an ioctl call to get a saved request.
  2. The driver receives this ioctl call and waits for a new request to appear in the request list.
  3. Thread 0 (main) tries to send an ioctl call to unmount the drive.
  4. Thread 0 hangs because the DeviceIoControl function is waiting for the call from step 2 to be completed.

Now, we can move to the process of disk mounting.

Mounting the disk

Onсe we’ve created a disk device and implemented IRP processing for it, we need to make this device available for users so that it can be accessed from File Explorer. To do this, we need to create a symbolic link between our disk device and the drive name:

UNICODE_STRING deviceName, symLink;
RtlInitUnicodeString(&deviceName, L"\\Device\\CoreMntDevDir\\disk");
RtlInitUnicodeString(&symLink, L"\\GLOBAL??\\Z:");
NTSTATUS ntStatus = IoCreateSymbolicLink(&symLink, &deviceName);

We need to create such a symbolic link in the global namespace so that the Z drive can be:

  • Seen from the wdmDrvTest.exe application launched in administrator mode so we can lock the volume before unmounting (see the next section)
  • Found via File Explorer and launched by a regular user

After that, the first time a file or folder is accessed on the drive, the I/O manager will mount the volume:

05 nt!IopMountVolume
06 nt!IopCheckVpbMounted
07 nt!IopParseDevice
08 nt!ObpLookupObjectName
09 nt!ObOpenObjectByNameEx
0a nt!IopCreateFile
0b nt!NtCreateFile
0c nt!KiSystemServiceCopyEnd
0d ntdll!NtCreateFile
0e KERNELBASE!CreateFileInternal
0f KERNELBASE!CreateFileW

During volume mounting, the system creates a volume parameter block (VPB) and links the volume device object (also created by the system) to the device object of our drive. The VPB address will be saved to the VPB field inside our device object.

Here’s what it looks like for our disk:

Screenshot 8: The VPB address in device object after mounting

Unmounting the disk

While the system automatically creates a device object for the volume and associates it with our disk through the VPB structure, unmounting this volume is still our task.

The whole process consists of the following steps:

  1. Unmount the volume
  2. Complete the thread that receives requests from the driver
  3. Delete our disk device object by calling the IoDeleteDevice function
  4. Delete the symbolic link to the Z drive

Note: Volume unmounting should always be performed first because the system can write data from the cache to the disk even during unmounting, potentially causing the system to hang.

The logic of the volume unmounting process is implemented in the wdmDrvTest.exe application. The application sends the following ioctl requests to the volume: FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME, and FSCTL_UNLOCK_VOLUME. These commands can only be run by a user with administrator rights.

After executing these commands, the VPB in our Device Object will be updated and will no longer contain a pointer to the volume device object.

At this point, we only have one control device object left for our driver. We can resend a disk mounting request to this device object, thus causing the whole cycle to repeat itself. This control device object will be removed after calling the DriverUnload function and unloading our driver.

Now, let’s talk about the security of our disk’s data.

Read also:
How to Develop Your Own Bootloader: A Comprehensive Tutorial

Encrypting the disk

To secure the disk, we can use OpenSSL to implement data encryption in our wdmDrvTest.exe application.

Here’s what the encryption algorithm would look like when our user application receives a read request from the driver:

1) The application reads the data from the file with the contents of our virtual disk (see screenshot 10 below)

2) The application deciphers the requested data

3) The application returns the deciphered results to the driver so that the driver can copy that data to the IRP request and complete it

void EncryptedImage::Read(char* buf, uint64_t offset, uint32_t bytesCount)
{
if (bytesCount % SECTOR_SIZE || offset % SECTOR_SIZE)
{
throw std::exception("wrong alignment");
}
std::vector<unsigned char=""> encData(bytesCount);
m_image->Read(&encData[0], offset, bytesCount);
uint64_t startSector = offset / SECTOR_SIZE;
DecryptLongBuf(encData, bytesCount, startSector, m_key, buf);
}

In the case of a write request, the algorithm would be a bit different — we would first need to encrypt the data, then write it to the disk.

When we mount the disk in the wdmDrvTest.exe application, the system passes a password to the PKCS5_PBKDF2_HMAC function. We get the encryption key from that password.

The size of our encryption key is 256 bits, but since it’s encoded with the 128-bit Advanced Encryption Standard (AES) ciphertext stealing (XTS) cipher, the key gets split into two blocks of 128 bits each. The first block will be used for encrypting the data, and the second block will be used for encrypting the tweak.

A tweak is basically an analog of the initialization vector in Cipher Block Chaining (CBC). The only difference is that a tweak is applied for each block — not only the first one. The tweak is generated from the disk sector number, so different tweaks will be used to encrypt different sectors of our disk.

There are two key advantages of working with this cipher:

1) Each data block (disk sector) is encrypted independently from the others

2) For different data blocks, the same plaintext will return different ciphertext

As a result, even if one disk sector gets damaged, decryption of the remaining sectors won’t be affected. In the case of CBC, this would be impossible because each data block relies on the encryption results of the previous block. At the same time, the use of a tweak prevents data leaks common with electronic codebook (ECB) mode, which also encrypts all blocks independently.

It’s also possible to implement encryption directly in the driver using standard tools like Cryptography API: Next Generation and BitLocker. However, these tools support AES-XTS encryption only in Windows 10 and later versions.

With most of the technical steps of our driver development processes over, let’s try to run the driver we’ve created.

Read also:
How to Overcome the Challenges of Developing a User Mode File System Driver

Running a demo

Once we’ve finished with all the preparations, all that’s left is to click the Build Solution button in Visual Studio to complete the process of building our driver. After that, we can configure a virtual machine running Windows 10 to launch and debug our driver. For our example, we used VMWare.

Note: When setting up a virtual machine in VMWare, it’s important to enable secure boot in the settings:

Screenshot 9: The Enable secure boot option in VMWare

Otherwise, the following commands won’t work.

First, we need to allow the loading of test-signed drivers so that the driver will be automatically signed with the wdmDrv.cer certificate during the build. To do that, we need to run the following command as an administrator:

> bcdedit.exe -set TESTSIGNING ON

Then, we need to copy the binaries and run the deploy.bat script (also as an administrator). This script copies our driver to the \system32\drivers\ directory and loads it.

After that, we can run the wdmDrvTest.exe application. The following command creates a vd.img file in the current folder and mounts the disk:

> wdmDrvTest.exe --create -p password -i vd.img

Screenshot 10: File with the contents of our virtual disk

Screenshot 11: Our virtual disk in File Explorer (Z:)

Now we can open the disk, which will cause the formatting window to appear. It allows us to format the disk with the required file system. Then we can create several files on the disk and go back to the wdmDrvTest.exe application and press any button to unmount the disk. To mount the disk from the file once more, we need to remove the –create parameter from the command above. After running the changed command as an administrator, the disk will appear in File Explorer again, but now with all the files created earlier.

To check if the data on the disk is encrypted, we can try opening the vd.img file with FTK Imager:

Screenshot 12: The encrypted file of our virtual disk opened with FTK Imager

If we turn off encryption, we’ll be able to see all of the contents of our virtual disk:

Screenshot 13: Contents of an unencrypted virtual disk in FTK Imager

Now, we can move to the final stage of the driver development process and debug our driver.

Read also:
Windows File System Minifilter Driver Development Tutorial

Debugging the disk

First, we need to install the WinDbg tool. It comes as part of Windows SDK and WDK, although you can also install it separately by checking only the Debugging tools for Windows option during setup.

Microsoft recommends using the Hyper-V hypervisor for driver debugging. They also provide a detailed guide on a simplified debugging process for Windows 10, which has Hyper-V built in. Recommendations on how to enable Hyper-V and configure a virtual machine with it are also provided on the Microsoft website. Alternatively, you can debug a driver using VirtualKD.

Since we created our virtual machine using VMWare, we use a slightly different approach:

1. Choose Add -> Serial Port in the settings of the virtual machine and check the boxes as shown in the following screenshot:

Screenshot 14: Virtual machine settings in VMWare

The name com_1 will be used in the WinDbg connection settings, so you can change it to a different one.

2. Start the virtual machine and run the following commands as an administrator:

> bcdedit /debug on
> bcdedit /dbgsettings serial debugport:2 baudrate:115200

3. On the machine with WinDbg installed, run the following command as an administrator:

> setx _NT_SYMBOL_PATH srv*c:\symbols*http://msdl.microsoft.com/download/symbols

This command adds the environment variable indicating where to look for the symbols (.pdb).

4. Create a WinDbg shortcut and add Target parameters at the end to launch kernel debugging:

-k com:pipe,port=\\.\pipe\com_1,resets=0,reconnect

Make sure to change the pipe name when you repeat these actions on your virtual disk.

5. Double-click on the WinDbg shortcut and restart the virtual machine.

Once the system restarts, we can:

  • Open WinDbg and press Ctrl+Break
  • Specify the path to the pdb driver using the .sympath+ command
  • Run the commands listed in this article once again to better memorize the described approach

And that’s it. We’ve successfully created a WDM driver.

Related services

Outsource Software Development in C/C++

Conclusion

Software drivers play an essential role in building Windows applications and expanding their capabilities. This process requires Windows driver developers to have a deep understanding of driver development specifics, keen knowledge of Windows internals, and expertise in C/C++.

Apriorit driver development professionals have successfully helped many businesses tackle their driver development challenges and deliver well-performing, secure, and competitive products.

Reach out to us to discuss ways our driver development experts can help your company build a secure and well-performing driver solution for Windows or any other platform.

Tell us about your project

Send us a request for proposal! We’ll get back to you with details and estimations.

By clicking Send you give consent to processing your data

Book an Exploratory Call

Do not have any specific task for us in mind but our skills seem interesting?

Get a quick Apriorit intro to better understand our team capabilities.

Book time slot

Contact us