How to write driver for windows

Уровень сложностиСредний

Время на прочтение10 мин

Количество просмотров48K

Небольшая предыстория

Совсем недавно я перешёл на 3 курс технического университета, где на нашу группу вновь навалилась целая куча новых предметов. Одним из них являлся ИиУВМ(Интерфейсы и устройства вычислительных машин), на котором нам предстояло изучать, как те или иные части компьютера взаимодействуют между собой. Уже на 2 лабораторной работе нам выдали задание, суть которого заключалась в переборе всех устройств на PCI шине и получению их Vendor и Device ID через порты ввода-вывода. Для упрощения задачи преподаватель выдал ссылку на специальный драйвер и dll библиотеку под Windows XP и заявил, что это оптимальный вариант выполнения работы, так как по другому сделать её невозможно. Перспектива писать код под устаревшую OS меня не радовала, а слова про «невозможность» другой реализации лишь разожгли интерес. После недолгих поисков я выяснил, что цель может быть достигнута с помощью самописного драйвера.

В этой статье я хочу поделиться своим опытом, полученным в ходе длительных блужданий по документации Microsoft и попыток добиться от ChatGPT вменяемого ответа. Если вам интересно системное программирование под Windows — добро пожаловать под кат.

Важно знать

Современные драйвера под Windows могут быть основаны на одном из двух фреймворков: KMDF и UMDF (Kernel Mode Driver Framework и User Mode Driver Framework). В данной статье будет рассматриваться разработка KMDF драйвера, так как на него наложено меньше ограничений в отношении доступных возможностей. До UMDF драйверов я пока не добрался, как только поэкспериментирую с ними, обязательно напишу статью!

Разработка драйверов обычно происходит с помощью 2 компьютеров (или компьютера и виртуальной машины), на одном вы пишите код и отлаживаете программу (хост-компьютер), на другом вы запускаете драйвер и молитесь, чтобы система не легла после ваших манипуляций (целевой компьютер).

Перед началом работы с драйверами обязательно создайте точку восстановления, иначе вы рискуете положить свою систему так, что она перестанет запускаться. В таком случае выходом из ситуации станет откат Windows в начальное состояние (то есть в состояние при установке), что уничтожит не сохранённые заранее файлы на системном диске. Автор данной статьи наступил на эти грабли и совсем забыл скопировать свои игровые сохранения в безопасное место…

Первый запуск драйвера после добавления обработки IOCTL запросов на моём компьютере приводит к падению системы с необходимостью откатываться на точку восстановления. После этого драйвер запускается и работает без проблем. Самое странное, что если перенести тот же код в новый проект и запустить этот драйвер, то падения не происходит. Причины этого найти мне не удалось, так что прошу помощи в комментариях.

Я не профессиональный разработчик и данная статья лишь способ проложить дорогу для тех, кому так же, как и мне, стало интересно выйти за рамки привычных user mode приложений и попробовать что-то новое. Критика и дополнения приветствуются, постараюсь по возможности оперативно исправлять все ошибки. Теперь точно всё :)

Установка компонентов

  1. В данной статье я не буду рассматривать вопрос о создании Hello world драйвера, этот вопрос полностью разобран тут. Рекомендую чётко следовать всем указаниям этого руководства, для того чтобы собрать и запустить свой первый драйвер. Это может занять некоторое время, но как можно достигнуть цели не пройдя никакого пути? Если у вас возникнут проблемы с этим процессом, я с радостью помогу вам в комментариях.

  2. Полезно будет установить утилиту WinObj, которая позволяет вам просматривать имена файлов устройств и находить символические ссылки, которые на них указывают. Качаем тут.

  3. Также неплохой утилитой является DebugView. Она позволяет вам просматривать отладочные сообщения, которые отправляются из ядра. Для этого необходимо включить опцию Kernel Capture на верхней панели.

    Теперь с полным баком всяких утилит и библиотек переходим к самому интересному.

Начинаем веселье

В результате выполнения всех пунктов руководства Microsoft вы должны были сформировать файл драйвера cо следующим содержимым (комментарии добавлены от меня):

#include <ntddk.h>
#include <wdf.h>

// Объявление прототипа функции входа в драйвер, аналогично main() у обычных программ
DRIVER_INITIALIZE DriverEntry;

// Объявление прототипа функции для создания экземпляра устройства
// которым будет управлять наш драйвер
EVT_WDF_DRIVER_DEVICE_ADD KmdfHelloWorldEvtDeviceAdd;

// Пометки __In__ сделаны для удобства восприятия, на выполнение кода они не влияют.
// Обычно функции в пространстве ядра не возвращают данные через return 
// (return возвращает статус операции)
// так что при большом числе аргументов такие пометки могут быть полезны
// чтобы не запутаться
NTSTATUS
DriverEntry(
  // Фреймворк передаёт нам этот объект, никаких настроек мы для него не применяем
    _In_ PDRIVER_OBJECT     DriverObject, 
    // Путь, куда наш драйвер будет помещён
    _In_ PUNICODE_STRING    RegistryPath 
)
{
    // NTSTATUS переменная обычно используется для возвращения
    // статуса операции из функции
    NTSTATUS status = STATUS_SUCCESS;

    // Создаём объект конфигурации драйвера
    // в данный момент нас не интерсует его функциональность
    WDF_DRIVER_CONFIG config;

    // Макрос, который выводит сообщения. Они могут быть просмотрены с помощью DbgView
    KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "HelloWorld: DriverEntry\n"));

    // Записываем в конфиг функцию-инициализатор устройства
    WDF_DRIVER_CONFIG_INIT(&config,
        KmdfHelloWorldEvtDeviceAdd
    );

    // Создаём объект драйвера
    status = WdfDriverCreate(DriverObject,
        RegistryPath,
        WDF_NO_OBJECT_ATTRIBUTES,
        &config,
        WDF_NO_HANDLE
    );
    return status;
}

NTSTATUS
KmdfHelloWorldEvtDeviceAdd(
    _In_    WDFDRIVER       Driver,     // Объект драйвера
    _Inout_ PWDFDEVICE_INIT DeviceInit  // Структура-иницализатор устройства
)
{
    // Компилятор ругается, если мы не используем какие-либо параметры функции 
    // (мы не используем параметр Driver)
    // Это наиболее корректный способ избежать этого предупреждения
    UNREFERENCED_PARAMETER(Driver);

    NTSTATUS status;

    // Объявляем объект устройства
    WDFDEVICE hDevice;

    // Снова вывод сообщения
    KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "KmdfHelloWorld: DeviceAdd\n"));

    // Создаём объект устройства
    status = WdfDeviceCreate(&DeviceInit,
        WDF_NO_OBJECT_ATTRIBUTES,
        &hDevice
    );

    // Утрированный пример того, как можно проверить результат выполнения операции
    if (!NT_SUCCESS(status)) {
        return STATUS_ERROR_PROCESS_NOT_IN_JOB;
    }

    return status;
}

Данный драйвер не делает ничего интересного, кроме 2-х отладочных сообщений и создания экземпляра устройства. Пока мы не можем общаться с устройством из пространства пользователя. Нужно это исправить!

Весь дальнейший код будет добавляться в функциюKmdfHelloWorldEvtDeviceAddДля достижения цели необходимо создать файл устройства и символическую ссылку на него в пространстве ядра. С этим нам помогут функции WdfDeviceInitAssignNameи WdfDeviceCreateSymbolicLinkОднако просто вызвать их, передав имена файлов, не получится, нужна подготовка.

Начнём с тех самых имён. Они представляют собой строки в кодировке UTF-8. Следующий пример показывает способ инициализации строки в пространстве ядра.

UNICODE_STRING  symLinkName = { 0 };
UNICODE_STRING deviceFileName = { 0 };


RtlInitUnicodeString(&symLinkName, L"\\DosDevices\\PCI_Habr_Link");
RtlInitUnicodeString(&deviceFileName, L"\\Device\\PCI_Habr_Dev"); 

Желательно придерживаться показанного в примере стиля именования файла устройства, то есть начинаться имя должно с префикса \Device\

Следующим шагом становится установление разрешения на доступ к устройству. Оно может быть доступно или из пространства ядра, или из системных или запущенных администратором программ. Внимание на код.

 UNICODE_STRING securitySetting = { 0 };
 RtlInitUnicodeString(&securitySetting, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)");

 // SDDL_DEVOBJ_SYS_ALL_ADM_ALL
 WdfDeviceInitAssignSDDLString(DeviceInit, &securitySetting);

Комментарий капсом — это пометка какой тип разрешения на устройство здесь выставлен. В документации Microsoft описаны константы, аналогичные комментарию, однако у меня компилятор их не видел, и мне пришлось вставлять строку в сыром виде. Ссылка на типы разрешений тут.

