Простой 32-битный драйвер на ассемблере

Метки:  , ,

В предыдущей статье, описывающей алгоритм установки драйвера в систему Windows, мы немного разбирались в том, из каких шагов состоят алгоритмы обнаружения оборудования и установки соответствующих драйверов. Вскоре после этого родилась идея перейти к практической части и осуществить попытку написания собственного, самого простого драйвера на ассемблере. Нет, не каких-то монументальных циклов статей, а исключительно для себя, так сказать, что бы как-то уже начать изучение области разработки драйверов на Ассемблере под операционную систему Windows. После продолжительного обследования предметной области выяснилось, что более-менее серьезная разработка драйверов для Windows ведется на языках программирования C/C++, это объясняется тем, что данные языки являются фактически "языками системы", то есть на них и написаны многие части операционной системы. Однако это вовсе не исключает утверждения, что и на ассемблере вполне себе можно разработать драйвер. Да, соглашусь что делать это не так уж и сподручно, по сравнению с тем же C, поскольку для последнего в качестве серьезного подспорья выпущен специализированный пакет разработки (DDK), но тем не менее все же возможно.

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

Поскольку драйвер у нас будет первым и самым простейшим, то в качестве платформы разработки возьмем 32-битную версию системы Windows. Понимаю что сейчас на дворе уже 21 век, и доминирование 64-битных операционных систем на рынке с каждым годом все заметнее, тем не менее, на этапе "нулевого знания" пример с 32-битным драйверов будет не менее показателен. Соответственно:

Рассматриваемый в статье драйвер является 32-битным, поэтому работа его в 64-битной системе не гарантирована, и даже более того - невозможна.

После изучения некоторого количества материала, найденного в Сети, стало очевидным что не имея опыта программирования в режиме ядра, довольно сложно изобрести что-нибудь оригинальное и, что самое главное, это оригинальное потом еще и реализовать :) Первое, что приходит в незамутненную опытом голову после прочтения теории драйвера - это реализация элементарного драйвера, содержащего одну единственную процедуру инициализации DriverEntry. Как вы уже должны были понять по указанному выше материалу - без неё при написании драйвера нам никак не обойтись, она является "базовой" и должна присутствовать в каждом без исключения драйвере. Но чем эта процедура будет у нас заниматься, какой код мы могли бы в ней разместить? Понятное дело, что для простого драйвера на ассемблере и логика должна быть достаточно простой, поскольку при любом ином раскладе мы рискуем запутать процесс обучения настолько, что начинающие разработчики и вовсе могут потерять к теме интерес. Можно было бы просто-напросто скомпилировать пустой каркас драйвера, указав процедуру DriverEntry с инструкцией возврата ret, но при таком подходе как мы получим какое-либо подтверждение о том, что наш драйвер выполняется? Поэтому, было бы интересно получить какой-нибудь осязаемый результат, например визуальное или аудиальное подтверждение работоспособности. С пониманием, как именно выводить на экран монитора из кода ядра (драйвера) у нас пока тяжеловато, а вот со звуком дела обстоят попроще, поскольку в Сети можно встретить примеры с выводом на встроенный динамик персонального компьютера, всеми уже заезженная вдоль и поперек задача. И в нашем примере я решил реализовать проигрывание известной мелодии под названием "Имперский марш" из фильма Звездные войны (так же известной под названием "тема Дарта Вейдера"). Традиционно, для начала приведем исходный код драйвера:

Самой первой строкой исходного кода является указание на формат выходного (получаемого по завершении процесса компиляции) исполняемого файла. В нашем примере впервые для многих встречается неизвестный нам ранее формат native, за которым следует указание некоего "смещения" со значением 10000h. Как мы уже писали в этой статье, native - это "родной" (нативный) формат приложения, часто используемый для драйверов (фактически библиотек/приложений режима ядра, которым не требуется инициализация подсистемы Win32 на стадии подготовки образа к исполнению). Значение 10000h - это предпочитаемый базовый адрес образа, то есть адрес загрузки драйвера в адресное пространство ядра, по которому он хотел бы расположиться. Тем не менее, загрузчик образов, проверяя значение в поле ImageBase, самостоятельно принимает решение по выбору загрузочного адреса, в подавляющем большинстве случаев загрузчик размещает драйвер по иному адресу. Тем не менее, для того, что бы выполнить подобное перемещение, требуется изменить указатели переходов, переменных и других данных в коде драйвера, ведь компилятор на этапе сборки бинарного модуля жестко привязывает адреса в коде к выбираемому при компиляции базовому смещению. Именно для подобных ситуаций в исполняемый файл включается таблица перемещений (relocation table, секция .relocs в конце), где указаны все адреса, которые требуют изменения. После обработки перемещений производится связывание (fix-up) импорта (адресов функций внешних библиотек).

