Продолжим разговор о драйверах, и на этот раз обсудим следующие моменты:
1. Очереди отложенных вызовов DPC
Если руководствоваться только логикой, понятия «отложенный вызов» и «скорость исполнения» явно противоречат друг другу. Тогда зачем откладывать работу кода? Что мешает исполнить его прямо на месте? Но не всё так просто и чтобы добраться до истины, нужно вернуться к уровням запросов на обслуживание прерываний IRQ-Level.
Простое правило гласит: «Для поддержки стабильной работы ОС, большую часть времени драйверы должны проводить на низких IRQL от PASSIVE(0) до DPC/DISPATCH(2), иначе подкачка вирт.страниц, планирование потоков и многозадачность отключаются». Всё ясно ..только идилию нарушает физ.оборудование, которое в любой момент может послать прерывание, и ЦП обязан отреагировать на него. Здесь инженеры нашли оригинальное решение проблемы – они назначили прерываниям от устройств приоритетные уровни начиная с DIRQL=3, и в тоже время ограничили возможности находящегося на этом уровне драйвера. Поскольку коду теперь не доступны большинство функций DDI (Win Device Driver Interface), он просто вынужден спуститься на уровень планировщика DISPATCH(2) и ниже.
За техническую сторону данного вопроса отвечает как-раз системный механизм «Deferred Procedure Call» с аббревиатурой DPC. Если в двух словах, то на уровне DIRQL драйвер выполняет только критически-важные участки своего кода, а всё остальное откладывает до лучших времён, помещая указатель на основную процедуру в специальную очередь системы DPC-Queue.
Рассмотрим вариант, когда поток юзера запрашивает чтение из файла. Получив пакет запроса IRP, верхний драйвер передаёт его сл.драйверу в стеке Ntfs.sys, а тот в свою очередь драйверу Disk.sys, который и выполнит фактическую операцию чтения на харде. Как только данные будут готовы, диск пошлёт прерывание в ЦП, и поскольку это физ.оборудование, его процедура обработки ISR выполниться на высоком уровне DIRQL.
Но проблема в том, что функция завершения запроса IoCompleteRequest() доступна только на уровне DISPATCH(2) и ниже. Одна из причин такого ограничения связана с огромным объёмом работы этой функции, в процессе которой прерывания от остальных устройств оказываются замаскированными на очень длительный период времени. Поэтому драйвер помещает вызов IoCompleteRequest() в очередь DPC, которая исполняется позже на уровне DPC/DISPATCH. Вы только посмотрите на кол-во её инструкций =1264, и это не считая вложенных в тушку вызовов других функций ядра:
Каждое из ядер ЦП имеет свою очередь DPC. Когда драйвер регистрирует процедуру отложенного вызова, она попадает в очередь того ядра, на котором исполнялся поток самого драйвера. В системе предусмотрено 2 типа отложенных процедур: «DpcForIsr» исключительно для обработчиков аппаратных прерываний, а так-же «CustomDpc» для исходящих от софта. Отличаются они лишь функциями, хотя используют общую очередь. Если первую можно вызывать только с уровня DIRQL>=3, то кастомную/вторую уже с любого, включая PASSIVE_LEVEL(0). Более того, при помощи KeSetTargetProcessorDpc() мы можем выбрать конкретное ядро ЦП, на котором будет исполняться наша процедура, что представлено на последней схеме рис.ниже:
Функция ядра KeInitializeDpc() создаёт объект DPC, и регистрирует для него процедуру Custom.
В первом параметре она ожидает указатель на структуру ядра KDPC, которая является вложенной в объект устройства DEVICE_OBJECT:
После того-как объект создан, остаётся функцией KeInsertQueueDpc() поставить его в очередь на выполнение.
Обычно инициализацию проводят внутри точки-входа DriverEntry(), а постановку в очередь уже позже, например внутри DispatchControl() или другой:
Непосредственно процедура отложенного вызова имеет следующий прототип, и внутри неё можно делать всё, что доступно нам на уровне DPC/DISPATCH(2). Диспетчер ввода-вывода передаёт ей 4 аргумента, три из которых необязательные Optional (см.функции выше):
Помимо описанного выше механизма DPC, в доках фигурируют ещё несколько способов вызова процедур:
2. Назначение блокировок SpinLock
Ещё один важный механизм – это блокировки «SpinLock», без которых не может обойтись функционал буквально всех системных драйверов нашего времени. Они призваны синхронизировать одновременный доступ сразу нескольких ядер ЦП, к одному общему ресурсу системы (как правило к устройству, или к памяти). На однопроцессорных системах смысл спинлоков теряется.
В качестве примера можно привести ситуацию, когда в определённый момент времени ядро(А) обращается к структуре EPROCESS на чтение, а ядро(В) на запись. Каждое из ядер живёт своей жизнью и не подозревает, что сосед обращается по тому-же адресу в памяти. Как результат, может возникнуть состояние гонки «Race Condition», что недопустимо ни при каких обстоятельствах. Проблемы такого характера и решаются при помощи спинлоков.
Получение блокировки функцией KeAcquireSpinLock() автоматически повышает IRQL до DISPATCH_LEVEL(2). Следовательно код, который получает блокировку должен находиться только в невыгружаемой памяти NonPagedPool. Чтобы не сломать производительность всей системы, при захваченной спин-блокировке нам нужно минимизировать объём работы, поскольку вполне возможно, что доступом к разделяемому ресурсу захочет овладеть соседнее ядро ЦП.
Каждое из ядер может удерживать всего один SpinLock, хотя два ядра могут одновременно удерживать две разные блокировки. Все спины на ядрах собираются в свои очереди, которые описывает структура KSPIN_LOCK_QUEUE (указатель хранится в личной памяти ядра PCR, Processor Control Region). В очереди всего 2 поля – это линк на сам спинлок в тушке какого-то драйвера, и линк на следущую структуру Next (если есть, иначе нуль). Как видно из лога отладчика, адреса очередей на разных ядрах отличаются:
Предположим, что в момент времени t1 код на ядре(0) захотел получить доступ к общему ресурсу. Тогда включив блокировку он с вероятностью 100% получит монопольный доступ к нему. Теперь, в момент времени t2 коду на ядре(1) так-же приспичило обратиться к тому-же ресурсу, но поскольку взведён флаг спина, ядро(1) закрутится в цикле перепроверяя, освободился он или нет. В доках данный процесс известен как «Спиннинг». Когда в момент t3 ядро(0) снимает свой SpinLock, общий ресурс опять становится доступным, и им спешит воспользоваться код на ядре(1). Такая ситуация представлена на схеме ниже:
Здесь важно понять, что на уровне IRQL выше DISPATCH(2) у исполняемых потоков процесса System (в контексте которых могут работают драйверы устройств) нет права на сон, поскольку «разбудить» их будет некому. Поэтому обнаружив спинлок ядро ЦП не засыпает, а именно крутится в цикле, тратя на это процессорное время.
К блокировкам нужно относиться с особой осторожностью, т.к. они с лёгкостью могут вогнать ядро в тупик с последующим BSOD. Например строго запрещается запрашивать блокировку дважды к одному ресурсу, предварительно не сняв предыдущую. Избежать тупиковой ситуации можно следуя простым правилам:
SpinLock – это низкоуровневый примитив синхронизации, который применяется только к драйверам. Он является аналогом мьютекса лишь с тем отличием, что не привязан к планировщику, а использует цикл активного ожидания без изменения состояния потока. Физически представляет обычную переменную в памяти, с атомарным доступом на RW инструкцией
Когда ядро ЦП обращается к общему ресурсу, оно записывает в эту переменную значение 1=Занято. Если предыдущее было нуль, ядро получает доступ к ресурсу, иначе возвращается опять к операции
На программном уровне сначала нужно выделить переменную под SpinLock (можно хоть в своей секции-данных). Но обычно драйверы используют буфер по указателю DeviceExtension в структуре DEVICE_OBJECT. Формат этого буфа мы выбираем на своё усмотрение, и его нужно воспринимать просто как резерв в невыгружаемом пуле памяти NonPagedPool. Размер в пределах 64К задаётся на этапе создания устройства IoCreateDevice(), и позже мы можем использовать этот буфер для любых своих нужд:
Определившись с адресом переменной, вызываем KeInitializeSpinLock() для инициализации объекта. Это сигнал системе, что в своём коде мы планируем использовать блокировки. Позже, во время работы на уровне IRQL<=DISPATCH_LEVEL вызываем KeAcquireSpinLock() и KeReleaseSpinLock(), внутри которых и выполняем всю работу с разделяемым ресурсом. Обратите внимание, что функция должна находиться в невыгружаемой памяти, поскольку она выполняется на повышенном IRQL.
Поскольку обычные блокировки действуют только на уровне IRQL<=2, они становятся бесполезны на DIRQL>=3, где обитают обработчики прерываний от физ.устройств. Поэтому для DIRQL предусмотрены свои функции KeAcquireInterruptSpinLock() и KeAcquireSpinLockForDpc() с соответствующими функциями Release.
Более того, начиная с Win7 инженеры вообще убрали обычные спинлоки из экспорта Ntoskrnl.exe, заменив их на ExAcquireSpinLockShared/Exclusive(). Теперь доступ на чтение общих ресурсов открыт всем, кто отметился функцией ExAcquireSpinLockShared(), а вот для доступа на запись нужно заполучить эксклюзивные права вызвав ExAcquireSpinLockExclusive(). Здесь есть хорошая
2.1. Прочие объекты синхронизации: Event, Semaphore, Mutex
Помимо спинлоков, синхронизировать работу ядер ЦП можно при помощи событий Event, мутантов Mutex, семафоров и таймеров.
Семафоры и мьютексы юзаются в рамках только одного процесса, а вот ивенты и спинлоки применяются, когда из одного потока надо просигналить в другие. Просто запомним, что изначально все примитивы задуманы для синхронизации в одном процессе, хотя некоторые из них умеют и в разных. Более того на Win10 подвезли ещё парочку свежих продуктов – это PushLock и CriticalRegion. В общем здесь есть из чего выбирать.
3. Общие принципы взаимодействия с пользователем
Если софт-драйвер не вещь в себе, он должен так или иначе общаться с приложением пользователя – т.е. принимать запросы, и отправлять соответствующие ответы. В основном интерфейсе обмена с драйвером DeviceIoControl() нам предлагаются несколько вариантов, и каждый отличается не только расположением буферов ввода-вывода, но и самим механизмом. Это большая тема, а потому мы рассмотрим только самый распространённый METHOD_BUFFERED, с которым конкурирует сквозной METHOD_DIRECT. Первый удобен тем, что все технические аспекты берёт на себя сам диспетчер ввода-вывода системы, в то время как буфер для Direct нужно создавать вручную, что непременно вызовет проблемы у начинающих.
3.1. Списки MDL – как основа механизма Direct
Раз уж разговор зашёл о прямом обмене Direct, нужно дать определение такому понятию как MDL или «Memory Descriptor List».
Если в двух словах, то это структура ядра, которая описывает один непрерывный участок памяти. Как мы уже знаем, 4 КБайтные страницы физ.памяти называют «фреймы», а для их адресации используется термин PFN – PageFrameNumber. Когда физической ОЗУ недостаточно для удовлетворения нашего запроса, системе приходится связывать несколько структур MDL в цепочку, поэтому ключевым словом является здесь «непрерывный участок», т.е. несколько фреймов с последовательными PFN например:
Значит нужно будет выделить буфер, который опишет структура MDL. Этот буф может находится как в системном пространстве, так и в пространстве пользователя. В дефолте MDL выделяется всегда в выгружаемом пуле системе PagedPool, что чрезвычайно опасно с точки зрения драйверов. Поэтому после того-как буфер выделен, его фреймы срочно нужно закрепить в физ.памяти, иначе рано или поздно BSOD нам обеспечен. Два этих вопроса решают функции ядра IoAllocateMdl() + MmProbeAndLockPages(), с обратными по смыслу IoFreeMdl() + MmUnlockPages():
Обычно в аргументах первой функции мы указываем только требуемый размер буфера, а остальные ставим в зеро. Но если в последний параметр записать адрес пакета IRP (он передаётся во все процедуры Dispatch), то диспетчер пропишет указатель на MDL в поле самой структуры IRP, что в последующем облегчит нам доступ к буферу. Важно понять, что IoAllocateMdl() только выделяет где-то в системной памяти буфер, и заполняет поля его MDL валидными значениями. А вот поле с критически важными флагами выставляет в структуре MDL уже вторая MmProbeAndLockPages() – пока не выставлены флаги, буфером пользоваться нельзя!
Будем считать, что на данном этапе у нас есть правильный MDL, и фреймы физ.памяти закреплены в ОЗУ. Однако большую роль играет здесь адрес буфера, расположение которого напрямую зависит от того, в контексте какого процесса функция IoAllocateMdl() его выделяла. Если драйвер работал в контексте пользовательского процесса, то проблем нет, а вот если в одном из многочисленных потоков процесса System, то буфер окажется не доступным юзеру, и весь наш план потерпит фиаско.
Основным удобством использования MDL является то, что под буфер выделяются последовательные фреймы именно физ.памяти. Такой расклад позволяет диспетчеру назначать этим фреймам любой виртуальный адрес, хоть в пространстве ядра, хоть в пространстве пользователя. Это реально круто! Если мы хотим отобразить буфер в другой диапазон вирт.адресов, то достаточно (надев шляпу диспетчера) дёрнуть за MmMapLockedPagesSpecifyCache(), где аргумент
Если повезёт и мы не вывалимся в BSOD, то с установленным
3.2. Обмен посредством DeviceIoControl() METHOD_BUFFERED
Все описанные выше шаги без нашего вмешательства диспетчер проделает сам, если в коде IOCTL функции DeviceIoControl() мы выберем буферизированный метод связи с драйвером, что делает его самым простым и эффективным. Правда в доках на MSDN есть примечание, мол вариант подходит для передачи небольших объёмов данных, но насколько небольших (в стиле мелкомягких) не уточняется. Адрес буфера приёма-передачи найдём в структуре
Функция DeviceIoControl() может как отправлять, так и принимать данные от драйвера, для чего имеются 2 отдельных буфера (см.прототип ниже). Однако это иллюзия, и по факту в наличии всего один общий буфер в ядре, на который указывает поле
При тестах выяснилось, что буфер обмена находится в адресном пространстве ядра, и посредством того-же MDL тупо маппится в юзер-спейс упомянутой выше MmMapLockedPagesSpecifyCache(). Таким образом, варианты обмена Direct и Buffered технически реализованы одинаково, только в первом случае приходится всё делать самостоятельно (и конечно-же не без ошибок с нашей стороны), а во-втором проблемой занимается уже диспетчер, который как-никто лучше разбирается в своей кухне.
4. Практика
Под занавес напишем драйвер и приложение к нему, которые будут обмениваться данными между собой.
Значит метод ставим Buffered, определяя таким образом фронт работы диспетчеру. Софт своей функцией DeviceIoControl() пошлёт код операции драйверу, а тот в свою очередь трусцой пробежится по всем структурам EPROCESS системы, чтобы создать список активных на данный момент. Из каждой EPROCESS будем брать по несколько наиболее интересных полей, на каждом шаге заполняя ими буфер отправки юзеру.
В предыдущей части я уже выкладывал код драйвера, а на этот раз я его просто дополнил, расширив функционал процедуры DispatchIoControl(). Полный исходник положу в скрепку, а здесь представлю только код основной процедуры. Для решения задачи нам потребуются 2 дополнительные функции DDI:
• MmGetSystemRoutineAddress() позволяет найти в экспорте Ntoskrnl.exe или Hal.dll указанную нами функцию. По сути это аналог пользовательской GetProcAddress() лишь с тем отличием, что она может искать не в любой библиотеке, а только в двух/ядерных. У неё один аргумент – указатель на структуру
• PsGetCurrentProcessId() возвратит идентификатор PID процесса, в контексте которого будет работать драйвер. Поскольку запрос мы будем отправлять из своего юзер-приложения, значит драйвер оседлает именно его. Этот Pid мы будем использовать как маркер окончания цикла обхода всех структур EPROCESS. То-есть начнём с процесса System, и закончим своим.
Запустив этот драйвер в логах обнаруживаем, что указанный в аргументах DeviceIoControl() буфер пользователя плавно перекочевал в ядро, т.к. адрес его
А вот и пользовательское приложение, которое отправляет драйверу запрос – здесь просто парсим данные в буфере, сбрасывая их на консоль:
По ходу мы и правда подружили драйвер с софтом, что доказывает скрин.
Обратите внимание на кол-во потоков(63) у процесса System. Когда говорят, что драйвер работает в контексте потоков ядра, он задействует потоки именно процесса System. Этих потоков много, но они не содержат в себе полезной нагрузки, а существуют просто как «транспорт» для драйверов. Поэтому и занимают они все скопом всего 1 МБайт в памяти. Зато у одного из процессов svchost видим 55 тредов, при этом памяти они пожирают аж 102 метра. На системе х32 где я проводил тесты, верхняя половина с адреса
Заключение
Тема драйверов настолько обширна, что говорить о ней можно бесконечно. Здесь на каждом углу торчат шипы, и нужно быть предельно осторожным, чтобы не словить ошибки BSOD. Только практика способна нас защитить от этого, которая без теории не даст никаких результатов. Вот несколько полезных линков, где можно найти всю важную для этой области информацию. В скрепке найдёте исходники с бинарями, а так-же два чуток подправленных инклуда wdm.inc для х32/64. Всем удачи, пока!
Kernel DDI:
Системные потоки:
Архив базы Misrosoft:
1. Очереди отложенных вызовов DPC
2. Назначение блокировок SpinLock
• прочие объекты синхронизации: Event, Semaphore, Mutex
3. Общие принципы взаимодействия с пользователем
• методы передачи данных Buffered и Direct
• буферы процедуры Dispatch DeviceControl
4. Практика
1. Очереди отложенных вызовов DPC
Если руководствоваться только логикой, понятия «отложенный вызов» и «скорость исполнения» явно противоречат друг другу. Тогда зачем откладывать работу кода? Что мешает исполнить его прямо на месте? Но не всё так просто и чтобы добраться до истины, нужно вернуться к уровням запросов на обслуживание прерываний IRQ-Level.
Простое правило гласит: «Для поддержки стабильной работы ОС, большую часть времени драйверы должны проводить на низких IRQL от PASSIVE(0) до DPC/DISPATCH(2), иначе подкачка вирт.страниц, планирование потоков и многозадачность отключаются». Всё ясно ..только идилию нарушает физ.оборудование, которое в любой момент может послать прерывание, и ЦП обязан отреагировать на него. Здесь инженеры нашли оригинальное решение проблемы – они назначили прерываниям от устройств приоритетные уровни начиная с DIRQL=3, и в тоже время ограничили возможности находящегося на этом уровне драйвера. Поскольку коду теперь не доступны большинство функций DDI (Win Device Driver Interface), он просто вынужден спуститься на уровень планировщика DISPATCH(2) и ниже.
За техническую сторону данного вопроса отвечает как-раз системный механизм «Deferred Procedure Call» с аббревиатурой DPC. Если в двух словах, то на уровне DIRQL драйвер выполняет только критически-важные участки своего кода, а всё остальное откладывает до лучших времён, помещая указатель на основную процедуру в специальную очередь системы DPC-Queue.
Рассмотрим вариант, когда поток юзера запрашивает чтение из файла. Получив пакет запроса IRP, верхний драйвер передаёт его сл.драйверу в стеке Ntfs.sys, а тот в свою очередь драйверу Disk.sys, который и выполнит фактическую операцию чтения на харде. Как только данные будут готовы, диск пошлёт прерывание в ЦП, и поскольку это физ.оборудование, его процедура обработки ISR выполниться на высоком уровне DIRQL.
Но проблема в том, что функция завершения запроса IoCompleteRequest() доступна только на уровне DISPATCH(2) и ниже. Одна из причин такого ограничения связана с огромным объёмом работы этой функции, в процессе которой прерывания от остальных устройств оказываются замаскированными на очень длительный период времени. Поэтому драйвер помещает вызов IoCompleteRequest() в очередь DPC, которая исполняется позже на уровне DPC/DISPATCH. Вы только посмотрите на кол-во её инструкций =1264, и это не считая вложенных в тушку вызовов других функций ядра:
Код:
0: kd> uf /i /c IoCompleteRequest
nt!IoCompleteRequest (fffff800`029b6950), 1264 instructions scaned
call to hal!HalRequestSoftwareInterrupt (fffff800`0280eaf4)
call to nt!EtwTraceReadyThread (fffff800`02955690)
call to nt!ExFreePoolWithTag (fffff800`02a29240)
call to nt!ExpInterlockedPushEntrySList (fffff800`028e9520)
call to nt!HvlNotifyLongSpinWait (fffff800`0293c460)
call to nt!IopCompleteRequest (fffff800`0288c750)
call to nt!IopDequeueIrpFromThread (fffff800`02889750)
call to nt!IopDropIrp (fffff800`028b4578)
call to nt!IopfCompleteRequest (fffff800`0288d4c0)
call to nt!IopFreeIrp (fffff800`0288c5b0)
call to nt!KeAcquireQueuedSpinLock (fffff800`02898548)
call to nt!KeBugCheckEx (fffff800`028e2ba0)
call to nt!KeInitializeApc (fffff800`0286e148)
call to nt!KeInsertQueueApc (fffff800`0289b198)
call to nt!KeReleaseQueuedSpinLock (fffff800`02898600)
call to nt!KeSetEvent (fffff800`02883e10)
call to nt!KiDeferredReadyThread (fffff800`02888240)
call to nt!KiDeliverApc (fffff800`0289b6a0)
call to nt!KiEndCounterAccumulation (fffff800`02928cb0)
call to nt!KiQueueReadyThread (fffff800`02899590)
call to nt!KiReadyThread (fffff800`02894790)
call to nt!KiRequestProcessInSwap (fffff800`028643b8)
call to nt!KiSignalThread (fffff800`02898e28)
call to nt!KiSwapContext (fffff800`028e4fd0)
call to nt!KiTryUnwaitThread (fffff800`02895d30)
call to nt!KxWaitForSpinLockAndAcquire (fffff800`028927c0)
call to nt!MmUnlockPages (fffff800`029aabe0)
call to nt!MmUnmapLockedPages (fffff800`028b6660)
call to nt!ObfDereferenceObject (fffff800`0288e510)
call to nt!PerfLogSpinLockAcquire (fffff800`0292e8e0)
call to nt!PerfLogSpinLockRelease (fffff800`02999f30)
call to nt!PoDeviceReleaseIrp (fffff800`029a3960)
call to nt!PsChargeProcessCpuCycles (fffff800`02954fc0)
0: kd>
Каждое из ядер ЦП имеет свою очередь DPC. Когда драйвер регистрирует процедуру отложенного вызова, она попадает в очередь того ядра, на котором исполнялся поток самого драйвера. В системе предусмотрено 2 типа отложенных процедур: «DpcForIsr» исключительно для обработчиков аппаратных прерываний, а так-же «CustomDpc» для исходящих от софта. Отличаются они лишь функциями, хотя используют общую очередь. Если первую можно вызывать только с уровня DIRQL>=3, то кастомную/вторую уже с любого, включая PASSIVE_LEVEL(0). Более того, при помощи KeSetTargetProcessorDpc() мы можем выбрать конкретное ядро ЦП, на котором будет исполняться наша процедура, что представлено на последней схеме рис.ниже:
Функция ядра KeInitializeDpc() создаёт объект DPC, и регистрирует для него процедуру Custom.
В первом параметре она ожидает указатель на структуру ядра KDPC, которая является вложенной в объект устройства DEVICE_OBJECT:
Код:
0: kd> dt -v nt!_device_object dpc.
struct _DEVICE_OBJECT
+0x0c8 Dpc : struct _KDPC 9 elements, 0x40 bytes <------//
+0x000 Type : UChar
+0x001 Importance : UChar
+0x002 Number : Uint2B
+0x008 DpcListEntry : struct _LIST_ENTRY, 2 elements, 0x10 bytes
+0x018 DeferredRoutine : Ptr64
+0x020 DeferredContext : Ptr64
+0x028 SystemArgument1 : Ptr64
+0x030 SystemArgument2 : Ptr64
+0x038 DpcData : Ptr64 <----- линк на структуру KDPC_DATA (доступна только диспетчеру)
;//-----------------------------------
void KeInitializeDpc(
[out] pKDPC Dpc, ;// линк на структуру выше для инициализации
[in] pROUTINE DeferredRoutine, ;// линк на отложенную процедуру
[in,opt] pVOID DeferredContext ;// ..(можно передать ей ^^^ какой-нибудь аргумент)
);
После того-как объект создан, остаётся функцией KeInsertQueueDpc() поставить его в очередь на выполнение.
Обычно инициализацию проводят внутри точки-входа DriverEntry(), а постановку в очередь уже позже, например внутри DispatchControl() или другой:
C++:
BOOLEAN KeInsertQueueDpc( ;//<-- True = ОК, False = объект DPC уже имеется в очереди
[in,out] pKDPC Dpc, ;// линк на готовый объект DPC выше
[in,opt] pVOID Argument1, ;// значения из структуры KDPC выше
[in,opt] pVOID Argument2 ;// ^^^^
);
BOOLEAN KeRemoveQueueDpc( ;// можно отменить DPC в системной очереди
[in,out] pKDPC Dpc
);
Непосредственно процедура отложенного вызова имеет следующий прототип, и внутри неё можно делать всё, что доступно нам на уровне DPC/DISPATCH(2). Диспетчер ввода-вывода передаёт ей 4 аргумента, три из которых необязательные Optional (см.функции выше):
C-подобный:
proc DeferredRoutine Dpc, DeferredContext, Argument1, Argument2
nop
;//----------------------------
;//.... здесь что-нибудь делаем
;//----------------------------
invoke IoCompleteRequest()
mov rax,status
ret
endp
Помимо описанного выше механизма DPC, в доках фигурируют ещё несколько способов вызова процедур:
APC – Asynchronous ProcCall, или асинхронный вызов. Если очередь DPC глобальна для всего ядра ОС, то очереди APC имеются у каждого потока.
RPC – Remote ProcCall, или удалённый вызов по сети. ISR находится на удалённом узле (привет софту TeamViewer).
ALPC – Advenced Local ProcCall, или вызов локальных процедур. Здесь подразумевается связь посредством порта между двумя системными процессами.
2. Назначение блокировок SpinLock
Ещё один важный механизм – это блокировки «SpinLock», без которых не может обойтись функционал буквально всех системных драйверов нашего времени. Они призваны синхронизировать одновременный доступ сразу нескольких ядер ЦП, к одному общему ресурсу системы (как правило к устройству, или к памяти). На однопроцессорных системах смысл спинлоков теряется.
В качестве примера можно привести ситуацию, когда в определённый момент времени ядро(А) обращается к структуре EPROCESS на чтение, а ядро(В) на запись. Каждое из ядер живёт своей жизнью и не подозревает, что сосед обращается по тому-же адресу в памяти. Как результат, может возникнуть состояние гонки «Race Condition», что недопустимо ни при каких обстоятельствах. Проблемы такого характера и решаются при помощи спинлоков.
Получение блокировки функцией KeAcquireSpinLock() автоматически повышает IRQL до DISPATCH_LEVEL(2). Следовательно код, который получает блокировку должен находиться только в невыгружаемой памяти NonPagedPool. Чтобы не сломать производительность всей системы, при захваченной спин-блокировке нам нужно минимизировать объём работы, поскольку вполне возможно, что доступом к разделяемому ресурсу захочет овладеть соседнее ядро ЦП.
Каждое из ядер может удерживать всего один SpinLock, хотя два ядра могут одновременно удерживать две разные блокировки. Все спины на ядрах собираются в свои очереди, которые описывает структура KSPIN_LOCK_QUEUE (указатель хранится в личной памяти ядра PCR, Processor Control Region). В очереди всего 2 поля – это линк на сам спинлок в тушке какого-то драйвера, и линк на следущую структуру Next (если есть, иначе нуль). Как видно из лога отладчика, адреса очередей на разных ядрах отличаются:
Код:
0: kd> dt _kpcr @$pcr
hal!_KPCR
+0x000 NtTib : _NT_TIB
+0x000 GdtBase : 0xfffff800`03c50000 _KGDTENTRY64
+0x008 TssBase : 0xfffff800`03c4f000 _KTSS64
+0x010 UserRsp : 0x00000000`01edf638
+0x018 Self : 0xfffff800`02a40000 _KPCR
+0x020 CurrentPrcb : 0xfffff800`02a40180 _KPRCB
+0x028 LockArray : 0xfffff800`02a407f0 _KSPIN_LOCK_QUEUE <----// 800`02a407f0
+0x030 Used_Self : 0x000007ff`fffdc000
+0x038 IdtBase : 0xfffff800`03c4e000 _KIDTENTRY64
+0x040 Unused : [2] 0
+0x050 Irql : 0 ''
..........
0: kd> ~1 <--------------------------------------------------------------- Смена ядра
1: kd> dt _kpcr @$pcr LockArray
+0x028 LockArray : 0xfffff880`009e97f0 _KSPIN_LOCK_QUEUE <----// 880`009e97f0
1: kd> dt _KSPIN_LOCK_QUEUE
hal!_KSPIN_LOCK_QUEUE
+0x000 Next : Ptr64 _KSPIN_LOCK_QUEUE
+0x008 Lock : Ptr64 Uint8B
1: kd>
Предположим, что в момент времени t1 код на ядре(0) захотел получить доступ к общему ресурсу. Тогда включив блокировку он с вероятностью 100% получит монопольный доступ к нему. Теперь, в момент времени t2 коду на ядре(1) так-же приспичило обратиться к тому-же ресурсу, но поскольку взведён флаг спина, ядро(1) закрутится в цикле перепроверяя, освободился он или нет. В доках данный процесс известен как «Спиннинг». Когда в момент t3 ядро(0) снимает свой SpinLock, общий ресурс опять становится доступным, и им спешит воспользоваться код на ядре(1). Такая ситуация представлена на схеме ниже:
Здесь важно понять, что на уровне IRQL выше DISPATCH(2) у исполняемых потоков процесса System (в контексте которых могут работают драйверы устройств) нет права на сон, поскольку «разбудить» их будет некому. Поэтому обнаружив спинлок ядро ЦП не засыпает, а именно крутится в цикле, тратя на это процессорное время.
К блокировкам нужно относиться с особой осторожностью, т.к. они с лёгкостью могут вогнать ядро в тупик с последующим BSOD. Например строго запрещается запрашивать блокировку дважды к одному ресурсу, предварительно не сняв предыдущую. Избежать тупиковой ситуации можно следуя простым правилам:
- Убедитесь, что затребовавший блокировку код обязательно снимает её позже.
- По максимуму ограничьте вызов функций и процедур, пока вы владеете блокировкой.
- Исключите вложенные друг в друга и рекурсивные блокировки, т.к. они могут привести к взаимоблокировкам.
- Не удерживайте спинлоки дольше 25 микросекунд(µs) = 0.025 милли(ms) = 0.000025 сек.
Ссылка скрыта от гостей
в реальном времени ведёт учёт процедур ISR и DPC в каждом из системных драйверов. Он даже считает потраченное на них общее время в миллисекундах, а так-же запоминает потолок Highest этого тайма. Правда спинлоки тут не причём, но хоть какую-то картину представить уже можно. Как видим ядро Ntoskrnl здесь на белом коне и по кол-ву отложенных вызовов DPC, и по затраченному на них времени:SpinLock – это низкоуровневый примитив синхронизации, который применяется только к драйверам. Он является аналогом мьютекса лишь с тем отличием, что не привязан к планировщику, а использует цикл активного ожидания без изменения состояния потока. Физически представляет обычную переменную в памяти, с атомарным доступом на RW инструкцией
xchg
(атомарная подразумевает блокирование ячейки памяти на время операции).Когда ядро ЦП обращается к общему ресурсу, оно записывает в эту переменную значение 1=Занято. Если предыдущее было нуль, ядро получает доступ к ресурсу, иначе возвращается опять к операции
xchg
, и так в цикле, пока не обнаружит 0=Свободно. По окончании работы, владелец спинлока обязательно должен записать в него условное значение нуль.На программном уровне сначала нужно выделить переменную под SpinLock (можно хоть в своей секции-данных). Но обычно драйверы используют буфер по указателю DeviceExtension в структуре DEVICE_OBJECT. Формат этого буфа мы выбираем на своё усмотрение, и его нужно воспринимать просто как резерв в невыгружаемом пуле памяти NonPagedPool. Размер в пределах 64К задаётся на этапе создания устройства IoCreateDevice(), и позже мы можем использовать этот буфер для любых своих нужд:
Код:
NTSTATUS IoCreateDevice(
[in] PDRIVER_OBJECT DriverObject,
[in] ULONG DeviceExtensionSize, <----// если нужен, указать размер буфера
[in,opt] PUNICODE_STRING DeviceName,
[in] DEVICE_TYPE DeviceType,
[in] ULONG DeviceCharacteristics,
[in] BOOLEAN Exclusive,
[out] PDEVICE_OBJECT *DeviceObject
);
0: kd> dt _device_object
ntdll!_DEVICE_OBJECT
+0x000 Type : Int2B
+0x002 Size : Uint2B
+0x004 ReferenceCount : Int4B
+0x008 DriverObject : Ptr64 _DRIVER_OBJECT
+0x010 NextDevice : Ptr64 _DEVICE_OBJECT
+0x018 AttachedDevice : Ptr64 _DEVICE_OBJECT
+0x020 CurrentIrp : Ptr64 _IRP
+0x028 Timer : Ptr64 _IO_TIMER
+0x030 Flags : Uint4B
+0x034 Characteristics : Uint4B
+0x038 Vpb : Ptr64 _VPB
+0x040 DeviceExtension : Ptr64 Void <-----// линк на частный буфер устройства
+0x048 DeviceType : Uint4B
+0x04c StackSize : Char
+0x050 Queue : <unnamed-tag>
+0x098 AlignRequirement : Uint4B
+0x0a0 DeviceQueue : _KDEVICE_QUEUE
+0x0c8 Dpc : _KDPC
+0x108 ActiveThreadCount : Uint4B
+0x110 SecurityDesc : Ptr64 Void
+0x118 DeviceLock : _KEVENT <-----// объект синхронизации планировщика
+0x130 SectorSize : Uint2B
+0x132 Spare1 : Uint2B
+0x138 DeviceObjectExt : Ptr64 _DEVOBJ_EXTENSION
+0x140 Reserved : Ptr64 Void
0: kd>
Определившись с адресом переменной, вызываем KeInitializeSpinLock() для инициализации объекта. Это сигнал системе, что в своём коде мы планируем использовать блокировки. Позже, во время работы на уровне IRQL<=DISPATCH_LEVEL вызываем KeAcquireSpinLock() и KeReleaseSpinLock(), внутри которых и выполняем всю работу с разделяемым ресурсом. Обратите внимание, что функция должна находиться в невыгружаемой памяти, поскольку она выполняется на повышенном IRQL.
C-подобный:
invoke KeInitializeSpinLock, pLock ;// инициализации переменной!
invoke KeAcquireSpinLock, pLock, pOldIrql ;// повышает IRQL до DISPATCH(2), и сохраняет предыдущий в pOldIrql
;//
;// доступ к ресурсу открыт, что-нибудь делаем
;//
invoke KeReleaseSpinLock, [pOldIrql] ;// восстанавливает предыдущее значение IRQL
Поскольку обычные блокировки действуют только на уровне IRQL<=2, они становятся бесполезны на DIRQL>=3, где обитают обработчики прерываний от физ.устройств. Поэтому для DIRQL предусмотрены свои функции KeAcquireInterruptSpinLock() и KeAcquireSpinLockForDpc() с соответствующими функциями Release.
Более того, начиная с Win7 инженеры вообще убрали обычные спинлоки из экспорта Ntoskrnl.exe, заменив их на ExAcquireSpinLockShared/Exclusive(). Теперь доступ на чтение общих ресурсов открыт всем, кто отметился функцией ExAcquireSpinLockShared(), а вот для доступа на запись нужно заполучить эксклюзивные права вызвав ExAcquireSpinLockExclusive(). Здесь есть хорошая
Ссылка скрыта от гостей
Алекса Ионеску по всем примитивам синхронизации.2.1. Прочие объекты синхронизации: Event, Semaphore, Mutex
Помимо спинлоков, синхронизировать работу ядер ЦП можно при помощи событий Event, мутантов Mutex, семафоров и таймеров.
Семафоры и мьютексы юзаются в рамках только одного процесса, а вот ивенты и спинлоки применяются, когда из одного потока надо просигналить в другие. Просто запомним, что изначально все примитивы задуманы для синхронизации в одном процессе, хотя некоторые из них умеют и в разных. Более того на Win10 подвезли ещё парочку свежих продуктов – это PushLock и CriticalRegion. В общем здесь есть из чего выбирать.
3. Общие принципы взаимодействия с пользователем
Если софт-драйвер не вещь в себе, он должен так или иначе общаться с приложением пользователя – т.е. принимать запросы, и отправлять соответствующие ответы. В основном интерфейсе обмена с драйвером DeviceIoControl() нам предлагаются несколько вариантов, и каждый отличается не только расположением буферов ввода-вывода, но и самим механизмом. Это большая тема, а потому мы рассмотрим только самый распространённый METHOD_BUFFERED, с которым конкурирует сквозной METHOD_DIRECT. Первый удобен тем, что все технические аспекты берёт на себя сам диспетчер ввода-вывода системы, в то время как буфер для Direct нужно создавать вручную, что непременно вызовет проблемы у начинающих.
3.1. Списки MDL – как основа механизма Direct
Раз уж разговор зашёл о прямом обмене Direct, нужно дать определение такому понятию как MDL или «Memory Descriptor List».
Если в двух словах, то это структура ядра, которая описывает один непрерывный участок памяти. Как мы уже знаем, 4 КБайтные страницы физ.памяти называют «фреймы», а для их адресации используется термин PFN – PageFrameNumber. Когда физической ОЗУ недостаточно для удовлетворения нашего запроса, системе приходится связывать несколько структур MDL в цепочку, поэтому ключевым словом является здесь «непрерывный участок», т.е. несколько фреймов с последовательными PFN например:
1-2-3-4
, но никак не: 4-1-3-2
.
Код:
0: kd> dt _mdl
ntdll!_MDL
+0x000 Next : Ptr64 _MDL
+0x008 Size : Int2B
+0x00a MdlFlags : Int2B
+0x010 Process : Ptr64 _EPROCESS
+0x018 MappedSystemVa : Ptr64 Void
+0x020 StartVa : Ptr64 Void
+0x028 ByteCount : Uint4B
+0x02c ByteOffset : Uint4B
0: kd>
Значит нужно будет выделить буфер, который опишет структура MDL. Этот буф может находится как в системном пространстве, так и в пространстве пользователя. В дефолте MDL выделяется всегда в выгружаемом пуле системе PagedPool, что чрезвычайно опасно с точки зрения драйверов. Поэтому после того-как буфер выделен, его фреймы срочно нужно закрепить в физ.памяти, иначе рано или поздно BSOD нам обеспечен. Два этих вопроса решают функции ядра IoAllocateMdl() + MmProbeAndLockPages(), с обратными по смыслу IoFreeMdl() + MmUnlockPages():
C++:
PMDL IoAllocateMdl( ;//<--------- Возвращает указатель на созданный MDL, или 0 если ошибка.
[in, opt] VirtualAddress, ;// базовый адрес: 0 = система найдёт сама
[in] Length, ;// размер буфера
[in] SecondaryBuffer, ;// False = основной, True = вторичный MDL в цепочке
[in] ChargeQuota, ;// резерв = 0
[in,out,opt] Irp ;// можно связать буфер со-структурой запроса IRP
);
VOID MmProbeAndLockPages( ;//<--------- Внимание! Ошибка генерит BSOD «ACCESS_VIOLATION», иначе ОК.
[in,out] Mdl, ;// линк на MDL
[in] AccessMode, ;// 0 = KernelMode, 1 = UserMode
[in] Operation ;// 0 = IoReadAccess, 1 = IoWriteAccess
);
Обычно в аргументах первой функции мы указываем только требуемый размер буфера, а остальные ставим в зеро. Но если в последний параметр записать адрес пакета IRP (он передаётся во все процедуры Dispatch), то диспетчер пропишет указатель на MDL в поле самой структуры IRP, что в последующем облегчит нам доступ к буферу. Важно понять, что IoAllocateMdl() только выделяет где-то в системной памяти буфер, и заполняет поля его MDL валидными значениями. А вот поле с критически важными флагами выставляет в структуре MDL уже вторая MmProbeAndLockPages() – пока не выставлены флаги, буфером пользоваться нельзя!
Код:
0: kd> dt _irp
nt!_IRP
+0x000 Type : Int2B
+0x002 Size : Uint2B
+0x008 MdlAddress : Ptr64 _MDL ;//<-----// аргумент IoAllocMdl().Irp сохранят сюда линк
+0x010 Flags : Uint4B
+0x018 AssociatedIrp : <unnamed-tag>
+0x020 ThreadListEntry : _LIST_ENTRY
+0x030 IoStatus : _IO_STATUS_BLOCK
........
;//------------------------------------ MDL flags
MDL_MAPPED_TO_SYSTEM_VA = 0x0001
MDL_PAGES_LOCKED = 0x0002
MDL_SOURCE_IS_NONPAGED_POOL = 0x0004
MDL_ALLOCATED_FIXED_SIZE = 0x0008
MDL_PARTIAL = 0x0010
MDL_PARTIAL_HAS_BEEN_MAPPED = 0x0020
MDL_IO_PAGE_READ = 0x0040
MDL_WRITE_OPERATION = 0x0080
MDL_LOCKED_PAGE_TABLES = 0x0100
MDL_PARENT_MAPPED_SYSTEM_VA = 0x0100
MDL_FREE_EXTRA_PTES = 0x0200
MDL_DESCRIBES_AWE = 0x0400
MDL_IO_SPACE = 0x0800
MDL_NETWORK_HEADER = 0x1000
MDL_MAPPING_CAN_FAIL = 0x2000
MDL_PAGE_CONTENTS_INVARIANT = 0x4000
MDL_ALLOCATED_MUST_SUCCEED = 0x4000
MDL_INTERNAL = 0x8000
MDL_MAPPING_FLAGS = 0x0927
Будем считать, что на данном этапе у нас есть правильный MDL, и фреймы физ.памяти закреплены в ОЗУ. Однако большую роль играет здесь адрес буфера, расположение которого напрямую зависит от того, в контексте какого процесса функция IoAllocateMdl() его выделяла. Если драйвер работал в контексте пользовательского процесса, то проблем нет, а вот если в одном из многочисленных потоков процесса System, то буфер окажется не доступным юзеру, и весь наш план потерпит фиаско.
Основным удобством использования MDL является то, что под буфер выделяются последовательные фреймы именно физ.памяти. Такой расклад позволяет диспетчеру назначать этим фреймам любой виртуальный адрес, хоть в пространстве ядра, хоть в пространстве пользователя. Это реально круто! Если мы хотим отобразить буфер в другой диапазон вирт.адресов, то достаточно (надев шляпу диспетчера) дёрнуть за MmMapLockedPagesSpecifyCache(), где аргумент
AccessMode=User
отфутболивает буфер в ворота юзера:
C++:
PVOID MmMapLockedPagesSpecifyCache(
[in] Mdl,
[in] AccessMode, ;// 0 = KernelMode, 1 = UserMode
[in] CacheType, ;// кэшировать или нет
[in,opt] RequestedAddress, ;// 0, или задать базовый адрес
[in] BugCheckOnFailure, ;// 0 = ошибка, вместо BSOD
[in] Priority ;// 0 = дефолт, или NoWrite/NoExecute
);
Если повезёт и мы не вывалимся в BSOD, то с установленным
KernelMode
система взведёт бит MAPPED_TO_SYSTEM_VA в поле Flags
структуры MDL, а в поле MappedSystemVa
пропишет базовый адрес буфера в ядерной памяти. Соответственно для режима UserMode
этот флаг в MDL будет сброшен, а адрес будет смотреть в пространство юзера. Операция отмены для этого DDI носит несколько неудачное имя MmPrepareMdlForReuse(). При программировании в ядре нужно придерживаться основного правила – всё, что мы делаем, по окончании нужно пренепременно отменять! (юзеру в этом плане живётся намного комфортней).3.2. Обмен посредством DeviceIoControl() METHOD_BUFFERED
Все описанные выше шаги без нашего вмешательства диспетчер проделает сам, если в коде IOCTL функции DeviceIoControl() мы выберем буферизированный метод связи с драйвером, что делает его самым простым и эффективным. Правда в доках на MSDN есть примечание, мол вариант подходит для передачи небольших объёмов данных, но насколько небольших (в стиле мелкомягких) не уточняется. Адрес буфера приёма-передачи найдём в структуре
IRP.AssociatedIrp.SystemBuffer
, и думаю он без проблем унесёт на себе до 100 КБайт виртуальной памяти.
Код:
0: kd> dt _IRP AssociatedIrp.; dt _IRP Tail.Overlay.CurrentStackLocation; dt _IO_STACK_LOCATION Parameters.DeviceIoControl.
nt!_IRP
+0x018 AssociatedIrp :
+0x000 SystemBuffer : Ptr64 Void <-----// Общий буфер на ввод-вывод
+0x078 Tail :
+0x000 Overlay :
+0x040 CurrentStackLocation : Ptr64 _IO_STACK_LOCATION
nt!_IO_STACK_LOCATION
+0x008 Parameters :
+0x000 DeviceIoControl :
+0x000 OutputBufferLength : Uint4B <----// размер ожидаемых от драйвера данных
+0x008 InputBufferLength : Uint4B <----// размер переданной юзером информации
+0x010 IoControlCode : Uint4B <----// код операции
+0x018 Type3InputBuffer : Ptr64
0: kd>
Функция DeviceIoControl() может как отправлять, так и принимать данные от драйвера, для чего имеются 2 отдельных буфера (см.прототип ниже). Однако это иллюзия, и по факту в наличии всего один общий буфер в ядре, на который указывает поле
SystemBuffer
в структуре IRP. То-есть драйвер и читает и пишет в него. От юзера требуется лишь указать размеры данных в этом буфере, а драйвер получит их потом из вложенной в IRP структуры IO_STACK_LOCATION.Parameters
.
C++:
BOOL DeviceIoControl(
HANDLE hDevice ;// хэндл открытого девайса от CreateFile()
DWORD dwIoControlCode ;// код операции для выполнения
LPVOID lpInBuffer ;// указатель на буфер, для передачи данных драйверу (если нужно)
DWORD nInBufferSize ;// ..размер этого буфера
LPVOID lpOutBuffer ;// указатель на буфер, для приёма ответа от драйвера
DWORD nOutBufferSize ;// ..размер этого буфера
LPDWORD lpBytesReturned ;// линк на переменную, куда вернётся кол-во принятых байт
LPOVERLAPPED lpOverlapped ;// структура для асинхронной операции (привет APC)
);
При тестах выяснилось, что буфер обмена находится в адресном пространстве ядра, и посредством того-же MDL тупо маппится в юзер-спейс упомянутой выше MmMapLockedPagesSpecifyCache(). Таким образом, варианты обмена Direct и Buffered технически реализованы одинаково, только в первом случае приходится всё делать самостоятельно (и конечно-же не без ошибок с нашей стороны), а во-втором проблемой занимается уже диспетчер, который как-никто лучше разбирается в своей кухне.
4. Практика
Под занавес напишем драйвер и приложение к нему, которые будут обмениваться данными между собой.
Значит метод ставим Buffered, определяя таким образом фронт работы диспетчеру. Софт своей функцией DeviceIoControl() пошлёт код операции драйверу, а тот в свою очередь трусцой пробежится по всем структурам EPROCESS системы, чтобы создать список активных на данный момент. Из каждой EPROCESS будем брать по несколько наиболее интересных полей, на каждом шаге заполняя ими буфер отправки юзеру.
В предыдущей части я уже выкладывал код драйвера, а на этот раз я его просто дополнил, расширив функционал процедуры DispatchIoControl(). Полный исходник положу в скрепку, а здесь представлю только код основной процедуры. Для решения задачи нам потребуются 2 дополнительные функции DDI:
• MmGetSystemRoutineAddress() позволяет найти в экспорте Ntoskrnl.exe или Hal.dll указанную нами функцию. По сути это аналог пользовательской GetProcAddress() лишь с тем отличием, что она может искать не в любой библиотеке, а только в двух/ядерных. У неё один аргумент – указатель на структуру
UNICODE_STRING
с именем функции для поиска. Мы передадим ей имя переменной ядра PsInitialSystemProcess, в результате чего получим указатель на EPROCESS процесса System.• PsGetCurrentProcessId() возвратит идентификатор PID процесса, в контексте которого будет работать драйвер. Поскольку запрос мы будем отправлять из своего юзер-приложения, значит драйвер оседлает именно его. Этот Pid мы будем использовать как маркер окончания цикла обхода всех структур EPROCESS. То-есть начнём с процесса System, и закончим своим.
C-подобный:
align 16
proc DispatchControl, pDevObj, pIrp ;//<----- примет управление от нашей DeviceIoControl()
;// Находим буфер приёма-передачи, который прописан в структуре пакета IRP
mov esi,[pIrp]
mov eax,[esi + _IRP.AssociatedIrp]
push eax
;// Покажем параметры, которые передал нам софт – это код операции, и размеры пользовательских буферов
mov ebx,[eax]
mov esi,[esi + _IRP.CurrentStackLocation]
lea esi,[esi + IO_STACK_LOCATION.Parameters]
cinvoke DbgPrint,<'Dispatch IoControl',10,\
'IOCTL: 0x%08x Buff: 0x%08x BuffSize: %d byte',10,\
'InLen: %d byte Data: 0x%08x',0>,\
[esi + PARAM_IO_CONTROL.IoControlCode],eax,\
[esi + PARAM_IO_CONTROL.OutputBuffLength],\
[esi + PARAM_IO_CONTROL.InputBuffLength],ebx
;// Подготовка к обходу всех структур EPROCESS
invoke PsGetCurrentProcessId ;//
xchg eax,ebx ;// EBX = Pid нашего процесса
invoke MmGetSystemRoutineAddress,pVarName ;// найдём линк на PsInitialSystemProcess
mov esi,[eax] ;// ESI = указатель на EPROCESS системы
pop edi ;// EDI = адрес пользовательского буфера
push edi ;// Пропустим в нём первый dword,
add edi,4 ;// ....куда в конце запишем кол-во найденных процессов
;// Парсим все структуры EPROCESS
@ProcessList:
push esi ;// Числа – это смещения в буфере
mov eax,[esi + EPROCESS.UniqueProcessId] ;// 00 = PID
cmp eax,ebx ;// это наш процесс?
jnz @f ;// нет
inc [stopFlag] ;// иначе взведём флаг в секции-данных
@@: stosd ;// запись значения в буфер (автоматом EDI+4)
mov eax,[esi + EPROCESS.VirtualSize] ;// 04 = размер очередного процесса в памяти
stosd
mov eax,[esi + EPROCESS.SectionBaseAddress] ;// 08 = базовый адрес в памяти
stosd
mov eax,[esi + EPROCESS.ActiveThreads] ;// 12 = число потоков у процесса
stosd
mov eax,esi ;// 16 = адрес его структуры EPROCESS
stosd
mov eax,[esi + EPROCESS.Peb] ;// 20 = адрес его структуры PEB
stosd
lea esi,[esi + EPROCESS.ImageFileName] ;// 24 = ASCII-строка с именем процесса
mov ecx,16 ;// .....длина строки всегда макс 16 символов
rep movsb ;// копируем из EPROCESS в буфер юзера
pop esi
mov esi,[esi + EPROCESS.ActiveProcessLinks] ;// указатель на сл.структуру EPROCESS
sub esi,0x88 ;// коррекция
inc [count] ;// считаем найденные..
cmp [stopFlag],1 ;// проверим флаг на окончание цикла
jnz @ProcessList ;// нет – продолжить!
;// Цикл закончен! Запишем в первый dword буфера кол-во найденных процессов
pop edi
mov eax,[count]
stosd
cinvoke DbgPrint,<'Process count: %d',10,' ',0>,eax ;// отладочное сообщение
;// Передаём статус окончания диспетчеру в/в
mov eax,[pIrp]
mov dword[eax + _IRP.IoStatus], STATUS_SUCCESS ;// всё ок!
mov dword[eax + _IRP.IoStatus+4],40*40 ;// обязательно указать размер данных в буфере!
;// инфа об одном процессе = 40 байт, и с запасом 40 таких блоков.
invoke IoCompleteRequest,[pIrp],IO_NO_INCREMENT
mov eax,STATUS_SUCCESS
ret
endp
Запустив этот драйвер в логах обнаруживаем, что указанный в аргументах DeviceIoControl() буфер пользователя плавно перекочевал в ядро, т.к. адрес его
0x80ffa000
, при этом юзер передал драйверу 1 dword со значением 0xDEADDEAD
. Это подтверждает теорию о том, что по факту буфер находится в памяти ядра, а в юзер-спейс лишь проецируется.А вот и пользовательское приложение, которое отправляет драйверу запрос – здесь просто парсим данные в буфере, сбрасывая их на консоль:
C++:
format pe console
include 'win32ax.inc'
entry start
;//-----------
.data
devName db '\\.\Codeby',0
devHndl dd 0
align 16
outBuff rb 1024*4 ;// резерв в 4КБ для приёмного буфа
inBuff dd 0xDEADDEAD ;// буфер передачи драйверу
retSize dd 0
buff db 0
;//-----------
.code
start: invoke SetConsoleTitle,<' *** DriverTest ***',0>
;//----- Пытаемся открыть устройство драйвера
invoke CreateFile,devName,GENERIC_READ,FILE_SHARE_READ,\
0,OPEN_EXISTING,0,0
mov [devHndl],eax
cmp eax,-1
jnz @f
cinvoke printf,'Open error!'
jmp @exit
;//----- Отправляем драйверу код операции
@@: invoke DeviceIoControl, eax, 0x220004, inBuff, 4, outBuff, 1024*4, retSize,0
invoke CloseHandle,[devHndl]
;//----- В буфере лежат данные - выводим их на консоль
cinvoke printf,<10,' Active Process List - found %d',10,\
10,' Pid Memory BaseAddr EPROCESS PEB Thread & Name',\
10,' ---- ------ -------- -------- -------- -------------',0>,dword[outBuff]
mov esi,outBuff
mov ecx,[esi]
add esi,4
@ParseBuff:
push ecx esi
mov eax,dword[esi + 4]
shr eax,20
mov ebx,esi
add ebx,24
cinvoke printf,<10,' %4d %3d Mb %08x %08x %08x %2d %s',0>,\
dword[esi + 0], eax,\
dword[esi + 8], dword[esi + 16],\
dword[esi + 20],dword[esi + 12],ebx
pop esi ecx
add esi,40
loop @ParseBuff
@exit: cinvoke _getch
cinvoke exit, 0
;//-----------
section '.idata' import data readable
library msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
include 'api\msvcrt.inc'
include 'api\kernel32.inc'
По ходу мы и правда подружили драйвер с софтом, что доказывает скрин.
Обратите внимание на кол-во потоков(63) у процесса System. Когда говорят, что драйвер работает в контексте потоков ядра, он задействует потоки именно процесса System. Этих потоков много, но они не содержат в себе полезной нагрузки, а существуют просто как «транспорт» для драйверов. Поэтому и занимают они все скопом всего 1 МБайт в памяти. Зато у одного из процессов svchost видим 55 тредов, при этом памяти они пожирают аж 102 метра. На системе х32 где я проводил тесты, верхняя половина с адреса
0x80000000
принадлежит ядру, а нижняя юзеру. Соответственно все структуры EPROCESS лежат в ядре и не доступны юзеру без драйвера.Заключение
Тема драйверов настолько обширна, что говорить о ней можно бесконечно. Здесь на каждом углу торчат шипы, и нужно быть предельно осторожным, чтобы не словить ошибки BSOD. Только практика способна нас защитить от этого, которая без теории не даст никаких результатов. Вот несколько полезных линков, где можно найти всю важную для этой области информацию. В скрепке найдёте исходники с бинарями, а так-же два чуток подправленных инклуда wdm.inc для х32/64. Всем удачи, пока!
Kernel DDI:
Ссылка скрыта от гостей
Системные потоки:
Ссылка скрыта от гостей
Архив базы Misrosoft:
Ссылка скрыта от гостей
Вложения
Последнее редактирование: