Ознакомьтесь с предыдущими частями «Введение в IRQL» и «Объекты драйверов».
Пробежавшись по макушкам фундаментальных основ, пришло время заняться практикой. Начнём, пожалуй, с Legacy, которых и по сей день водится с избытком в современных ОС. Как упоминалось ранее, они отличаются от WDM тем, что не поддерживают технологию Plug&Play, хотя в остальном нисколько не уступают последним. Другими словами, Legacy требуют ручной установки, не дружат с программным управлением питания, ну и так по мелочам. На Win7 можно было в меню «Диспетчера устройств» выбрать «Показать скрытые», и получить увесистый их список. Но начиная с Win10 единую базу убрали, а девайсы распихали по соответствующим пунктам, спрятав от нас подальше. Таким образом тема не-PnP-драйверов актуальна, а потому стоит уделить ей внимание.
Обосновано-ли использование ассемблера для драйверов?
Несмотря на то, что львиная доля ядра Ntoskrnl.exe написана на чистом Си и плюсах, это всё-же языки высокого уровня. В системе достаточно мест, где они не смогут справиться с задачей по определению, например прямое взаимодействие с регистрами ЦП. Поэтому отдельные части диспетчера памяти и планировщика потоков написаны именно на ассме, где он и выражает себя в полной мере. Ну и конечно это размеры бинарей на выходе из компилятора, которые будут как-минимум в 2 раза меньше сишных. Но есть и минусы – отсутствует реализованный на стороне компилятора сборщик мусора, автоанализ ошибок, заголовки с описанием структур, и прочее.
Кстати, как утверждает руководитель отдела Azure в Microsoft М.Руссинович, начиная с Win11 разрабы отказываются уже и от сишки, в пользу самобытного языка Rust. Написанные на нём модули можно найти по суффиксу(_rs) в именах, например так:
В этой части:
1. Структура файлов *.sys
Формат двоичных файлов драйвера Win ничем не отличается от привычных нам исполняемых *.exe, поскольку у них один прародитель РЕ (Portable-Executable). Если вскрыть их в любом РЕ-вьювере, можно обнаружить те-же секции кода/данных/импорта/экспорта и прочих. Просто в поле «Subsystem» опционального заголовка выставляется флаг «Native=1».
Второй момент – это текстовые строки. Дело в том, что ядро понимает только Unicode, хотя бывают и редкие исключения (например теги в пулах памяти, или отладочные сообщения). В приложениях юзера ANSI-строки адресуются явно, а здесь нужно заворачивать их в структуру UNICODE_STRING, и передавать получателю адрес этой структуры:
В ядре имеются более 50 функций, которые ожидают в аргументах линки на данную структуру – найти их в отладчике можно по маске
Ну и в третьих – это невыгружаемые в своп критически важные секции кода/данных, а у остальных иногда выставляется атрибут «Discardable» (не нужны после загрузки образа в память). Например релоки загрузчик использует для правки абсолютных адресов, а по окончании этого процесса к секции
На запрос
2. Точка входа DriverEntry()
Процедура DriverEntry() первой получает управление от диспетчера ввода-вывода.
Её код должен проделать следующие операции:
Поскольку DriverEntry() выполняется в контексте системного потока «System», она не должна занимать много времени, и проделав на одном дыхании 5 этих пунктов, тут-же отчитаться перед диспетчером. С этого момента ОС отправляет дров в пул неактивных потоков в наборе «WorkingSet», и он даже не попадает в очередь планировщика. То-есть наступает полный штиль, до прихода на его имя запроса IRP (io-request-packet).
У процедуры 2 аргумента: в первый диспетчер передаёт линк на созданную им где-то структуру DRIVER_OBJECT, а во второй – указатель на куст в реестре, куда дров может сохранить свои настройки (пока нам не нужен). Вот пример реализации сказанного:
Как видим нет ничего особенного – главное обзавестись инклудом с описанием полей в структурах, от чего я вас избавил прицепив его в скрепку к статье. Теперь если запустить утилиту WinObj из пакета «SysinternalsSuite» Руссиновича (у меня Explorer от Four-F), можно с удивлением обнаружить в ней наш девайс «Codeby». По этому имени позже мы сможем открыть его на чтение/запись юзер-функцией CreateFile().
3. Процедуры обслуживания запросов DispatchRoutine
..это то, что делает из драйвера функциональное устройство FDO, и вбивает клин между техникой программирования в режиме пользователя, и в режиме ядра. Книжки, которые мы читали до этого можно смело спрятать в стол, поскольку кодинг в ядре очень далёк от безмятежной юзермоды. Малейшие ошибки сразу оборачиваются здесь в BSOD, а всех нюансов и не перечесть. То, что сейчас работает, через час может вообще не запускаться, т.к. всё зависит от обстановки именно на текущий момент.
Такой расклад приводит к тому, что отлавливать баги приходится по несколько дней, и хорошо, если это даст положительный результат. Ловля блох методом исключений даёт отличный профит на практике, т.е. добавили первый функционал – потестили, потом второй ..третий, исключая таким образом потенциальных виновников. Именно по этой тропе мы и проследуем с вами.
За некоторым исключением, почти все процедуры принимают от диспетчера 2 аргумента – это указатель на структуру DEVICE_OBJECT устройства, а так-же указатель на пакет запроса IRP, который собственно и адресован драйверу. В последнем хранится буквально вся информация о запросе на ввод-вывод:
Прочитав поле
Самая простая (и важная) процедура из всех – это Unload().
Как и следует из её названия, она озадачена выгрузкой образа драйвера из системной памяти. Код процедуры должен отменить всё, что было проделано внутри DriverEntry(). Если в своём примере я создавал ссылку при помощи IoCreateSumbolicLink(), значит должен затереть её чз IoDeleteSumbolicLink(), и т.д.
Кстати помимо официального способа выгрузки драйверов (посредством регистрации процедуры Unload) есть ещё и топорный – просто возвращаем диспетчеру в/в
Здесь я планирую обрабатывать только запросы IRP_MJ_CREATE/CLOSE/CONTROL – это стандартный минимум для Legacy драйверов, хотя в WDM обязательны ещё POWER+PNP. Когда софт пользователя откроет девайс «Codeby» чз CreateFile(), диспетчер пошлёт IRP в наш коллбек MJ_CREATE, а у драйвера появится возможность отловить этот момент. Дальнейшие действия зависят уже от назначения и функциональных особенностей драйвера, я-же просто выведу мессагу DbgPrint(). Аналогично и с процедурой MJ_CLOSE, только она перехватывает юзерскую CloseHandle() при освобождении дескриптора устройства.
На выходе нужна функция IoCompleteRequest() – сигнал диспетчеру, что «в Багдаде всё спокойно» и мы успешно обработали запрос. В модели WDM её аргумент
Если заглянуть сейчас во-вьювер «DeviceTree», он покажет паспорт нашего мини-драйвера, в том числе: базу загрузки образа, его размер в памяти, обрабатываемые коды IRP_MJ_xx, отсутствие процедуры AddDevice(), и факт его монолитности, поскольку ни к кому не приаттачен в стеке драйверов.
4. Ожидание команд от юзера DeviceIoControl()
И вот со скоростью черепахи мы добрались до основной процедуры драйвера CodebyControl() – я специально оставил её на дессерт. Она реагирует на вызов DeviceIoControl() юзера, и ожидает в аргументе код самой операции IOCTL. Этот красавец с мускулатурой бодибилдера способен на многое, и является основным интерфейсом для взаимодействия с драйверами из пользовательского режима. Вот прототип этой Win32-API:
После успешного вызова этой API, драйверу придёт такая инфа:
Особого внимания заслуживает здесь 32-битный код операции, который состоит аж из шести частей.
В старшей половине с битами [31:16] кодируется тип устройства, которому адресуется запрос. В системе зарегано 64 типа с константами
Таким образом, чтобы обратиться к своему устройству «Codeby» из юзермоды, в аргументе DeviceIoControl() я должен указать
В своём коллбеке CodebyControl() и беру из структуры IO_STACK_LOCATION переданный мне юзером код операции, и по его значению могу выполнять различные задачи. В данном случае юзер посылает только
Собрав всё вместе получаем следующую картину где видно, что драйвер благополучно похукал вызовы всех Win32API на стороне юзера Create/IoControl/Close, после чего диспетчер выгрузил его тушку из системной памяти ядра. Обратите внимание на адрес пакета
5. Заключение
По некоторым причинам я проводил тесты на вирт.машине WinXP x32, хотя переделать исходник под х64 не составит особого труда. Тут главной целью было донести до читателя суть, а реализация уже дело второе. Позже рассмотрим организацию драйверов WDM, работу с памятью, и физическими устройствами, а пока интриги больше нет – расходимся. В скрепку положил пример для компиляции ассемблером FASM, инклуды с основными структурами ядра для х32/64, а так-же софт, который использовал в данной статье. Всем удачи, пока!
Пробежавшись по макушкам фундаментальных основ, пришло время заняться практикой. Начнём, пожалуй, с Legacy, которых и по сей день водится с избытком в современных ОС. Как упоминалось ранее, они отличаются от WDM тем, что не поддерживают технологию Plug&Play, хотя в остальном нисколько не уступают последним. Другими словами, Legacy требуют ручной установки, не дружат с программным управлением питания, ну и так по мелочам. На Win7 можно было в меню «Диспетчера устройств» выбрать «Показать скрытые», и получить увесистый их список. Но начиная с Win10 единую базу убрали, а девайсы распихали по соответствующим пунктам, спрятав от нас подальше. Таким образом тема не-PnP-драйверов актуальна, а потому стоит уделить ей внимание.
Обосновано-ли использование ассемблера для драйверов?
Несмотря на то, что львиная доля ядра Ntoskrnl.exe написана на чистом Си и плюсах, это всё-же языки высокого уровня. В системе достаточно мест, где они не смогут справиться с задачей по определению, например прямое взаимодействие с регистрами ЦП. Поэтому отдельные части диспетчера памяти и планировщика потоков написаны именно на ассме, где он и выражает себя в полной мере. Ну и конечно это размеры бинарей на выходе из компилятора, которые будут как-минимум в 2 раза меньше сишных. Но есть и минусы – отсутствует реализованный на стороне компилятора сборщик мусора, автоанализ ошибок, заголовки с описанием структур, и прочее.
Кстати, как утверждает руководитель отдела Azure в Microsoft М.Руссинович, начиная с Win11 разрабы отказываются уже и от сишки, в пользу самобытного языка Rust. Написанные на нём модули можно найти по суффиксу(_rs) в именах, например так:
Код:
C:\>cd windows\system32
C:\Windows\System32> dir win32k* <--------// или: dir *_rs*
Том в устройстве C имеет метку SYSTEM
Серийный номер тома: 5C91-038E
Содержимое папки C:\Windows\System32
21.11.2019 08:24 3 126 272 win32k_rs.sys <------//
C:\Windows\System32>
В этой части:
- Структура драйвера
- Точка входа DriverEntry()
- Базовые процедуры DispatchRoutine
- Ожидание команд от юзера DeviceIoControl()
- Выводы
1. Структура файлов *.sys
Формат двоичных файлов драйвера Win ничем не отличается от привычных нам исполняемых *.exe, поскольку у них один прародитель РЕ (Portable-Executable). Если вскрыть их в любом РЕ-вьювере, можно обнаружить те-же секции кода/данных/импорта/экспорта и прочих. Просто в поле «Subsystem» опционального заголовка выставляется флаг «Native=1».
Код:
;//---- Subsystem --------
IMAGE_SUBSYSTEM_UNKNOWN = 0 ;// Неизвестная подсистема
IMAGE_SUBSYSTEM_NATIVE = 1 ;// Драйверы устройств и собственные процессы Win
IMAGE_SUBSYSTEM_WINDOWS_GUI = 2 ;// Подсистема графического интерфейса (GUI)
IMAGE_SUBSYSTEM_WINDOWS_CUI = 3 ;// Подсистема консоли Windows (CUI)
IMAGE_SUBSYSTEM_EFI_APPLICATION = 10 ;// Приложение EFI (Extensible Firmware Interface)
IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER = 11 ;// Драйвер EFI с загрузочными службами
IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER = 12 ;// Драйвер EFI со службами времени выполнения
IMAGE_SUBSYSTEM_EFI_ROM = 13 ;// Образ EFI ROM
IMAGE_SUBSYSTEM_XBOX = 14 ;// Xbox
IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION = 16 ;// Загрузочное приложение Windows.
Второй момент – это текстовые строки. Дело в том, что ядро понимает только Unicode, хотя бывают и редкие исключения (например теги в пулах памяти, или отладочные сообщения). В приложениях юзера ANSI-строки адресуются явно, а здесь нужно заворачивать их в структуру UNICODE_STRING, и передавать получателю адрес этой структуры:
C-подобный:
struct UNICODE_STRING
Length dw 0 ;// реальная длина строки
MaximumLength dw 0 ;// длина строки +2 (терминальный нуль)
Buffer dq 0 ;// указатель на строку в памяти
ends
В ядре имеются более 50 функций, которые ожидают в аргументах линки на данную структуру – найти их в отладчике можно по маске
x nt!*UnicodeString*
. Это сравнение строк, преобразование из Unicode в ANSI, или из текста в число, и многое другое. Но в данном случае нам понадобится лишь RtlInitUnicodeString(), которая заполнит поля структуры валидными значениями:
Код:
0: kd> x nt!*UnicodeString*
fffff800`02cddd00 nt!RtlInitUnicodeString
fffff800`02f84b90 nt!RtlCreateUnicodeString
fffff800`02f84c38 nt!RtlFreeUnicodeString
fffff800`02fef770 nt!RtlUnicodeStringToAnsiString
fffff800`02fb55c8 nt!RtlAnsiStringToUnicodeString
fffff800`02f676b8 nt!RtlIntegerToUnicodeString
fffff800`02f69ec0 nt!RtlUnicodeStringToInteger
fffff800`02fb5d8c nt!RtlInt64ToUnicodeString
fffff800`02f67010 nt!RtlCompareUnicodeString
fffff800`02ce3340 nt!RtlCopyUnicodeString
fffff800`02f679f0 nt!RtlHashUnicodeString
.............
0: kd>
Ну и в третьих – это невыгружаемые в своп критически важные секции кода/данных, а у остальных иногда выставляется атрибут «Discardable» (не нужны после загрузки образа в память). Например релоки загрузчик использует для правки абсолютных адресов, а по окончании этого процесса к секции
.reloc
никто не обращается от слова совсем. Чтобы она не болталась без дела в памяти, её без последствий можно просто затереть новыми данными. Аналогичная ситуация и с секцией INIT
, которая представляет собой запакованный архив – после анпака можно без зазрения совести отправить её в корзину.На запрос
!dh
(dump header) отладчик сбрасывает в лог РЕ-заголовок файла, ожидая аргументом базу образа в памяти (см. lmsm), при этом ключ /f
оставляет в логе только информацию о заголовке, /s
только секции, а в дефолте (без ключей) и то и другое:
Код:
0: kd> lmsm m i*
start end module name
fffff880`03bcf000 fffff880`03bed000 i8042prt (deferred)
fffff880`0161b000 fffff880`01625000 IaNVMeF (deferred)
fffff880`00e89000 fffff880`00e91000 intelide (deferred)
fffff880`00e40000 fffff880`00e4a000 iusb3hcs (deferred)
0: kd> !dh /f fffff880`03bcf000
FILE HEADER VALUES
8664 machine (х64)
8 number of sections
4A5BC11D time date stamp Tue Jul 14 04:19:57 2009
F0 size of optional header
22 characteristics
Executable
App can handle >2gb addresses
OPTIONAL HEADER VALUES
20B magic #
9.00 linker version
15600 size of code
4400 size of initialized data
18070 address of entry point
----- new -----
00010000 image base
1000 section alignment
200 file alignment
1 subsystem (Native) <----------------//
6.01 operating system version
1E000 size of image
400 size of headers
25E29 checksum
0000000000040000 size of stack reserve
0000000000001000 size of stack commit
0000000000100000 size of heap reserve
0000000000001000 size of heap commit
0: kd>
2. Точка входа DriverEntry()
Процедура DriverEntry() первой получает управление от диспетчера ввода-вывода.
Её код должен проделать следующие операции:
- IoCreateDevice() – создать подчинённое устройство.
- IoCreateSymbolicLink() – зарегистрировать устройство в пространстве имён, чтобы можно было найти его по имени.
- В поле «DriverUnload» структуры DRIVER_OBJECT записать указатель на процедуру выгрузки драйвера из памяти.
- В массив «IRP_MJ_xx» структуры DRIVER_OBJECT записать линки на процедуры обслуживания запросов на в/в (выборочно).
- Если всё прошло успешно, в регистре EAX вернуть диспетчеру значение
STATUS_SUCCESS=0
.
DRIVER_OBJECT. DriverExtension.AddDevice
, где и должен лежать указатель на процедуру добавления устройства (создав, она сразу аттачит девайс в стек драйверов). Но сейчас мы во власти Legacy и драйвер у нас монолитный, а потому AddDevice() не нужна.
Код:
0: kd> !drvobj i8042prt 3
Driver object (fffffa8002ad9e70) is for: \Driver\i8042prt
Device object list: fffffa8002adc640, fffffa8002aae3d0
DriverEntry: fffff88001033070 i8042prt!GsDriverEntry
DriverStartIo: fffff8800101d6f8 i8042prt!I8xStartIo
DriverUnload: fffff8800102fae0 i8042prt!I8xUnload
AddDevice: fffff8800102ef80 i8042prt!I8xAddDevice <--------//
Поскольку DriverEntry() выполняется в контексте системного потока «System», она не должна занимать много времени, и проделав на одном дыхании 5 этих пунктов, тут-же отчитаться перед диспетчером. С этого момента ОС отправляет дров в пул неактивных потоков в наборе «WorkingSet», и он даже не попадает в очередь планировщика. То-есть наступает полный штиль, до прихода на его имя запроса IRP (io-request-packet).
У процедуры 2 аргумента: в первый диспетчер передаёт линк на созданную им где-то структуру DRIVER_OBJECT, а во второй – указатель на куст в реестре, куда дров может сохранить свои настройки (пока нам не нужен). Вот пример реализации сказанного:
C-подобный:
format PE native
entry DriverEntry
include 'win32ax.inc'
include 'equates\wdm32.inc'
;//*******************
section '.data' data readable writeable notpageable ;//<---- атрибут невыгружаемой секции
DevName du '\Device\Codeby',0 ;// имя устройства (unicode)
linkName du '\??\Codeby',0 ;// ссылка на него
pDevName UNICODE_STRING
plinkName UNICODE_STRING
pDevObj dd 0 ;// IoCreateDevice() вернёт сюда указатель на DEVICE_OBJECT
Status dd 0 ;// переменная под статус операции
;//*******************
section '.text' code readable executable notpageable
proc DriverEntry, pDrvObject, pRegPath
;// Оформляем строки в юникод
invoke RtlInitUnicodeString, pDevName, DevName
invoke RtlInitUnicodeString, plinkName, linkName
cinvoke DbgPrint,<'Init string OK!',0>
;// Cоздаём вирт. псевдо-устройство
invoke IoCreateDevice,[pDrvObject],0,pDevName,\
FILE_DEVICE_UNKNOWN,\
FILE_DEVICE_SECURE_OPEN,0,pDevObj
or eax,eax
je @f
mov [Status],eax
cinvoke DbgPrint,<'CreateDevice error: %08x',0>,eax
jmp @exit
@@: cinvoke DbgPrint,<'CreateDevice OK! %08x',0>,[pDevObj]
;// Cоздаём ссылку на устройство в пространстве имён
invoke IoCreateSymbolicLink, plinkName, pDevName
or eax,eax
je @f
mov [Status],eax
cinvoke DbgPrint,<'Create link ERROR! %08x',0>,eax
jmp @exit
@@: cinvoke DbgPrint,<'Create link OK!',0>
;// Регистрируем нужные процедуры Dispatch
mov esi,[pDrvObject]
mov [esi + DRIVER_OBJECT.DriverUnload] , CodebyUnload
mov [esi + DRIVER_OBJECT.IRP_MJ_CREATE], CodebyCreate
mov [esi + DRIVER_OBJECT.IRP_MJ_CLOSE] , CodebyClose
mov [esi + DRIVER_OBJECT.IRP_MJ_DEVICE_CONTROL],CodebyControl
;// Инициализация закончена - посылаем диспетчеру Ok!
mov [Status],STATUS_SUCCESS
@exit: mov eax,[Status]
ret
endp
Как видим нет ничего особенного – главное обзавестись инклудом с описанием полей в структурах, от чего я вас избавил прицепив его в скрепку к статье. Теперь если запустить утилиту WinObj из пакета «SysinternalsSuite» Руссиновича (у меня Explorer от Four-F), можно с удивлением обнаружить в ней наш девайс «Codeby». По этому имени позже мы сможем открыть его на чтение/запись юзер-функцией CreateFile().
3. Процедуры обслуживания запросов DispatchRoutine
..это то, что делает из драйвера функциональное устройство FDO, и вбивает клин между техникой программирования в режиме пользователя, и в режиме ядра. Книжки, которые мы читали до этого можно смело спрятать в стол, поскольку кодинг в ядре очень далёк от безмятежной юзермоды. Малейшие ошибки сразу оборачиваются здесь в BSOD, а всех нюансов и не перечесть. То, что сейчас работает, через час может вообще не запускаться, т.к. всё зависит от обстановки именно на текущий момент.
Такой расклад приводит к тому, что отлавливать баги приходится по несколько дней, и хорошо, если это даст положительный результат. Ловля блох методом исключений даёт отличный профит на практике, т.е. добавили первый функционал – потестили, потом второй ..третий, исключая таким образом потенциальных виновников. Именно по этой тропе мы и проследуем с вами.
За некоторым исключением, почти все процедуры принимают от диспетчера 2 аргумента – это указатель на структуру DEVICE_OBJECT устройства, а так-же указатель на пакет запроса IRP, который собственно и адресован драйверу. В последнем хранится буквально вся информация о запросе на ввод-вывод:
Код:
struct _IRP
Type dw 0
Size dw 0
Reserved dd 0
MdlAddress dq 0
Flags dq 0
AssociatedIrp dq 0
ThreadListEntry LIST_ENTRY
IoStatus IO_STATUS_BLOCK
RequestorMode db 0
PendingReturned db 0
StackCount db 0
CurrentLocation db 0
Cancel db 0
CancelIrql db 0
ApcEnvironment db 0
AllocationFlags db 0
UserIosb dq 0
UserEvent dq 0
Overlay dq 2 dup(0)
CancelRoutine dq 0
UserBuffer dq 0
DriverContext dq 4 dup(0)
Thread dq 0
AuxiliaryBuffer dq 0
ListEntry LIST_ENTRY
CurrentStackLocation dq 0 ;<-----// IO_STACK_LOCATION
OriginalFileObject dq 0
ends
Прочитав поле
CurrentStackLocation
получим линк на одноимённую структуру, где можно найти запрашиваемые коды операций Major/Minor, и что более важно – это параметры текущего запроса. Для них выделяется блок размером в 32-байта, а содержимое блока напрямую зависит от одного из 28-ми кодов IRP_MJ_xx. В своей процедуре DriverEntry() я договорился с диспетчером, что буду обслуживать всего три базовых запроса Create/Close/IoControl
, а потому меня будут интересовать только их параметры.
Код:
struct IO_STACK_LOCATION
MajorFunction db 0 <----//
MinorFunction db 0 <----//
Flags db 0
Control db 0
Reserved dd 0
Parameters db 32 dup(0) ------//------+
DeviceObject dq 0 |
FileObject dq 0 |
CompletionRoutine dq 0 ///
Context dq 0 |
ends |
|
0: kd> dt _io_stack_location Parameters. –v V
+0x008 Parameters : union <unnamed-tag>, 38 elements, 0x20 bytes
+0x000 Create : struct <unnamed-tag>, 5 elements, 0x20 bytes
+0x000 Read : struct <unnamed-tag>, 3 elements, 0x18 bytes
+0x000 Write : struct <unnamed-tag>, 3 elements, 0x18 bytes
+0x000 DeviceIoControl : struct <unnamed-tag>, 4 elements, 0x20 bytes
+0x000 DeviceCapabilities : struct <unnamed-tag>, 1 elements, 0x08 bytes
+0x000 ReadWriteConfig : struct <unnamed-tag>, 4 elements, 0x20 bytes
+0x000 FileSystemControl : struct <unnamed-tag>, 4 elements, 0x20 bytes
.........
0: kd> dt _io_stack_location Parameters.DeviceIoControl.
+0x008 Parameters
+0x000 DeviceIoControl
+0x000 OutputBufferLength : Uint4B
+0x008 InputBufferLength : Uint4B
+0x010 IoControlCode : Uint4B
+0x018 Type3InputBuffer : Ptr64
0: kd> dt _io_stack_location Parameters.Create.
+0x008 Parameters
+0x000 Create
+0x000 SecurityContext : Ptr64
+0x008 Options : Uint4B
+0x010 FileAttributes : Uint2B
+0x012 ShareAccess : Uint2B
+0x018 EaLength : Uint4B
0: kd> dt _io_stack_location Parameters.Read.
+0x008 Parameters
+0x000 Read
+0x000 Length : Uint4B
+0x008 Key : Uint4B
+0x010 ByteOffset : LARGE_INTEGER
0: kd>
Самая простая (и важная) процедура из всех – это Unload().
Как и следует из её названия, она озадачена выгрузкой образа драйвера из системной памяти. Код процедуры должен отменить всё, что было проделано внутри DriverEntry(). Если в своём примере я создавал ссылку при помощи IoCreateSumbolicLink(), значит должен затереть её чз IoDeleteSumbolicLink(), и т.д.
Кстати помимо официального способа выгрузки драйверов (посредством регистрации процедуры Unload) есть ещё и топорный – просто возвращаем диспетчеру в/в
STATUS_DEVICE_CONFIGURATION_ERROR=0xC0000182
, и он сам правильно разрулит ситуацию. Обратите внимание, что все процедуры DispatchXX обязательно должны быть выровнены в памяти на границу 16-байт:
C-подобный:
align 16
proc CodebyUnload, pDrvObject
invoke IoDeleteSymbolicLink, plinkName
invoke IoDeleteDevice, [pDevObj]
cinvoke DbgPrint,<'Unload driver OK!',0>
mov eax,STATUS_SUCCESS
ret
endp
Здесь я планирую обрабатывать только запросы IRP_MJ_CREATE/CLOSE/CONTROL – это стандартный минимум для Legacy драйверов, хотя в WDM обязательны ещё POWER+PNP. Когда софт пользователя откроет девайс «Codeby» чз CreateFile(), диспетчер пошлёт IRP в наш коллбек MJ_CREATE, а у драйвера появится возможность отловить этот момент. Дальнейшие действия зависят уже от назначения и функциональных особенностей драйвера, я-же просто выведу мессагу DbgPrint(). Аналогично и с процедурой MJ_CLOSE, только она перехватывает юзерскую CloseHandle() при освобождении дескриптора устройства.
На выходе нужна функция IoCompleteRequest() – сигнал диспетчеру, что «в Багдаде всё спокойно» и мы успешно обработали запрос. В модели WDM её аргумент
IO_NO_INCREMENT=0
указывает, до какого значения нужно поднять приоритет запроса IRQL следующему драйверу в стеке. Здесь мы передаём нуль, т.е. оставить прежний (возможные варианты см.в скрепке).
C-подобный:
align 16
proc CodebyCreate, pDevObj, pIrp
cinvoke DbgPrint,<'Dispatch Create! IRP: %08x',0>,[pIrp]
invoke IoCompleteRequest, [pIrp], IO_NO_INCREMENT
mov eax,STATUS_SUCCESS
ret
endp
;//----------------
align 16
proc CodebyClose, pDevObj, pIrp
cinvoke DbgPrint,<'Dispatch Close! IRP: %08x',0>,[pIrp]
invoke IoCompleteRequest, [pIrp], IO_NO_INCREMENT
mov eax,STATUS_SUCCESS
ret
endp
Если заглянуть сейчас во-вьювер «DeviceTree», он покажет паспорт нашего мини-драйвера, в том числе: базу загрузки образа, его размер в памяти, обрабатываемые коды IRP_MJ_xx, отсутствие процедуры AddDevice(), и факт его монолитности, поскольку ни к кому не приаттачен в стеке драйверов.
4. Ожидание команд от юзера DeviceIoControl()
И вот со скоростью черепахи мы добрались до основной процедуры драйвера CodebyControl() – я специально оставил её на дессерт. Она реагирует на вызов DeviceIoControl() юзера, и ожидает в аргументе код самой операции IOCTL. Этот красавец с мускулатурой бодибилдера способен на многое, и является основным интерфейсом для взаимодействия с драйверами из пользовательского режима. Вот прототип этой Win32-API:
C++:
BOOL DeviceIoControl (
HANDLE hDevice ;// дескриптор открытого устройства от CreateFile()
DWORD dwIoControlCode ;// код IOCTL операции для выполнения
LPVOID lpInBuffer ;// указатель на буфер, для передачи доп.данных драйверу
DWORD nInBufferSize ;// ..размер этого буфера
LPVOID lpOutBuffer ;// указатель на буфер, для приёма ответа от драйвера
DWORD nOutBufferSize ;// ..размер этого буфера
LPDWORD lpBytesReturned ;// указатель на переменную, куда вернётся кол-во принятых байт
LPOVERLAPPED lpOverlapped ;// структура для асинхронной операции (Null)
);
После успешного вызова этой API, драйверу придёт такая инфа:
Код:
0: kd> dt _io_stack_location Parameters.DeviceIoControl.
+0x008 Parameters
+0x000 DeviceIoControl
+0x000 OutputBufferLength : Uint4B
+0x008 InputBufferLength : Uint4B
+0x010 IoControlCode : Uint4B <--------//
+0x018 Type3InputBuffer : Ptr64
Особого внимания заслуживает здесь 32-битный код операции, который состоит аж из шести частей.
В старшей половине с битами [31:16] кодируется тип устройства, которому адресуется запрос. В системе зарегано 64 типа с константами
FILE_DEVICE_xx
. Если мы обращаемся к одному из них, то старший бит(31) в коде запроса должен быть сброшен (см.рис.ниже). Если-же это придуманный нами какой-то левый девайс, то соответственно бит нужно будет взвести. В данном случае, при создании устройства чз IoCreateDevice() я указал системную константу FILE_DEVICE_UNKNOWN=0х22
, и соответственно обращаться к нему должен именно под этим ником в коде IOCTL.
C++:
NTSTATUS IoCreateDevice(
[in] PDRIVER_OBJECT DriverObject,
[in] ULONG DeviceExtensionSize,
[in] PUNICODE_STRING DeviceName,
[in] DEVICE_TYPE DeviceType, <------// FILE_DEVICE_UNKNOWN см. DriverEntry() выше
[in] ULONG DeviceCharacteristics,
[in] BOOLEAN Exclusive,
[out] PDEVICE_OBJECT *DeviceObject
);
Таким образом, чтобы обратиться к своему устройству «Codeby» из юзермоды, в аргументе DeviceIoControl() я должен указать
IOCTL=0x00220004
, что подразумевает DEVICE_UNKNOWN
, неограниченные права ANY_ACCESS
, код операции 0x01
, и метод передачи BUFFERED
. Иначе диспетчер в/в не сможет найти адресат, и в лучшем случае тупо проигнорирует наш запрос. Имейте в виду, что драйверы плохо относятся к самодеятельности и народному творчеству, а потому лучше всегда придерживаться общих правил на уровне каждого бита.В своём коллбеке CodebyControl() и беру из структуры IO_STACK_LOCATION переданный мне юзером код операции, и по его значению могу выполнять различные задачи. В данном случае юзер посылает только
0x01
, но если учитывать разрядность всего поля 11-бит, то получаем 2^11=2048 возможных вариантов. Текущее значение IRQL я беру здесь напрямую из структуры процессора PCR, хотя можно было вызвать функцию ядра KeGetCurrentIrql(). На системах х32 доступ к этой структуре открывает сегментный регистр fs
, а на системах х64 регистр gs
:
C-подобный:
align 16
proc CodebyControl, pDevObj, pIrp ;// 0x220004
mov esi,[pIrp]
mov esi,[esi + _IRP.CurrentStackLocation]
lea esi,[esi + IO_STACK_LOCATION.Parameters]
mov eax,[esi + PARAM_IO_CONTROL.IoControlCode]
mov ebx,[fs:KPCR.Irql]
cinvoke DbgPrint,<'Dispatch IoControl',10,\
'IOCTL: %08x IRP: %08x IRQL: %d',0>,eax,[pIrp],ebx
invoke IoCompleteRequest, [pIrp], IO_NO_INCREMENT
mov eax,STATUS_SUCCESS
ret
endp
Собрав всё вместе получаем следующую картину где видно, что драйвер благополучно похукал вызовы всех Win32API на стороне юзера Create/IoControl/Close, после чего диспетчер выгрузил его тушку из системной памяти ядра. Обратите внимание на адрес пакета
IRP 0x81e0b140
– в рамках трёх операций внутри одного процесса диспетчер не удаляет его, а тупо перезаписывает содержимое новыми данными.5. Заключение
По некоторым причинам я проводил тесты на вирт.машине WinXP x32, хотя переделать исходник под х64 не составит особого труда. Тут главной целью было донести до читателя суть, а реализация уже дело второе. Позже рассмотрим организацию драйверов WDM, работу с памятью, и физическими устройствами, а пока интриги больше нет – расходимся. В скрепку положил пример для компиляции ассемблером FASM, инклуды с основными структурами ядра для х32/64, а так-же софт, который использовал в данной статье. Всем удачи, пока!