Почему для драйвера так важно описывать секцию таблицы перемещений в исходном коде, ведь для типовых Win32-приложений эта процедура не является обязательной? Я думаю, что ответ тут заключается в том, что для типового приложения создается "собственное" виртуальное адресное пространство процесса, поэтому загрузчик всегда размещает код по предпочитаемому адресу и перемещений не требуется. А вот для драйвера (библиотеки) он подобного гарантировать не может, поскольку он(а) загружается в уже созданное адресное пространство ядра (процесса) по произвольному динамическому адресу.

В строке 2 кода драйвера мы наблюдаем определение точки входа (директива entry) драйвера на функцию под названием DriverEntry. На основании диалекта FASM, точкой входа является первая инструкция метки, указанной после директивы entry. После прохождения процедуры загрузки драйвера в память, менеджер ввода-вывода вызывает данную функцию, иными словами система передает управление по на первый байт функции DriverEntry после загрузки драйвера в память.

Код функции DriverEntry всегда выполняется в качестве одного из потоков процесса System, в контексте этого же процесса.

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

NTSTATUS DriverEntry( _In_ struct _DRIVER_OBJECT *DriverObject, _In_ PUNICODE_STRING RegistryPath );

или более привычный нам по диалекту FASM:

DriverEntry DriverObject:DRIVER_OBJECT, RegistryPath:UNICODE_STRING

Как мы видим, процедура принимает два входных параметра: указатели на некие структуры с именами DriverObject и RegistryPath предопределенных типов.

Данные типы определены в файле включения \INCLUDE\DDK\ntddk.inc, входящим в состав внешнего пакета DDK, отсутствующего в дистрибутиве FASM. Скачать его можно тут, после чего распаковать и разместить содержимое в поддиректории \INCLUDE\DDK. В этот сторонний пакет DDK входят разнообразные файлы .inc-файлы включений, содержащие типы, константы, прототипы и структуры, используемые в ядре.

определения этих типов выглядят следующим образом:

и

где:

  • DriverObject - указатель на объект только что созданного драйвера (структура DRIVER_OBJECT). Первый параметр, передающийся в функцию DriverEntry. Перед вызовом процедуры DriverEntry диспетчер ввода-вывода, наряду с другими действиями по подготовке кода драйвера к запуску, создает объект "драйвер" (driver object), который представляет ключевые параметры драйвера для системы. Получается, что именно посредством этого объекта система "управляет" нашим драйвером. Объект представляет собой структуру данных типа DRIVER_OBJECT, некоторые поля которой заполняет система, а иные придется инициализировать разработчику, и часто это осуществляется именно в процедуре DriverEntry. Используя этот указатель в коде, мы можем заполнить требуемые нам поля структуры DRIVER_OBJECT, однако в рассматриваемом нами простейшем драйвере в этом нет необходимости.
  • RegistryPath - указатель на раздел реестра (дерево \Registry\Machine\System\CurrentControlSet\Services\Имя_драйвера), содержащий параметры инициализации драйвера. Фактически это указатель на структуру типа UNICODE_STRING, которая содержит указатель непосредственно на саму Unicode-строку, содержащую имя раздела. Указатель используется в коде драйвера для чтения или записи в реестр ключевой информации, которую драйвер может в дальнейшем использовать. В нашем случае мы, опять же, не работаем с данным параметром.

Сама структура UNICODE_STRING выглядит следующим образом:

Поле Описание
Length Длина строки в байтах (не в символах), без учета нуль-терминатора (символа завершающего нуля);
MaximumLength Длина в байтах (не в символах) буфера, указываемого через член структуры Buffer;
Buffer Указатель непосредственно на Unicode-строку (не всегда, кстати, завершающейся нулем).

Итак, вернемся к рассмотрению исходного кода драйвера. В строка 10-34 у нас размещаются константы нот и соответствующие им частоты, из них у нас будет формироваться мелодия, данные которой размещаются в секции данных. Чуть ниже по листингу мы видим функцию DriverEntry, в которой происходит вызов внутренней процедуры с именем PlaySound, являющейся фактически основной полезной нагрузкой драйвера, именно она проигрывает мелодию на встроенном PC-спикере (динамике). Давайте подробнее рассмотрим код самой процедуры PlaySound (размещенный в строках 48-71). Начинается он с сохранения регистров, которые меняются в самой процедуре, потом выставляется флаг направления, как вы уже догадались для осуществления цикла чтения нот и их проигрывания. Загружаем в регистр esi указатель на буфер, который указывается входным параметром buffer процедуры, этот буфер должен содержать проигрываемую мелодию в следующем формате:

Частота, Задержка, Частота, Задержка, Частота, Задержка, Частота, Задержка ...

В регистр ecx загружается счетчик цикла, он же размерность массива (деленная на 8, поскольку каждая итерация цикла будет проигрывать целую тональность, а она у нас имеет по 4 байта на частоту и 4 на задержку). Следом происходит загрузка в регистр eax двойного слова из памяти, указанной смещением из регистра esi, это у нас частота. И считанная частота передается на вход следующей по коду функции HalMakeBeep, которая работает с динамиком напрямую через порты.