Далее необходимо настроить дескриптор безопасности для устройства. Если коротко, то это реакция устройства на обращения к своему файлу.

// FILE_DEVICE_SECURE_OPEN означает, что устройство будет воспринимать обращения
// к файлу устройства как к себе
WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE);

Наконец-то мы можем создать файл устройства:

 status = WdfDeviceInitAssignName(
     DeviceInit,
     &deviceFileName
 );

// Напоминание о том, что результат критичных для драйвера функций нужно проверять
 if (!NT_SUCCESS(status)) {
     WdfDeviceInitFree(DeviceInit);
     return status;
 }

Переходим к символической ссылке, следующий код должен быть вставлен после функции WdfDeviceCreate

 status = WdfDeviceCreateSymbolicLink(
     hDevice,
     &symLinkName
 );

Итоговый код функции KmdfHelloWorldEvtDeviceAdd должен иметь следующий вид:

NTSTATUS
KmdfHelloWorldEvtDeviceAdd(
    _In_    WDFDRIVER       Driver,     // Объект драйвера
    _Inout_ PWDFDEVICE_INIT DeviceInit  // Структура-иницализатор устройства
)
{
    // Компилятор ругается, если мы не используем какие-либо параметры функции 
    // (мы не используем параметр Driver)
    // Это наиболее корректный способ избежать этого предупреждения
    UNREFERENCED_PARAMETER(Driver);

    NTSTATUS status;

    UNICODE_STRING  symLinkName = { 0 };
    UNICODE_STRING deviceFileName = { 0 };
    UNICODE_STRING securitySetting = { 0 };

    RtlInitUnicodeString(&symLinkName, L"\\DosDevices\\PCI_Habr_Link");
    RtlInitUnicodeString(&deviceFileName, L"\\Device\\PCI_Habr_Dev");
    RtlInitUnicodeString(&securitySetting, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)");

    // SDDL_DEVOBJ_SYS_ALL_ADM_ALL
    WdfDeviceInitAssignSDDLString(DeviceInit, &securitySetting);

    WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE);

    status = WdfDeviceInitAssignName(
        DeviceInit,
        &deviceFileName
    );

    // Hезультат критичных для драйвера функций нужно проверять
    if (!NT_SUCCESS(status)) {
        WdfDeviceInitFree(DeviceInit);
        return status;
    }

    // Объявляем объект устройства
    WDFDEVICE hDevice;

    // Снова вывод сообщения
    KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL,"HelloWorld: EvtDeviceAdd\n"));

    // Создаём объект устройства
    status = WdfDeviceCreate(&DeviceInit,
        WDF_NO_OBJECT_ATTRIBUTES,
        &hDevice
    );

    status = WdfDeviceCreateSymbolicLink(
        hDevice,
        &symLinkName
    );

    // Утрированный пример того, как можно проверить результат выполнения операции
    if (!NT_SUCCESS(status)) {
        return STATUS_ERROR_PROCESS_NOT_IN_JOB;
    }

    return status;
}

После сборки и установки драйвера, в утилите WinObj по пути «GLOBAL??» вы сможете увидеть следующее:

Если у вас не появились эти файлы, то, возможно, вы допустили ошибку при инициализации и вам нужно под отладчиком проверить статус всех вызываемых функций

Если у вас не появились эти файлы, то, возможно, вы допустили ошибку при инициализации и вам нужно под отладчиком проверить статус всех вызываемых функций

Общаемся с устройством

Взаимодействие с устройством происходит через IOCTL запросы. В режиме пользователя есть специальная функция, которая позволяет отправлять их устройству и получать данные в ответ. Пока сконцентрируемся на обработке этих запросов на стороне драйвера.

С чего начнётся этот этап? Правильно! С инициализации необходимых компонентов. Следите за руками:


WDF_IO_QUEUE_CONFIG  ioQueueConfig;
WDFQUEUE  hQueue;

// Инициализируем настройки очереди, в которую будут помещаться запросы
// Параметр WdfIoQueueDispatchSequential говорит то, что запросы будут обрабатываться
// по одному в порядке очереди
WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(
    &ioQueueConfig,
    WdfIoQueueDispatchSequential
);

// Обработчик HandleIOCTL будет вызываться в ответ на функцию DeiviceIOControl
// Уже скоро мы создадим его
ioQueueConfig.EvtIoDeviceControl = HandleIOCTL;

// Создаём очередь
status = WdfIoQueueCreate(
      hDevice,    // Объект устройства уже должен существовать
      &ioQueueConfig,
      WDF_NO_OBJECT_ATTRIBUTES,
      &hQueue
);
if (!NT_SUCCESS(status)) {
      return status;
}

Очередь есть, но нет обработчика. Работаем:

// Выглядит страшно, но по сути код может быть любым числом, этот макрос использован 
// для более подробного описания возможностей IOCTL кода для программиста
#define IOCTL_CODE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x3000, METHOD_BUFFERED, GENERIC_READ | GENERIC_WRITE)

VOID HandleIOCTL(
    _In_ WDFQUEUE Queue,   // Объект очереди, применения ему я пока не нашёл
    _In_ WDFREQUEST Request, // Из этого объекта мы извлекаем входной и выходной буферы
    _In_ size_t OutputBufferLength,
    _In_ size_t InputBufferLength,
    _In_ ULONG IoControlCode // IOCTL код, с которым к устройству обратились
)
{
    NTSTATUS status = STATUS_SUCCESS;

    UNREFERENCED_PARAMETER(Queue);
    UNREFERENCED_PARAMETER(InputBufferLength);
    UNREFERENCED_PARAMETER(OutputBufferLength);
    UNREFERENCED_PARAMETER(Request);

   switch (IoControlCode)
   {
      case IOCTL_CODE:
      {
          // Обрабатываем тут
          break;
      }
   }

    // По сути своей return из обработчика
    // Используется, если запрос не возваращает никаких данных
    WdfRequestComplete(Request, status);
}

Вот мы уже и на финишной прямой, у нас есть очередь, есть обработчик, но последний не возвращает и не принимает никаких данных. Сделаем так, чтобы обработчик возвращал нам сумму переданных чисел.

Объявим 2 структуры, из их названий будет понятно для чего они будут использоваться. В режиме ядра лучше использовать системные типы данных, такие как USHORT, UCHAR и другие.

struct DeviceRequest
{
    USHORT a;
    USHORT b;
};

struct DeviceResponse
{
    USHORT result;
};

Обновлённая функция обработки IOCTL запроса:

VOID HandleIOCTL(
    _In_ WDFQUEUE Queue,   // Объект очереди, применения ему я пока не нашёл
    _In_ WDFREQUEST Request, // Из этого объекта мы извлекаем входной и выходной буферы
    _In_ size_t OutputBufferLength,
    _In_ size_t InputBufferLength,
    _In_ ULONG IoControlCode // IOCTL код, с которым к устройству обратились
)
{
    NTSTATUS status = STATUS_SUCCESS;

    UNREFERENCED_PARAMETER(Queue);
    UNREFERENCED_PARAMETER(InputBufferLength);
    UNREFERENCED_PARAMETER(OutputBufferLength);

    size_t returnBytes = 0;

    switch (IoControlCode)
    {
    case IOCTL_CODE:
    {
        struct DeviceRequest request_data = { 0 };

        struct DeviceResponse *response_data = { 0 };

        PVOID buffer = NULL;
        PVOID outputBuffer = NULL;
        size_t length = 0;

        // Получаем указатель на буфер с входными данными
        status = WdfRequestRetrieveInputBuffer(Request, 
                                               sizeof(struct DeviceRequest),
                                               &buffer,
                                               &length);

        // Проверка на то, что мы получили буфер и он соотвествует ожидаемому размеру
        // Очень важно делать такие проверки, чтобы не положить систему :)
        if (length != sizeof(struct DeviceRequest) || !buffer)
        {
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
        }

        request_data = *((struct DeviceRequest*)buffer);

        // Получаем указатель на выходной буфер
        status = WdfRequestRetrieveOutputBuffer(Request, 
                                                sizeof(struct DeviceResponse), 
                                                &outputBuffer,
                                                &length);
        if (length != sizeof(struct DeviceResponse) || !outputBuffer)
        {
            status = STATUS_INVALID_DEVICE_REQUEST;
            break;
        }
        response_data = (struct DeviceResponse*)buffer;

        // Записываем в выходной буфер результат
        response_data->result = request_data.a + request_data.b;
        // Вычисляем сколько байт будет возвращено в ответ на данный запрос
        returnBytes = sizeof(struct DeviceResponse);

        break;
    }
    }

    // Функция-return изменилась, так как теперь мы возвращаем данные
    WdfRequestCompleteWithInformation(Request, status, returnBytes);
}

Последний шаг — программа в режиме пользователя. Cоздаём обычный С или C++ проект и пишем примерно следующее:

#include <windows.h>
#include <iostream>

//Эта часть аналогична тем же объявлениям в драйвере
//================
#define IOCTL_CODE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x3000, METHOD_BUFFERED, GENERIC_READ | GENERIC_WRITE)

struct DeviceRequest
{
    USHORT a;
    USHORT b;
};

struct DeviceResponse
{
    USHORT result;
};
// ===========================

int main()
{
	HANDLE hDevice = CreateFileW(L"\\??\\PCI_Habr_Link", 
                                 GENERIC_READ | GENERIC_WRITE,
                                 FILE_SHARE_READ, 
                                 NULL, 
                                 OPEN_EXISTING,
                                 FILE_ATTRIBUTE_NORMAL,
                                 NULL);
  
	DeviceRequest request = { 0 };
	request.a = 10;
	request.b = 15;

	LPVOID input = (LPVOID)&request;

	DeviceResponse response = {  0};
	LPVOID answer = (LPVOID)&response;
	DWORD bytes = 0;

	bool res = DeviceIoControl(hDevice, IOCTL_CODE, input, sizeof(DeviceRequest),
		answer, sizeof(DeviceResponse), &bytes, NULL);

	response = *((DeviceResponse*)answer);
	std::cout << "Sum : " << response.result << std::endl;
	char ch;
	std::cin >> ch;

	CloseHandle(hDevice);
}

При запуске вы должны получить такой результат:

Заключение

Собственно, на этом всё. В конечном итоге мы получили драйвер, способный принимать и отвечать на запросы пользовательских программ. Дальнейшие модификации проекта ограничиваются лишь вашей фантазией.

Предыстория

Для очередного проекта возникла необходимость написать простенький софтверный драйвер под Windows, но так как опыта в написании драйверов у меня примерно столько же, сколько и в балете, я начал исследовать данную тему. В таких делах я предпочитаю начинать с основ, ибо если кидаться сразу на сложные вещи, то можно упустить многие базовые понятия и приёмы, что в дальнейшем только усложнит жизнь.

После 20 минут поисков по сети я наткнулся на Github Павла Иосифовича (zodiacon — Overview). Личность легендарная в своих кругах, достаточно посмотреть на его репозиторий, публикации и выступления на именитых конференциях. Помимо этого, Павел является автором/соавтором нескольких книг: «Windows Internals» (книга, имеющаяся у меня на полке, которая принесла немало пользы), и «Windows Kernel Programming» 2019 года выпуска (бегло пролистав 11 Глав или 390 страниц, я понял – это то, что нужно!).
Кстати, книгу вы можете купить прямо на сайте Павла

Ссылка скрыта от гостей

Книгу я приобрёл в бумажной версии, чтобы хоть и немного, но поддержать автора. Безупречное качество, несмотря на то, что она издается в мягком переплете. Хорошие плотные листы формата А4 и качественная краска. (книга без проблем пережила вылитую на нее кружку горячего кофе).
Пока я сидел на балконе и читал четвёртую главу книги, в голову пришла мысль: а почему бы не сделать ряд статей на тему «Программирования драйвера под Windows», так сказать, совместить полезное, с еще более полезным.

И вот я здесь, пишу предысторию.

Как я вижу этот цикл статей и что от него ожидать:
Это будут статьи, которые будут базироваться на вышеупомянутой книге, своеобразный вольный и сокращенный перевод, с дополнениями и примечаниями.

Базовые понятия о внутреннем устройстве Windows (Windows Internals)

Для того, чтобы начать разрабатывать Драйвер под Windows, то есть работать на уровне с ядром ОС, необходимо базовое понимание того, как эта ОС утроена. Так как я хочу сосредоточиться на написании драйвера, а не на теории об операционных системах, подробно описывать понятия я не буду, чтобы не растягивать статью, вместо этого прикреплю ссылки для самостоятельного изучения.

Следовательно, нам стоит ознакомиться с такими базовыми понятиями как:

Ссылка скрыта от гостей

Процесс – это объект, который управляет запущенной инстанцией программы.​

Ссылка скрыта от гостей

Технология, позволяющая создавать закрытые пространства памяти для процессов. В своем роде — это песочница.​

Ссылка скрыта от гостей

Это сущность, которая содержится внутри процесса и использует для работы ресурсы, выделенные процессом — такие, как виртуальная память. По сути, как раз таки потоки и запускают код.​

Ссылка скрыта от гостей

В своем роде это прокладка, которая позволяет программе отправлять запросы в Ядро операционной системы, для выполнения нужных ей операций.​

Ссылка скрыта от гостей

Это сложно описать словами коротко, проще один раз увидеть картинку.​

В упрощённом виде это выглядит так:​

image1.png

Ссылка скрыта от гостей

Дескрипторы и объекты необходимы для регулирования доступа к системным ресурсам.​

Объект — это структура данных, представляющая системный ресурс, например файл, поток или графическое изображение.​

Дескриптор – это некая абстракция, которая позволяет скрыть реальный адрес памяти от Программы в пользовательском режиме.​

Для более глубокого понимания Операционных систем могу посоветовать следующие материалы:
Книги:

  • Таненбаум, Бос: Современные операционные системы
  • Windows Internals 7th edition (Part 1)

Видео:

Настройка рабочего пространства

Для разработки драйвера, как и любого другого софта необходима подходящая среда.
Так как мы работаем в операционной системе Windows, её средствами мы и будем пользоваться.

Что нам понадобится:

1. Visual Studio 2017 и старше.​

(Community Version хватает с головой) Также во вкладке „Individual components” необходимо установить

Код:

MSVC v142 - VS 2019 C++ ARM build tools (Latest)
MSVC v142 - VS 2019 C++ ARM Spectre-mitigated libs (Latest)
MSVC v142 - VS 2019 C++ ARM64 build tools (Latest)
MSVC v142 - VS 2019 C++ ARM64 Spectre-mitigated libs (Latest)
MSVC v142 - VS 2019 C++ ARM64EC build tools (Latest - experimental)
MSVC v142 - VS 2019 C++ ARM64EC Spectre-mitigated libs (Latest - experimental)
MSVC v142 - VS 2019 C++ x64/x86 build tools (Latest)
MSVC v142 - VS 2019 C++ x64/x86 Spectre-mitigated libs (Latest)

image10.png

и далее по списку.

2. Windows 10/11 SDK (последней версии)​

Ссылка скрыта от гостей

image3.png

Тут все просто. Качаем iso файл, монтируем и запускаем установщик.

3. Windows 10/11 Driver Kit (WDK)​

Ссылка скрыта от гостей

image4.png

В конце установки вам будет предложено установить расширение для Visual Studio. Обязательно установите его!

image11.png

После закрытия окна установки WDK появится установщик Расширения VisualStudio

image2.png

4. Sysinternals Suite

Ссылка скрыта от гостей

Скачайте и распакуйте в удобное для вас место. Это набор полезных утилит, которые пригодятся для исследования Windows, дебага драйвера и прочего.

image8.png

5. Виртуальная Машина с Windows для тестов.​

Выбор ПО для виртуализации на ваше усмотрение. Я буду использовать «VMware Workstation 16 pro».
Написанные драйверы лучше тестировать именно в виртуальной машине, так как Ядро — ошибок не прощает, и вы будете часто улетать в синий экран смерти.

После того, как все было установлено, пора запускать Visual Studio и начинать писать драйвер.

Создание проекта

Запускаем Visual Studio и создаем новый проект. Создадим пустой проект „Empty WDM Driver“

image21.png

Называем его как душе угодно.

image24.png

И вот он, наш свеженький чистенький проект для нашего первого драйвера.

image27.png

Теперь необходимо создать cpp файл, в котором мы будем писать сам драйвер.

image7.png

image14.png

Вот и все. Настройку системы и среды мы закончили.

image22.png

Первый драйвер

Сначала импортируем ntddk.h эта одна из базовых библиотек для работы с ядром. Больше информации

Ссылка скрыта от гостей

. Как и у любой программы, у драйвера должна быть точка входа DriverEntry, как функция Main в обычной программе. Готовый прототип этой функции выглядит так

C++:

#include <ntddk.h>
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
/*
    In_ это часть SAL(Source Code Ananotation Language) Аннотации не видимы для компилятора,
    но содержат метаданные которые, улучшают анализ и чтение кода.
*/
    return STATUS_SUCCESS;
}

Если мы попробуем собрать наш проект, то получим следующие ошибки и предупреждения.

image15.png

В данном случае пункт 1 является следствием пунктов 2 и 3. Дело в том, что по дефолту в Visual Studio некоторые “предупреждения” расцениваются как ошибки.
Чтобы решить эту проблему есть 2 пути.

  1. Отключить эту фичу в Visual Studio, что делать не рекомендуется. Так как сообщения об ошибках могут быть полезны и сэкономят вам время и нервы в дальнейшем.
  2. Более правильный и классический метод это использовать макросы в c++. Как видно из сообщения с кодом C4100 объекты RegistryPath и DriverObject не упомянуты в теле функции. Подробнее

    Ссылка скрыта от гостей

    .

Для того, чтобы избавиться от предупреждений, и заставить наш код работать, стоит поместить объекты в макрос UNREFERENCED_PARAMETER(ObjectName)

C++:

include <ntddk.h>

NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(DriverObject);
    UNREFERENCED_PARAMETER(RegistryPath);
    return STATUS_SUCCESS;
}

Теперь, если пересобрать проект, то мы увидим, что ошибка С220 и предупреждение C4100 пропали, но к ним на смену пришли LNK2019 и LNK1120. Однако это уже не ошибки компиляции — это ошибки линкера. А кто говорил что будет легко?
О том, что такое линкер можно почитать

Ссылка скрыта от гостей

.

Дело в том, что наша функция не представлена в стандартном линкере С++ и вообще она девушка капризная и хочет Си-линкер. Удовлетворим желание дамы и дадим ей то, чего она хочет.

Делается это просто. Перед функцией надо добавить extern "C" так наш линкер будет понимать, что эта функция должна линковаться С-линкером.

Собираем проект заново и вуаля — Драйвер собрался.

image17.png

Что на данный момент умеет наш драйвер? Сейчас это по сути пустышка, которая после загрузки, в случае успеха, вернет нам сообщения об удачном запуске. Давайте заставим его нас поприветствовать и проверим его работоспособность. Выводить сообщения мы будем при помощи функции KdPrint(()); да именно в двойных кавычках.

Итоговый код драйвера будет выглядеть так:

C++:

#include <ntddk.h>

//Указываем линкеру, что DriverEntry должна линковаться С-линкером
extern "C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
    //Убираем варнинг C4100 и связанную с ним ошибку C220
    UNREFERENCED_PARAMETER(DriverObject);
    UNREFERENCED_PARAMETER(RegistryPath);
    //Выводим сообщение
    KdPrint(("Hi Codeby, this is our first driver! Yuhu!\n"));
   
    return STATUS_SUCCESS;
}

Собираем или пересобираем драйвер.

image6.png

Важно! Сборка драйвера должна происходить в режиме Debug!!!

image12.png

После чего в папке нашего проекта мы сможем найти результаты нашего труда. Вы только посмотрите на него, какой маленький и хорошенький.

image5.png

Но что делать дальше? Как проверить его работоспособность?
Для этого нам и понадобится наша виртуальная машина с Windows, но перед запуском на ней драйвера, нам придется проделать пару манипуляций. Дело в том, что в Windows есть встроенная защита, и если драйвер не подписан «нужной» подписью ака сертификатом, то драйвер просто не загрузится.

Дальнейшие действия нужно проделать в Windows на виртуальной машине.
Чтобы отключить эту проверку подписи, а точенее перевести Windows в тестовый режим, запустите cmd.exe от имени администратора и введите следующую команду bcdedit /set testsigning on.

image13.png

Перезагрузите виртуальную машину.
Если все прошло удачно, в правом нижнем углу вы увидите следующую надпись (2 нижнее строчки могут отличиться в зависимости от версии Windows)

Возвращаемся в папку с драйвером и копируем его в виртуальную машину. Теперь нам надо создать службу для запуска драйвер. Открываем консоль от имени администратора и вводим следующую команду:
sc create Name type= kernel binPaht= PATH_TO_DRIVER

в моем случае это выглядит так:

image19.png

Также проверить успешность создания можно через реестр.

image16.png

В той же консоли мы можем попробовать запустить нашу службу.
sc start CodebyDriver

image18.png

Отлично, драйвер запустился и мы даже не улетели в синьку, а это всегда приятно. Теперь давайте проверим, выводится ли сообщение от драйвера.
Для этого нам необходимо провести подготовительные работы.

Создадим новый ключ в реестре и назовем его Debug Print Filter.

image23.png

В качестве значения задаем DWORD с именем DEFAULT и определяем данные для значения как 8.

image25.png

Перезагружаем виртуальную машину.

После перезапуска запускаем DebugView данный инструмент находится в архиве Sysinternals, который мы ранее скачали. Ее можно смело скопировать в виртуальную машину.

Запускаем DebugView от имени Администратора и ставим галочку “Capture Kerner”

image20.png

Capture Win32 и Capture Global Win32 можно снять, если летит много сообщений.

Затем запускаем консоль от имени администратора и запускаем службу загрузки драйвера.

image26.png

Все отработало отлично, и мы видим приветствие от нашего драйвера!
На этой приятной ноте первая статья из цикла заканчивается. В дальнейших статьях мы добавим функционала нашему драйверу, научим его выгружаться и получать данные.

Спасибо за чтение!

P.S: Я сам только начал изучать тему работы с драйверами. Так что если у вас есть предложения или правки по технической части статьи, прошу отписать в комментарии, чтобы я мог внести изменения в статью.
P.P.S: Как вы могли заметить, писать мы будем преимущественно на С++, посему могу посоветовать отличный канал с уроками по С++ — The Cherno.

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
  • Finalizing the implementation of WDM requirements
  • 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:

Visual Studio 2019 interface for creating driver projects

Screenshot 1: Visual Studio 2019 interface for creating driver 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:

Makefile

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

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):

List of a driver’s linked modules with imported APIs

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

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.

Encryption disk architecture

To build properly functioning WDM drivers, 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.

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:

C

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

Comparing a lifecycle of a user application and a kernel driver

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.

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:

Our virtual disk in the Device directory opened with the winobj utility

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:

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

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:

C++

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

The DriverObject structure view

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 writing WDM drivers 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:

C++

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.

The process of handling an I/O event

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:

C++

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:

The list of handlers registered for a 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:

C++

PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(irp);

switch(ioStack->MajorFunction)

{

caseIRP_MJ_CREATE:

...

caseIRP_MJ_WRITE:

...

default:

returnCompleteIrp(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:

C

irp->IoStatus.Status = STATUS_SUCCESS;

irp->IoStatus.Information = 0x1000000;

IoCompleteRequest(irp, IO_NO_INCREMENT);

returnSTATUS_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:

C++

IoSkipCurrentIrpStackLocation(irp);

returnIoCallDriver(lowerDeviceObject, irp);

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

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:

C++

IoMarkIrpPending(irp);

ExInterlockedInsertTailList(list, &irp->Tail.Overlay.ListEntry, listLock);

KeSetEvent(&requestEvent, (KPRIORITY)0, FALSE);

returnSTATUS_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:

IRP request details

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.

C++

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

returnSTATUS_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:

C++

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:

C++

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:

The VPB address in device object after mounting

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.

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

C++

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.

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:

The Enable secure boot option in VMWare

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:

Bash

> 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:

Bash

> wdmDrvTest.exe --create -p password -i vd.img
File with the contents of our virtual disk

Screenshot 10: File with the contents of our virtual disk
Our virtual disk in File Explorer (Z:)

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:

The encrypted file of our virtual disk opened 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:

Contents of an unencrypted virtual disk in FTK Imager

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

Finalizing the implementation of WDM requirements

Currently, we have a legacy driver. To turn it into a Windows Driver Model driver, we need to introduce the following changes:

  • Create the inf file
  • Create the AddDevice routine
  • Create callbacks for additional IRPs

To make these changes. Let’s use the toastMon sample from WDK 7600.16385.1. Note that the toaster’s version on GitHub already uses the Kernel-Mode Driver Framework (KMDF).

First, we update the inf file created by default in the beginning. This file contains HardwareID 一 Root\wdmDrv, which the system uses to find a driver which will serve this device:

C++

[Manufacturer]
%ManufacturerName%=Standard,NT$ARCH$

[Standard.NT$ARCH$]
%DeviceDesc%=wdmDrv_Device, Root\wdmDrv

[Strings] 
ManufacturerName="VvCorp"
DeviceDesc= "My Device"

Now, if we try to install inf without changes in the driver, we’ll see the Unable to find any matching devices error in C:\Windows\INF\setupapi.dev.log.

Next, let’s add the AddDevice callback and specify its address in DriverEntry:

C++

DriverObject->DriverExtension->AddDevice = AddDevice;

Here’s how the system calls the DriverEntry function:

C++

00 wdmDrv!DriverEntry
01 wdmDrv!GsDriverEntry
02 nt!PnpCallDriverEntry
03 nt!IopLoadDriver
04 nt!PipCallDriverAddDeviceQueryRoutine
05 nt!PnpCallDriverQueryServiceHelper
06 nt!PipCallDriverAddDevice
07 nt!PipProcessDevNodeTree
08 nt!PiRestartDevice
09 nt!PnpDeviceActionWorker
0a nt!ExpWorkerThread
0b nt!PspSystemThreadStartup
0c nt!KiStartSystemThread

After that, the system calls the AddDevice function for the device which is defined in the inf file:

C++

00 wdmDrv!AddDevice
01 nt!PpvUtilCallAddDevice
02 nt!PnpCallAddDevice
03 nt!PipCallDriverAddDevice
04 nt!PipProcessDevNodeTree
05 nt!PiRestartDevice
06 nt!PnpDeviceActionWorker
07 nt!ExpWorkerThread
08 nt!PspSystemThreadStartup
09 nt!KiStartSystemThread

Now, we have to create our control device in the AddDevice routine instead of DriverEntry. After this, we’ll attach our created control device to the device stack using the following call:

C++

deviceExtension->TopOfStack = IoAttachDeviceToDeviceStack(
        gDeviceObject,
        PhysicalDeviceObject);

Let’s take a closer look at the elements from the example above:

  • PhysicalDeviceObject (PDO) is the device for \Driver\PnpManager, which we received from the system in AddDevice arguments
  • gDeviceObject is the control device we created

Here’s how the device stack in WinDbg looks like once the device is successfully attached to the stack:

The device stack after we attached a control device

Screenshot 14: The device stack after we attached a control device

The call mentioned above also sets the StackSize field for our control device, making it 2: PDO->StackSize+1. And deviceExtension->TopOfStack will hold PhysicalDeviceObject. We’ll use it to pass IRPs down to the stack.

Next, we specify callbacks for PnP, Power Management, and Windows Management Instrumentation (WMI):

C++

DriverObject->MajorFunction[IRP_MJ_PNP]=DispatchPnp;
DriverObject->MajorFunction[IRP_MJ_POWER]=DispatchPower;
DriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL]=DispatchSystemControl;

For Power Management and WMI, we only forward the IRP to the next device in the stack.

As for PnP, after AddDevice the system will call the DispatchPnp routine. Here, we are only interested in IRP_MN_START_DEVICE and IRP_MN_REMOVE_DEVICE IRPs (see below). We need to wait for IRP completion by PDO. For that, let’s set our CompletionRoutine and pass IRP down to PDO. When PDO completes IRP, all devices in the stack will be started. So our upper device can finish its start activity:

C++

00 wdmDrv!CompletionRoutine
01 nt!IopfCompleteRequest
02 nt!IofCompleteRequest
03 nt!IopPnPCompleteRequest
04 nt!IopPnPDispatch
05 nt!IofCallDriver
06 wdmDrv!DispatchPnp
07 nt!IofCallDriver
08 nt!PnpAsynchronousCall
09 nt!PnpSendIrp
0a nt!PnpStartDevice
0b nt!PnpStartDeviceNode
0c nt!PipProcessStartPhase1
0d nt!PipProcessDevNodeTree

Now, we can install our driver using the inf file and the Device Console (DevCon) tool from WDK:

C++

> devcon.exe install wdmDrv.inf Root\wdmDrv

After that, our driver will be displayed in Device Manager:

Our driver is displayed in Device Manager

Screenshot 15: Our driver is displayed in Device Manager

We can also see our device in the PnP tree in WinDbg by using “!devnode 0 1” command:

C++

DevNode 0xffffd988b0e0fcc0 for PDO 0xffffd988b23022e0
    InstancePath is "ROOT\SYSTEM\0001"
    ServiceName is "wdmDrv"
    State = DeviceNodeStarted (0x308)
    Previous State = DeviceNodeEnumerateCompletion (0x30d)

To uninstall device, execute:

C++

> devcon remove Root\wdmDrv

Our driver will receive IRP_MN_REMOVE_DEVICE IRP and the stack will look like this:

C++

00 wdmDrv!DispatchPnp
01 nt!IofCallDriver
02 nt!IopSynchronousCall
03 nt!IopRemoveDevice
04 nt!PnpRemoveLockedDeviceNode
05 nt!PnpDeleteLockedDeviceNode
06 nt!PnpDeleteLockedDeviceNodes
07 nt!PnpProcessQueryRemoveAndEject
08 nt!PnpProcessTargetDeviceEvent

Now, let’s delete our control device here instead of DriverUnload. And after this IRP, our driver will be unloaded from memory.

As you can see, WDM requires handling of additional IRPs for PnP, Power Management, and WMI. If you use KMDF, all of these will be under the hood. So, instead of writing boilerplate code which can potentially have bugs, it would be better to use KMDF. But first, you’ll need to determine which type of driver you need. In our case, a legacy driver was enough. Note: if you need to monitor filesystem activity, for example, you also don’t need KMDF. Consider going with a minifilter driver in such a case.

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

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:

Virtual machine settings in VMWare

Screenshot 16: 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:

Bash

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

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

Bash

> 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:

Bash

-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 Windows WDM driver.

To learn details about drivers, check out our articles about Windows driver testing and USB WiFi driver development.

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.

Dennis Turpitka, CEO, Apriorit Inc.

A driver is an essential software component of an operating system, allowing it to work with various devices, hardware, and virtual ones. Probably the most common perception about drivers is that they’re notoriously hard to deal with. Writing a simple device driver is difficult enough, and if you’re talking about something complex—well, let’s just say that not even major companies always get it right.

This area of software development is specific and detached, requiring its own techniques, processes, and specialists. Even specialized driver development services are offered on the software engineering market.

This article offers more or less a primer on how computer drivers are written, and hopefully will be your starting point in exploring the Windows NT kernel.

What is a Driver?

As mentioned, the most basic definition of a driver is software that allows the system to work with various devices. But this definition proves is rather incomplete—in reality, various types of drivers exist and they can be divided by two major criteria:

By tasks:

Functional drivers: These are the classic drivers, which implement a standard device access interface (I/O requests) (Fig. 1). Such drivers are usually developed by device manufacturers, such as graphics-card vendors, audio-device vendors, etc.

1. Structure of a functional driver.

Filter drivers: These drivers don’t address devices directly, but are involved in the processing of requests directed to those devices (Fig. 2). Usually, I/O requests from system to device are processed by a driver stack. Filter drivers can log, restrict access to the device, or modify the requests. For example, antiviruses (that use the file-system filter drivers) check files for viruses before allowing it to be opened.

2. Structure of a filter driver.

Software drivers: These drivers, unlike previous ones, aren’t involved in requests to physical devices (Fig. 3). For example, to develop an application that finds hidden processes, you need to get access to objects of the system kernel, which describe every running process. Then you can split your application in two parts. One part will run in a user mode and provide a graphical interface, while the other one will run in a kernel mode and provide access to the closed-system data.

3. Structure of a software driver.

By execution context:

Kernel-mode drivers: These drivers are executed in a system kernel and have access to the closed data. All of the above are applicable to them.

User-mode drivers: Certain filter drivers and functional drivers, which don’t need access to the system data, can be executed in user mode. It makes them safer from a system stability standpoint.

Specifics of Driver Development

From the programmer point of view, a driver is a set of functions that process requests to a certain device or a group of devices. The programmer implements certain procedures depending on processed requests.

Elevated privileges in kernel mode impose additional responsibility on the developer, considering that any mistake in the driver code can result not just in the driver unloading, but also the overall system crash (remember the famous “blue screen of death” in Windows?).

Development language for Windows drivers is chosen based on the driver type:

• The Windows Driver Kit (WDK) compiler for the kernel-mode driver supports only C language.

• User-mode drivers are written in C++. Interaction with WDK happens via COM interfaces.

Tools

To run, debug and test the driver, you will need the following set of tools: 

WDK 8.1: Libraries, compilers, and samples for driver development. The WDK version is chosen depending on the targeted system.

Microsoft Visual Studio 2013: The development environment.

OSR Loader: A simple utility for installing, running, stopping and uninstalling the driver.

WinDbg: A very simple and very convenient debugger that’s included in WDK. 

VMWare or VirtualBox: A virtual machine that’s needed to debug and test the driver.

VirtualKD: A very useful utility that helps quickly and easily set up a virtual machine such as VMWare or VirtualBox for debug in kernel mode.

Hello World”

Now we will try to write a simple driver that displays a message in the kernel mode. First, create a new Empty WDM Driver project in Visual Studio (Fig. 4).

5. Solution explorer section for an Empty WDM Driver.

Any driver needs to define at least one function: DriverEntry. This function will be used by the system when loading the driver. For our simple driver, it looks like this (Fig. 6):

6. Code defining the function of the driver.

As you can guess, this function says “Hello” to the whole world ;) Let’s try to build the project (Fig.7):

7. Building the project.

Perfect, now the driver is ready to be used.

All files are located here (Fig. 8) :

8. Compiled driver files.

.cer: This is the certificate file—starting with Windows Vista, the OS doesn’t load unsigned drivers. In our case, we have a test signature, allowing only the file to be debugged.

.inf: This configuration file is for driver installation. We will not use it.

.pdb: This file contains information about the functions and variables necessary for debugging.

.sys: The driver itself.

Let’s install the driver on a target system. To do this, we need to copy the Sample.sys file to the system, run OSLOADER.exe, and specify the path to the driver (Fig. 9).

10. Command prompt command showing the driver is successfully installed.

11. Successfully installed driver showing in the registry.

Before running the driver («Start Service»), allow the message display by executing the “ed Kd_DEFAULT_MASK 0xF” command in the debugger. Now, let’s start the driver (Fig. 12):

12. Executing the sample driver.

Congratulations with successfully running your driver! However, this driver can’t be unloaded from memory and will keep working until the OS is restarted. To unload the driver, we need to slightly modify its code (Fig. 13):

13. Modifying the driver code to enable unloading.

This will tell the system that our driver can be unloaded. In order to do that, SampleDriverUnload should be executed (Fig. 14).

14. Sample driver is unloaded from the kernel.

To be able to receive requests, we need to register our device in the system. For this, we need to:

1. Define the SAMPLE_DEVICE_EXTENSION structure, in which we will store data required for work (Fig. 15).

15. Defining the SAMPLE_DEVICE_EXTENSION structure.

2. Define the device name (Fig. 16).

16. Defining the device name.

3. Create DEVICE_OBJECT (Fig. 17).

17. Creating the device object.

4. Create a link to the device for applications (Fig. 18).

18. Creating a link to the device.

5. In SampleDriverUnload, delete both link and DEVICE_OBJECT (Fig. 19).

19. Making changes to the unload code in order to delete the device object and link for applications.

6. Register your request handling functions. For this, you need to fill out an array of pointers to MJ functions (Fig. 20):

20. Filling out an array of pointers to MJ functions.

Currently, SampleMJDispatch just returns STATUS_SUCCESS. A simple utility from Sysinternals called WinObj allows us to see the result (Fig. 21):

21. WinOdj utility showing created device object.

and (Fig. 22):

22. WinObj utility showing created application link.

Now let’s try to communicate with the driver. Let’s create a Win32 Console Application and execute the following code (Fig. 23):

23. Creating a simple test for the driver.

Put a breakpoint in the debugger on SampleMJDispatch and run the test. Below is a stack that shows how our request has reached the driver. In this case, the IRP_MJ_CREATE request was sent (Fig. 24):

24. Driver test successfully running in debugger.

By redefining certain functions from DriverObject->MajorFunction, we can write/read from the device and also execute specific requests.

A couple of words about error processing: Returning an error code from function is a common practice in the C language. The calling routine will check and process returned errors according to the code. To free resources in case of an error, a “goto” statement is used.

Microsoft includes a powerful tool for driver testing in Windows distributive called Driver Verifier (“verifier” command in Command Prompt). With it, you can detect deadlocks, memory leaks, improperly processed requests, etc. We strongly advise you to use it!

Conclusion

In this article, we covered only the very basics of software drivers and their development. “How are drivers for a computer written?” is a very broad and complex topic, as drivers are both hardware- and system-specific. Each device and each operating system presents its own set of challenges.

We encourage you to read further and experiment in a virtual environment. For Windows API, Microsoft Software Developer Network contains everything you want to know. And if you’re interested in Linux device driver development, you can find a good beginner-level tutorial here.

We encourage you to read further and experiment in a virtual environment. Good luck exploring the depths of driver creation!

Last reviewed and updated: 10 August 2020

You’re probably here looking at OSR.COM because you need to learn how to write or debug Windows drivers.  But if you’ve never written a driver for Windows before and you have a given task to accomplish (like supporting a particular piece of hardware or intercepting access to files), your first question is likely to be:  Where do I start?  Where can I find a good introduction to Windows drivers?  Are there any tutorials on writing Windows drivers that will be helpful to me?

One problem is that the world of Windows drivers is extremely varied.  You would write a device driver to support a specific piece of hardware, perhaps a USB device or a PCIe device.  You would write a Filter Manager Minifilter driver to implement on-access scanning of files (such as anti-virus products), activity monitors, and file replication, deduplication, or backup solutions.  While these are both “Windows drivers” they don’t have much in common with each other.

Fortunately, we’re here to help you figure things out.

Believe it or not, one of the most commonly asked questions we receive here at OSR is “How do I write a driver for Windows?” You’d think the answer would be simple. And sometimes it is. But, all too often, the answer is not only non-obvious, it’s fraught with complexity.

The answers to the question, “How do I write a driver for my device on Windows” come in three categories:

  • What you need to know
  • What development tools (and stuff) you need
  • What driver model to use

We’ll describe each of these in individual sections, below.

What You Need To Know

The things you need to know fall into two categories:

  • Personal background about the Windows operating system and devices that’ll allow you to readily learn about how to write Windows drivers.
  • Technical information about the hardware device you need to write your driver for (if you’re writing a driver for a hardware device).

Personal Knowledge

Items in the first category, personal background knowledge, are actually pretty simple. To be able to write drivers for Windows and not just frustrate yourself, you need to have at least general knowledge of computer operating systems and Windows in particular. You probably know most of what you need if you took a general OS Concepts class when you were in school. If you understand about devices, registers, interrupts, virtual memory, scheduling, multi-threaded programming, reentrancy, and concurrency issues… you’re more than half-way to being a driver developer. You can pick-up the Windows-specific information you need from doing a bit of reading. Please don’t skip this step. We spend almost two days in the 5-day driver seminar we teach here at OSR just discussing Windows OS and I/O subsystem architecture. So, it’s important.

Also, if you’re not familiar with programming on Windows systems from a user perspective (maybe you’ve been working in Linux all your life) it would also be helpful to know a bit about Windows I/O fundamentals.  Things like CreateFile, ReadFile, WriteFile, and asynchronous (that is “overlapped”) I/O.

If you need to brush-up on your OS concepts, would like to know more about Windows OS concepts in particular, or you’d like to learn more about how I/O is performed in Windows, we have some reading suggestions in the Sidebar labeled Understanding Windows OS and I/O Concepts. Doing that reading should set you up well for your task for writing Windows drivers.

Understanding OS Concepts and Windows Concepts
How do you learn basic OS concepts and Windows architecture?  There are a couple of good books to which we regularly refer our students.  These are:

Windows Internals 7th Edition — Part 1
(Yosifovich, Ionescu, Russinovich, and Solomon) (Microsoft Press)

This is the basic description of Windows OS Architecture. Everyone in the world of Windows has read it at some time. When you read the following chapters, you may just skip the exercises shown or try a few if they sound interesting to you… it’s your choice. –

  • Chapter 1: Concepts and Tools (whole chapter)
  • Chapter 2: System Architecture (whole chapter)
  • Chapter 3: System Mechanisms (Up to but not including section entitled Advanced Local Procedure Calls)

Windows System Programming 4th Edition
(Johnson M. Hart) (Addison-Wesley Microsoft Technology Series)

If you’re going to write device drivers, it probably makes sense to understand something about how to write Windows programs. If you’ve worked on Unix, and you’ve never written a program on a Windows system, this book will give you a lot of the information you’ll need.

  • Chapter 1: Getting Started With Windows (whole chapter)
  • Chapter 2: Using the Windows File System and Character I/O (whole chapter)
  • Chapter 4: Exception Handling (whole chapter)
  • Chapter 14: Asynchronous Input/Output and Completion Ports

One other thing you’ll need to know in terms of personal background is something about the hardware architecture that’s typical of the platform on which your hardware will be running. Whether the device you’re writing the driver for will run on PC (desktop to server) systems or used exclusively in an ARM SoC system, knowing something about the hardware environment – such as common buses and hardware concepts – that are unique to that platform would be valuable. You don’t need to know a lot. We’re not saying you need to be a hardware designer. We’re just saying knowing, for example, the basic concepts of PCIe or USB or SPI or whatever bus your device connects to will help speed you on your way as you write your driver.

About Your Hardware

If you’re writing a driver to support a hardware device on Windows, you’ll need the hardware specifications for the device you’ll be supporting. The information you need usually takes the form of a “data sheet” (which is often more like a book than a single sheet of paper) that describes the register-level interface to your device. Your hardware designer can give this to you. You need the specifics of your device, by the way. If the device you’ll be writing your driver for is implemented using some sort of PLD like an FPGA, don’t let your hardware designer simply point you off to the hardware spec for the PLD device (hardware designer waves her hand at you while saying: “Oh, we’re using an Arria II GX. Just go to Altera’s web site and download what you need. Bye.”). You need to know how the designer has implemented the register interface using the chosen PLD device, not the specs for the PLD itself.

Development Tools (and Stuff) You Need

In recent history, the tools used for Windows driver development have undergone nothing short of a revolution. Gone (well, mostly) are the days when you had to use special mystic project files and compile and link your code from the command line. Today, Windows driver development is fully integrated with Visual Studio.

Visual Studio + Windows Driver Kit

At the current time (May 2020, Windows 10 is the current version), the most recent version of the tools for Windows driver development is Visual Studio 2019.  You can even use Visual Studio 2019 Community Edition.  That means the “free for anybody to use” version of Visual Studio will work for driver development.

Once you have Visual Studio installed on your development machine, you’ll also need to install the Windows Driver Kit (WDK) add-in that supports driver development. This is a separate, but free (yay!), download from Microsoft (no MSDN subscription necessary). Search “Download the WDK” using your search engine of choice. Visual Studio and the WDK together provide everything you need to create driver projects, and to compile, link, and even debug Windows drivers. After you’ve successfully installed

Visual Studio and the WDK together provide everything you need to create driver projects, and to compile, link, and even debug Windows drivers.  After you’ve successfully installed Visual Studio and the WDK, you can very easily build a simple driver demo project.  You don’t even need any hardware!  Just select “New Project” and within Visual C++ select the Windows Driver project category.   Within this category select Kernel Mode Driver (KMDF).  Click OK and Visual Studio will generate a simple starter or demo driver project for you that doesn’t require any specific hardware.  This driver will successfully build, and can even be installed on a test machine.  Yup, it really is that simple.

Driver Testing

Ah, test machines. That’s probably something we should discuss. Driver development on Windows requires two Windows systems. One system where you run Visual Studio, do your development, and run the debugger. And a second, separate, system on which you run your driver. The Windows kernel debugger, running on your Development System, controls your Target System (where the driver you’re developing is running) via a remote connection that can be either be the network or a serial port (there are other options, but they are less common or “have issues”).

If you think about it, this makes good sense: Driver and hardware errors can quite easily destabilize or even crash a system. So you certainly don’t want to be running your new and potentially buggy driver on the same system on which you’re editing your source code files and doing your development.

In many cases, the second system can be a virtual machine. Using a virtual machine is acceptable when you’re writing a driver (such as a filter driver or a Filter Manager file system Minifilter) that doesn’t directly access any hardware. But if your driver talks to real hardware, you’ll need a real, physical, second machine to use as your Target System. This is generally good practice even when you’re building a device driver for something like a USB device, when the VM host you’re using allows you to assign access to the device exclusively to a given VM.

Debugging: You Want to Use WinDbg

We mentioned the Windows kernel debugger. This debugger is named WinDbg (which almost everyone pronounces as “wind bag”, by the way). The debugger is included in the Windows Driver Kit and is automatically installed on your system when you install the WDK. It’s the debugger you’ll use as part of developing and testing your driver. It’s very similar to the user-mode debugger in Visual Studio, and has most of the same features.

There are several options available for using WinDbg for debugging your driver. One option is to use WinDbg directly within Visual Studio, through the interface provided by the WDK. While this pretty much works, here at OSR we don’t recommend this. Our experience is that trying to use WinDbg from within Visual Studio creates more complications than it’s currently worth. Instead, we recommend that you run WinDbg directly from your development machine, outside of Visual Studio. This allows you to use Visual Studio for driver development, which is what it’s best at, and use WinDbg directly for debugging, which is what WinDbg is best at.

Before you can use WinDbg to debug your driver, you’ll need to enable kernel debugging on the target system (if you’re writing a kernel-mode driver). Fortunately, it’s easy and very well documented (thank you, WDK doc writers). Search “Setting Up Kernel-Mode Debugging Manually” in your search engine of choice for the steps.

One quick note about debugging. Do not, under any circumstances, try to develop your driver without setting up WinDbg. For some reason, there are folks who’ve been fooled into thinking they can use something like the Microsoft DebugView utility, which allows DbgPrint statements (the kernel-mode equivalent of printf or OutputDebugString) from your driver to be viewed on your system, as their sole tool for driver development. While DebugView can be useful at times, we can guarantee that it is no substitute for having a debugger that allows you to set breakpoints, single step, and change the contents of structure fields and local variables. While setting up WinDbg for the first time can sometimes be annoying, we promise you it’ll be worth your effort in the long run. Yay, WinDbg!

Getting Your Driver Onto Your Test Machine

If you play around with the WDK and Visual Studio a bit, you might notice that the WDK adds features to Visual Studio for “deployment” and “testing” for your driver.  Ignore this.  Please.  It’s soooo much easier just to copy your driver to your test machine on your own than trying to reliably get the WDK to do this for you from within Visual Studio.

The first time (and ONLY the first time) you copy your driver onto your Test Machine, install it using the INF file.  After that, when you want to update your driver, just copy the new driver executable (the driver’s .SYS file) over the old one (drivers are traditionally stored in \Windows\System32\Drivers).  Then disable and re-enable your device using Windows Device Manager.  When you re-enable your device, the new version of your driver will be loaded.  If you have trouble copying your new driver directly over your old one, rename the old driver (rename myDriver.sys myDriver.OLD) and then copy the new one. This is what we do at OSR (or we use “.kdfiles -m” in WinDbg, which is also a great trick).

This is another one of those things where you’re just going to have to trust me, and thank me later.

Getting Debug Messages

Also: You’ll notice the WDF template drivers (and most of the WDK sample drivers as well) output debug information using something called WPP Tracing.  WPP Tracing is fine… but it’s really designed for collecting driver info from the field, once your driver has been released.  During development, you will be MUCH happier if you just use DbgPrint… which just writes output directly to the WinDbg Command Windows (like printf or OutputDebugString).  Once you’ve got your driver working, if you decide you want to be able to collect trace information from your driver after release, it’s pretty easy to convert your DbgPrint statements to WPP Tracing.  That’s what we teach our students to do, and it’s what we do here at OSR.

If this doesn’t immediately work, fear not!  You have to enable DbgPrint output on your target machine.  It’s super easy:  See the instructions here.

Driver Samples

The final thing you’ll definitely want are the Windows Driver Kit Samples. These are example drivers, provided by Microsoft, that demonstrate how to write drivers of various kinds. They’re just like the typical sample code you download from just about anywhere: They are very useful and highly instructive, even if some of the code provided isn’t always exactly “the best.” Samples are provided for all sorts of hardware drivers, filter drivers, and software-only drivers. Heck, they even give you the source code to a few of the drivers that are part of the Windows OS… including sources for the FAT file system.

The samples are hosted on GitHub (yes, really) and available for download. Search “Windows driver samples github” from your search engine of choice. You can download specific samples individually, or you can download the entire ZIP archive (about 140MB when we last checked, including more than 160 sample drivers). We recommend you download the complete archive. Take your time and look through the samples. This will be time well spent.

So… now you have the background info you need, and you have all the stuff you need to develop Windows drivers. What’s the next step?

What Driver Model to Use

The actual development of a Windows driver starts with choosing what “driver model” to use for your driver’s implementation. Many folks find this step confusing. A driver model is an overall driver organization, including a set of APIs and entry points, which you’ll use when you write your code. Unlike some other operating systems that support a small number of driver models (“block” and “character”, for example) Windows has a wide number of driver models. The best driver model to choose is based on as many as three things. These are:

  • The type of driver you’re writing: Hardware device, filter, or some other kind.
  • If you’re writing a driver for a hardware device, the category (storage controller, sound card, graphics adapter, network card) of device.
  • Developer preference

Now hear this: The choice of a driver model is the most important decision you’ll make about how your driver will be developed. And it’s a place where many people make the wrong decision and “go off the rails” – making their project much harder than it needs to be. So take some time to make this decision. Don’t simply Google around and find some trash example lying on a web site somewhere and start to hack it. Make the decision thoughtfully.

General Purpose Models

Broadly speaking, there are two Windows driver models that apply for general use, and some Windows driver models that apply to specific devices. For example, if you’re writing a driver for a local area network card, Windows has a specific model that is tailored specifically for this use and makes it maximally convenient to implement this type of driver. Likewise, if you’re writing a driver that supports streaming audio or streaming video, Windows has a specific model for these types of drivers. These are only two simple examples. Windows has specific models for lots of other device types as well.

