Статья ASM. Драйверы WDM (4) – SpinLock, DPC, обмен с пользователем

Продолжим разговор о драйверах, и на этот раз обсудим следующие моменты:

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() мы можем выбрать конкретное ядро ЦП, на котором будет исполняться наша процедура, что представлено на последней схеме рис.ниже:

Dpc.png

Функция ядра 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, или вызов локальных процедур. Здесь подразумевается связь посредством порта между двумя системными процессами.​

Alpc.png


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). Такая ситуация представлена на схеме ниже:

SpinLock.png

Здесь важно понять, что на уровне IRQL выше DISPATCH(2) у исполняемых потоков процесса System (в контексте которых могут работают драйверы устройств) нет права на сон, поскольку «разбудить» их будет некому. Поэтому обнаружив спинлок ядро ЦП не засыпает, а именно крутится в цикле, тратя на это процессорное время.

К блокировкам нужно относиться с особой осторожностью, т.к. они с лёгкостью могут вогнать ядро в тупик с последующим BSOD. Например строго запрещается запрашивать блокировку дважды к одному ресурсу, предварительно не сняв предыдущую. Избежать тупиковой ситуации можно следуя простым правилам:
  1. Убедитесь, что затребовавший блокировку код обязательно снимает её позже.
  2. По максимуму ограничьте вызов функций и процедур, пока вы владеете блокировкой.
  3. Исключите вложенные друг в друга и рекурсивные блокировки, т.к. они могут привести к взаимоблокировкам.
  4. Не удерживайте спинлоки дольше 25 микросекунд(µs) = 0.025 милли(ms) = 0.000025 сек.
Софт в реальном времени ведёт учёт процедур ISR и DPC в каждом из системных драйверов. Он даже считает потраченное на них общее время в миллисекундах, а так-же запоминает потолок Highest этого тайма. Правда спинлоки тут не причём, но хоть какую-то картину представить уже можно. Как видим ядро Ntoskrnl здесь на белом коне и по кол-ву отложенных вызовов DPC, и по затраченному на них времени:

lMon.png

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. Это подтверждает теорию о том, что по факту буфер находится в памяти ядра, а в юзер-спейс лишь проецируется.

Log.png

А вот и пользовательское приложение, которое отправляет драйверу запрос – здесь просто парсим данные в буфере, сбрасывая их на консоль:

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 лежат в ядре и не доступны юзеру без драйвера.

Result.png


Заключение

Тема драйверов настолько обширна, что говорить о ней можно бесконечно. Здесь на каждом углу торчат шипы, и нужно быть предельно осторожным, чтобы не словить ошибки BSOD. Только практика способна нас защитить от этого, которая без теории не даст никаких результатов. Вот несколько полезных линков, где можно найти всю важную для этой области информацию. В скрепке найдёте исходники с бинарями, а так-же два чуток подправленных инклуда wdm.inc для х32/64. Всем удачи, пока!

Kernel DDI:
Системные потоки:
Архив базы Misrosoft:
 

Вложения

  • User32_Drv.zip
    21,5 КБ · Просмотры: 26
Последнее редактирование:
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!