Драйвер режима ядра операционной системы Windows не имеет возможности использовать весь спектр Win32 API-функций, доступных обычным, непривилегированным приложениям. Для кода режима ядра системой предоставляются специальные (отдельные) наборы функций.

Функция HalMakeBeep экспортируется системной библиотекой hal.dll и является, честно говоря, не самым лучшим выбором, поскольку не документирована и, соответственно, официально разработчиками не гарантируется неизменное ее состояние при переходе системы от версии к версии. Тем не менее, что-то более простое для вывода звука придумать сложно, в противном случае для вывода мелодии нам пришлось бы программировать порты напрямую, что создало бы нам для первого раза довольно избыточно-усложненную логику, которая (в свою очередь) потянула бы за собой задачу по изучению (и описанию) принципов работы перепрограммируемого интервального таймер и программируемого контроллера периферийного интерфейса Intel 8255. А библиотека hal.dll предоставляет нам готовую функцию одной из библиотек ядра, являющейся самым "низким" уровнем перед оборудованием компьютера и используемых для сопряжения с аппаратной частью, функции её просты и интуитивно-понятны, и именно поэтому выбор был сделать в её пользу.

Если в коде вызова функции вы активно работаете с регистрами общего назначения (eax, ebx, ecx, edx ...), то будьте внимательны, поскольку многие функции режима ядра (как впрочем и некоторые функции Win32 API) в своем теле не сохраняют/не восстанавливают состояния некоторых регистров общего назначения, что в ситуации с кодом режима ядра приводит порой к падению в синий экран. Простым решением проблемы видится самостоятельное сохранение регистров перед вызовом и последующее восстановление.

Поэтому вызов функции HalMakeBeep у нас обрамляет пара инструкций pushad-popad, что не является оптимальным решением, но для нашего говнокода вполне сгодится :) Двигаемся далее по коду, и в строке 60 у нас загружается следующее в буфере двойное слово, которое используется как задержка для звучащей в данный момент частоты, таким образом создается длительность звучания.
Как вы уже поняли, мелодия в нашем случае представляет собой набор частот с соответствующими задержками. Хотелось бы отдельно поговорить о задержках, и обратить внимания на два связанных с этим момента:

  • задержки указаны в массиве в качестве числа (например: 350*M), поэтому на разных конфигурациях ПК они будут звучать с разным темпом, все зависит от значения константы M (указанной в строке 7).
  • задержки реализованы в коде в виде пустого цикла из пары инструкций dec + jnz (строки 61-62), то есть "грузят" ЦП.
Более элегантным способом была бы реализация задержки с использованием функций KeStallExecutionProcessor, KeDelayExecutionThread, но моих скудных знаний не хватает на то, что бы их правильно использовать.

Итак, вернемся к основному коду, далее в приложении (строка 64) динамик у нас отключается подачей значения 0 на вход функции HalMakeBeep. Ну и после этого код у нас входит в цикл повторения заданное количество раз всех описанных выше действий, перебирая каждую записи в буфере мелодии.
После того, как код функции DriverEntry отработал, управление возвращается диспетчеру ввода-вывода (строки 44-45). Но тут вот есть один интересный момент, функция DriverEntry, как и подавляющее большинство других функций Windows, должна вернуть код возврата, сигнализирующий о статусе завершения функции. В штатном режиме, когда инициализация драйвера проходит успешно, функция возвращает статус STATUS_SUCCESS, но в нашем случае мы пишем драйвер-пустышку, который кроме кода своей процедуры инициализации (проигрывание мелодии) ничего не делает, так зачем же он нам в памяти ядра постоянно загруженным? Поэтому есть предложение не оставлять его в памяти, а посему в нашем коде мы возвращаем системе в регистре eax значение STATUS_DEVICE_CONFIGURATION_ERROR (NTSTATUS код 0C0000182h), то есть код фиктивной ошибки, после чего система должна удалить драйвер из памяти (в действительности же этого не происходит и драйвер висит в памяти со статусом Stopped).
Сама мелодия размещается в блоке данных с меткой sound_buffer (строки 73-77). Блок содержит, как мы уже отмечали выше, чередующиеся значения констант частот и задержек. Частота у нас задается константами, определенными в начале исходного кода драйвера (строки 10-34) и носящими имена соответствующих нот. Каждой константе (ноте) соответствует своя частота.

Заключение

После компиляции исходного кода нашего с вами драйвера, на выходе мы получаем исполняемый файл с расширением .sys, который, теоретически, готов к выполнению. Осталось дело за малым - заставить драйвер загружаться в адресное пространство ядра. Сделать это можно несколькими способами (будут описаны позже в отдельной статье), и самый простой из них заключается в загрузке драйвера на одном из этапов загрузки операционной системы, путем создания и редактирования ключа реестра HKLM\SYSTEM\CurrentControlSet\Services\имя_драйвера и вложенных параметров.

Комментарии: 2

  1. Иван

    А для x64 напишите статью?

    1. einaare

      напишу конечно, но вот когда это будет - точно сказать не могу. я ведь сам учусь, разобраться для начала надо в теории..

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *