Небольшая предыстория
Совсем недавно я перешёл на 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 приложений и попробовать что-то новое. Критика и дополнения приветствуются, постараюсь по возможности оперативно исправлять все ошибки. Теперь точно всё 🙂
Установка компонентов
-
В данной статье я не буду рассматривать вопрос о создании Hello world драйвера, этот вопрос полностью разобран тут. Рекомендую чётко следовать всем указаниям этого руководства, для того чтобы собрать и запустить свой первый драйвер. Это может занять некоторое время, но как можно достигнуть цели не пройдя никакого пути? Если у вас возникнут проблемы с этим процессом, я с радостью помогу вам в комментариях.
-
Полезно будет установить утилиту WinObj, которая позволяет вам просматривать имена файлов устройств и находить символические ссылки, которые на них указывают. Качаем тут.
-
Также неплохой утилитой является 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); }
При запуске вы должны получить такой результат:
Заключение
Собственно, на этом всё. В конечном итоге мы получили драйвер, способный принимать и отвечать на запросы пользовательских программ. Дальнейшие модификации проекта ограничиваются лишь вашей фантазией.
ссылка на оригинал статьи https://habr.com/ru/articles/761512/
Добавить комментарий