Lacking a specific model for your device type, you can use one of the general-purpose models. The first general-purpose model is the Windows Driver Model (WDM). WDM is the old, historic, model for writing Windows drivers. Nobody should use this model anymore for writing new Windows drivers. Seriously. Nobody. It’s hard to use and filled with “traps” that have evolved over years to support backward compatibility guarantees. Trying to write a new WDM driver in the 21st Century will do nothing but make you hate life. Don’t do it. Enough said?

Much preferred over WDM is the Windows Driver Foundation (WDF). This is the second general-purpose driver model that Windows supports. WDF is a modern, pleasant, and (dare I say it) almost easy to use method for writing Windows drivers. Unless there’s a specific model that Microsoft recommends for the device, filter, or software-only driver you need to write, you’ll want to use WDF.

One interesting thing about WDF is that it actually comes in three flavors, called Frameworks:

  • Kernel Mode Driver Framework – KMDF
  • User Mode Driver Framework V1 – UMDF V1.x
  • User Mode Driver Framework V2 – UMDF V2.0 (only applies to Windows 8.1 and later)

KMDF is the Kernel Mode Driver Framework. This is the model you’ll almost certainly want to use now and in the near future for any general-purpose Windows driver development project.

You’ll notice that there are two WDF Frameworks that allow you to write drivers in user-mode. Writing drivers in user mode is good, because if there’s a bug in your driver (let’s say, you deference a null pointer) your user-mode driver won’t crash the system the way it would if you wrote your driver in kernel mode. That’s certainly a very good thing, and contributes to nothing but customer satisfactions. So, why didn’t we recommend using UMDF for writing your drivers?

Using UMDF today is a bit of a problem. UMDF V1 is the older model. It’ll support devices running on Windows versions as old as Windows XP. But UMDF V1 uses an odd, difficult, programming pattern that’s based on COM (yes, the Component Object Model… that COM). Add to that the fact that UMDF V1 has been put in “end of life” status by Microsoft, and you get a model that most people will want to avoid.

UMDF V2.0 is actually a pretty terrific driver model. It uses 99% the same syntax as KMDF, but it runs in user mode, thus contributing to overall system stability. So why don’t we recommend using UMDF V2.0 today? Because UMDF V2.0 is currently only supported on Windows 8.1 or later.  To be absolutely clear, this means that if you write a UMDF V2 driver, that driver can only be installed on systems that are running Windows 8.1 or more recent versions of Windows. In short, unless you only need to support Windows 8.1 or more recent systems, UMDF V2 isn’t really a viable choice. On the other hand, if you do only need to support Windows 8.1 or later (I don’t know, maybe you’re writing a driver for some sort of embedded system) then UMDF V2.0 could be a very good choice indeed.

Choosing the Best Model for Your Project

Confused? It wouldn’t be surprising if you are. We told you many people find this driver model stuff confusing. Fortunately, there are some simple rules that can help you decide the best driver model for your use. Here are those rules:

  • Writing a driver for a hardware device? Check the Windows Hardware Certification Requirements for the type of device that you’ll be supporting. To do this, search for “Windows Hardware Certification Requirements: Devices”. If the type of device you’ll be supporting is listed, the Certification Requirements document will almost always specify the driver model you must use. Note that this guidance applies even if you don’t plan to apply for Windows Hardware Certification for your device and driver. The Certification Requirements will almost always point you in the direction of the best, easiest, most modern, and most supportable driver model that applies to your type of device.
  • Writing a filter driver for a device? A filter driver in Windows is a type of driver that monitors I/O operations going to a given device/driver in the system and intercepts those I/O operations. The purpose for intercepting those I/O operations might be to track them, measure them, or modify them. If you’re writing a filter for file systems (like for an antivirus product) or networks (such as you would write for a firewall product), there are specific driver models defined for these uses.
  • Writing a driver that intercepts file system operations?   This type of driver might be used for antivirus scanning, creating a hierarchical storage system, or even for file deduplication, replication or backup.  In this case, you want to write a Filter Manager Minifilter driver.  This is a special model designed precisely for these purposes.
  • Writing a software-only driver? For example, maybe you need to write a driver that collects data in kernel-mode. In this case, you probably want to write a software-only driver. Using what’s called the “legacy NT model” is probably your best option. But you also have the option of using KMDF for this type of driver.
  • Are you writing a media-based or network-based file system? Stop reading now. You almost certainly do not want to write a Windows file system. It’s really difficult. We know, because it’s one of the things we’ve done over the years here at OSR. Send us email. We’ll see if we can talk you out of it, and if not we’ll point you in the right direction. Seriously. No charge.
  • None of the previous steps pointed you to a specific model? Do you need to support systems older than Windows 8.1? If you only need to support Windows 8.1 or later, the best model for you to use is probably UMDF 2.0. If you need to support systems older than Windows 8.1, then your best choice of driver model is probably KMDF.

There are a number of factors that contribute to the decision of which driver model is best for you. You can read more about this on MSDN. Search for the page titled “Choosing a driver model”. For the reasons we described above, we recommend for the present time you ignore Microsoft’s advice about preferring UMDF. UMDF V2.0 will be a great choice when it supports the majority of systems in the field (either because Microsoft decides to support UMDF 2 on systems older than Windows 8.1 or everyone is running Windows 8.1 or later). But until that time, everywhere you see UMDF recommended we suggest you choose KMDF instead.

You’ve Chosen a Model: Now What?

Once you understand the driver model to use, you have to figure out how to write a driver using that model. The WDK documentation is pretty good (and getting better all the time), it’s not exactly a good tutorial for beginners.  The available books on Windows driver development are either lacking in practical detail, wrong, out of date, or all three.  While you could learn on your own, with trial and error and help from news groups, this is where we here at OSR can help!

You might have gathered from this article that driver development isn’t much like application programming.  It doesn’t readily lend itself to grabbing an example, learning just enough to modify it, and then call it done.  So, why spend the time and effort (it will be weeks, at least) trying to learn this discipline on your own when you have an expert resource you can use?

Seriously consider taking an OSR seminar.  We teach all of our most popular seminars (WDF, Internals and Software Drivers, Kernel-Mode Debugging and Crash Analysis, and even Windows File System Minifilters) multiple times a year online, with a live instructor, so you can participate from locations all over the world.  Invest a week of your time, and a few dollars of your company’s money, and avoid the annoyance of learning by trial-and-error.  At an OSR seminar, you’ll learn from real developer/instructors who have written dozens (if not literally hundreds) of real, shipping, production Windows drivers.  They’ve made just about every possible mistake, so they can help you avoid those same issues.  At a OSR seminar, you’ll get started on the right path and avoid many of the pitfalls that are common to new driver developers.

About Online Examples and Samples

Remember what we said earlier, that writing a Windows driver (any type of driver) is quite a bit more complicated than writing an application?  It’s hard to take a sample and start hacking it into becoming your solution.  And, when you do choose a sample from which to start, you need to choose that sample carefully or you won’t end up with a decent solution.

Sadly, it’s been our experience that (except for Microsoft’s own WDK samples on GitHub) most of the online samples that you’ll find are badly out of date, badly designed, or really terribly implemented.  Sorry, but it’s the truth.  CodeProject and Stack Overflow have their place… but that place is unfortunately not the world of Windows drivers.  So, when it comes to samples, please stick to the current WDK samples that live on GitHub.  It’ll almost certainly save you lots of annoyance.

Questions?  Where Do You Get Help?

You might be pleased to discover that there’s a vibrant, and helpful, online community for driver developers, where you can post questions here and get answers.  Check out the OSR Developer Community.

In Summary

That’s how you get started writing Windows drivers. Learn a bit about Windows architecture, get the tools, and choose a model for your driver. Maybe take one of our seminars… we think that’d be a good idea, too.  Of course, there are lots of things we haven’t discussed in this short article. We haven’t discussed how to install your driver (you write something called an INF file), specific techniques for driver development with any of the models, or strategies for debugging your code.  We talk about these types of things regularly in The NT Insider (our free journal on Windows internals and software development issues) — Sign up (for free) and we’ll let you know each time it’s published.

We hope the above has been useful, and provided a place to start. Happy driver writing!

Понравилась статья? Поделить с друзьями:
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
  • Ошибка 0x00004005 при подключении сетевого принтера windows 10
  • Convert webm to mp4 windows
  • Вы сейчас не можете запросить сертификат так как типы сертификатов недоступны windows server
  • Как сделать хостинг для сайта на своем компьютере windows
  • Openoffice для windows vista