- Руководство по созданию ядра для x86-системы. Часть 1. Просто ядро
- Как загружается x86-система?
- Что нам нужно?
- Задаём точку входа на ассемблере
- Ядро на Си
- Связующая часть
- Grub и Multiboot
- Строим ядро
- Настраиваем grub и запускаем ядро
- Стоит ли самому собирать ядро для домашней ОС?
- Книга «Работа с ядром Windows»
- Глава 8
- Уведомления потоков и процессов
- Уведомления процессов
- Реализация уведомлений процессов
- Функция DriverEntry
- Обработка уведомлений о выходе из процессов
- Обработка уведомлений о создании процессов
- Передача данных в пользовательский режим
- Клиент пользовательского режима
- Уведомления потоков
- Уведомления о загрузке образов
- Упражнения
- Итоги
Руководство по созданию ядра для x86-системы. Часть 1. Просто ядро
Давайте напишем простое ядро, которое можно загрузить при помощи бутлоадера GRUB x86-системы. Это ядро будет отображать сообщение на экране и ждать.
Как загружается x86-система?
Прежде чем мы начнём писать ядро, давайте разберёмся, как система загружается и передаёт управление ядру.
В большей части регистров процессора при запуске уже находятся определённые значения. Регистр, указывающий на адрес инструкций (Instruction Pointer, EIP), хранит в себе адрес памяти, по которому лежит исполняемая процессором инструкция. EIP по умолчанию равен 0xFFFFFFF0. Таким образом, x86-процессоры на аппаратном уровне начинают работу с адреса 0xFFFFFFF0. На самом деле это — последние 16 байт 32-битного адресного пространства. Этот адрес называется вектором перезагрузки (reset vector).
Теперь карта памяти чипсета гарантирует, что 0xFFFFFFF0 принадлежит определённой части BIOS, не RAM. В это время BIOS копирует себя в RAM для более быстрого доступа. Адрес 0xFFFFFFF0 будет содержать лишь инструкцию перехода на адрес в памяти, где хранится копия BIOS.
Так начинается исполнение кода BIOS. Сперва BIOS ищет устройство, с которого можно загрузиться, в предустановленном порядке. Ищется магическое число, определяющее, является ли устройство загрузочным (511-ый и 512-ый байты первого сектора должны равняться 0xAA55).
Когда BIOS находит загрузочное устройство, она копирует содержимое первого сектора устройства в RAM, начиная с физического адреса 0x7c00; затем переходит на адрес и исполняет загруженный код. Этот код называется бутлоадером.
Бутлоадер загружает ядро по физическому адресу 0x100000. Этот адрес используется как стартовый во всех больших ядрах на x86-системах.
Все x86-процессоры начинают работу в простом 16-битном режиме, называющимся реальным режимом. Бутлоадер GRUB переключает режим в 32-битный защищённый режим, устанавливая нижний бит регистра CR0 в 1. Таким образом, ядро загружается в 32-битном защищённом режиме.
Заметьте, что в случае с ядром Linux GRUB видит протоколы загрузки Linux и загружает ядро в реальном режиме. Ядро самостоятельно переключается в защищённый режим.
Что нам нужно?
- x86-компьютер;
- Linux;
- ассемблер NASM;
- gcc;
- ld (GNU Linker);
- grub;
Исходники можно найти на GitHub.
Задаём точку входа на ассемблере
Как бы не хотелось ограничиться одним Си, что-то придётся писать на ассемблере. Мы напишем на нём небольшой файл, который будет служить исходной точкой для нашего ядра. Всё, что он будет делать — вызывать внешнюю функцию, написанную на Си, и останавливать поток программы.
Как же нам сделать так, чтобы этот код обязательно был именно исходной точкой?
Мы будем использовать скрипт-линковщик, который соединяет объектные файлы для создания конечного исполняемого файла. В этом скрипте мы явно укажем, что хотим загрузить данные по адресу 0x100000.
Вот код на ассемблере:
Первая инструкция, bits 32 , не является x86-ассемблерной инструкцией. Это директива ассемблеру NASM, задающая генерацию кода для процессора, работающего в 32-битном режиме. В нашем случае это не обязательно, но вообще полезно.
Со второй строки начинается секция с кодом.
global — это ещё одна директива NASM, делающая символы исходного кода глобальными. Таким образом, линковщик знает, где находится символ start — наша точка входа.
kmain — это функция, которая будет определена в файле kernel.c . extern значит, что функция объявлена где-то в другом месте.
Затем идёт функция start , вызывающая функцию kmain и останавливающая процессор инструкцией hlt . Именно поэтому мы заранее отключаем прерывания инструкцией cli .
В идеале нам нужно выделить немного памяти и указать на неё указателем стека ( esp ). Однако, похоже, что GRUB уже сделал это за нас. Тем не менее, вы всё равно выделим немного места в секции BSS и переместим на её начало указатель стека. Мы используем инструкцию resb , которая резервирует указанное число байт. Сразу перед вызовом kmain указатель стека ( esp ) устанавливается на нужное место инструкцией mov .
Ядро на Си
В kernel.asm мы совершили вызов функции kmain() . Таким образом, наш «сишный» код должен начать исполнение с kmain() :
Всё, что сделает наше ядро — очистит экран и выведет строку «my first kernel».
Сперва мы создаём указатель vidptr , который указывает на адрес 0xb8000. С этого адреса в защищённом режиме начинается «видеопамять». Для вывода текста на экран мы резервируем 25 строк по 80 ASCII-символов, начиная с 0xb8000.
Каждый символ отображается не привычными 8 битами, а 16. В первом байте хранится сам символ, а во втором — attribute-byte . Он описывает форматирование символа, например, его цвет.
Для вывода символа s зелёного цвета на чёрном фоне мы запишем этот символ в первый байт и значение 0x02 во второй. 0 означает чёрный фон, 2 — зелёный цвет текста.
Вот таблица цветов:
В нашем ядре мы будем использовать светло-серый текст на чёрном фоне, поэтому наш байт-атрибут будет иметь значение 0x07.
В первом цикле программа выводит пустой символ по всей зоне 80×25. Это очистит экран. В следующем цикле в «видеопамять» записываются символы из нуль-терминированной строки «my first kernel» с байтом-атрибутом, равным 0x07. Это выведет строку на экран.
Связующая часть
Мы должны собрать kernel.asm в объектный файл, используя NASM; затем при помощи GCC скомпилировать kernel.c в ещё один объектный файл. Затем их нужно присоединить к исполняемому загрузочному ядру.
Для этого мы будем использовать связывающий скрипт, который передаётся ld в качестве аргумента.
Сперва мы зададим формат вывода как 32-битный Executable and Linkable Format (ELF). ELF — это стандарный формат бинарных файлов Unix-систем архитектуры x86. ENTRY принимает один аргумент, определяющий имя символа, являющегося точкой входа. SECTIONS — это самая важная часть. В ней определяется разметка нашего исполняемого файла. Мы определяем, как должны соединяться разные секции и где их разместить.
В скобках после SECTIONS точка (.) отображает счётчик положения, по умолчанию равный 0x0. Его можно изменить, что мы и делаем.
Смотрим на следующую строку: .text : < *(.text) >. Звёздочка (*) — это специальный символ, совпадающий с любым именем файла. Выражение *(.text) означает все секции .text из всех входных файлов.
Таким образом, линковщик соединяет все секции кода объектных файлов в одну секцию исполняемого файла по адресу в счётчике положения (0x100000). После этого значение счётчика станет равным 0x100000 + размер полученной секции.
Аналогично всё происходит и с другим секциями.
Grub и Multiboot
Теперь все файлы готовы к созданию ядра. Но остался ещё один шаг.
Существует стандарт загрузки x86-ядер с использованием бутлоадера, называющийся Multiboot specification. GRUB загрузит наше ядро, только если оно удовлетворяет этим спецификациям.
Следуя им, ядро должно содержать заголовок в своих первых 8 килобайтах. Кроме того, этот заголовок должен содержать 3 поля, являющихся 4 байтами:
- магическое поле: содержит магическое число 0x1BADB002 для идентификации ядра.
- поле flags: нам оно не нужно, установим в ноль.
- поле checksum: если сложить его с предыдущими двумя, должен получиться ноль.
Наш kernel.asm станет таким:
Строим ядро
Теперь мы создадим объектные файлы из kernel.asm и kernel.c и свяжем их, используя наш скрипт.
Эта строка запустит ассемблер для создания объектного файла kasm.o в формате ELF-32.
Опция «-c» гарантирует, что после компиляции не произойдёт скрытого линкования.
Это запустит линковщик с нашим скриптом и создаст исполняемый файл, называющийся kernel.
Настраиваем grub и запускаем ядро
GRUB требует, чтобы имя ядра удовлетворяло шаблону kernel- . Поэтому переименуйте ядро. Своё я назвал kernel-701.
Теперь поместите его в директорию /boot. Для этого понадобятся права суперпользователя.
В конфигурационном файле GRUB grub.cfg добавьте следующее:
Не забудьте убрать директиву hiddenmenu , если она есть.
Перезагрузите компьютер, и вы увидите список ядер с вашим в том числе. Выберите его, и вы увидите:
Это ваше ядро! В следующей части добавим систему ввода / вывода.
Стоит ли самому собирать ядро для домашней ОС?
Возьмем сферический ПК — Intel i5, 8GB RAM, видеокарта среднего ценового сегмента.
Есть ли смысл пользователю такого домашнего ПК собирать ядро самому? Был опыт сборки ядра FreeBSD — понятное дело, уменьшается размер, быстрее загружается система, можно настроить «под себя», но… мне кажется что компиляция ядра — это больше развлечение(хобби в некотором смысле) и практической пользы не так уж много
- Вопрос задан более трёх лет назад
- 8626 просмотров
Обычный пользователь? Linux? Пересборка ядра?
Перекомпилировать ядро иногда стоит! Особенно это касается ноутбуков и серверов.
Дело в том, что ядро по умолчанию работает на всём и не имеет многих оптимизаций. Так к примеру компиляция под Ваш процессор может существенно понизить энергопотребление, повысить быстродействие и еще много всего. В общем случае смысла компилировать ядро нет. Но я, к примеру, это делаю по тому, что 10% времени автономной работы от батареи — это существенно.
Вспомните о обновлениях ядра, и если Вам не лень регулярно перекомпилироватся — то ставьте кастом. Иначе не берите в голову.
Если хотите разобраться, что к чему, что от чего зависит и вообще въехать в технологии, которые зависят от поддержки ядра — то стоит. Если вы хотите просто иметь десктоп с линуксом — то нет.
В общем, выбор основывается не на повышении производительности компьютера, а на задачах, именно решающихся сборкой ядра.
Кстати говоря, если вам скажут, что повышение производительности — миф, не верьте. Прямо сейчас наблюдаю два одинаковых компа (т.е. одинаковая память, материнки, процы). На них запустили один и тот же бинарник minerd. На одном биткойны майнятся на 10% быстрее — это Gentoo, которую конфигурировал и собирал я руками. Второй — debian.
Книга «Работа с ядром Windows»
Привет, Хаброжители! Ядро Windows таит в себе большую силу. Но как заставить ее работать? Павел Йосифович поможет вам справиться с этой сложной задачей: пояснения и примеры кода превратят концепции и сложные сценарии в пошаговые инструкции, доступные даже начинающим.
В книге рассказывается о создании драйверов Windows. Однако речь идет не о работе с конкретным «железом», а о работе на уровне операционной системы (процессы, потоки, модули, реестр и многое другое).
Вы начнете с базовой информации о ядре и среде разработки драйверов, затем перейдете к API, узнаете, как создавать драйвера и клиентские приложения, освоите отладку, обработку запросов, прерываний и управление уведомлениями.
Глава 8
Уведомления потоков и процессов
Один из мощных механизмов, доступных для драйверов режима ядра — возможность уведомления о некоторых важных событиях. В этой главе будут рассмотрены некоторые из этих событий, а именно создание и уничтожение процессов, создание и уничтожение потоков и загрузка образов.
- Уведомления процессов
- Реализация уведомления процессов
- Передача данных в пользовательский режим
- Уведомления потоков
- Уведомления о загрузке образов
- Упражнения
Уведомления процессов
Каждый раз, когда в системе создается или уничтожается процесс, ядро может уведомить об этом факте заинтересованные драйверы. Это позволяет драйверам отслеживать состояние процессов (возможно, связывая с процессами некоторые данные). Как минимум это позволяет драйверам отслеживать создание/уничтожение процессов в реальном времени. Под «реальным временем» я имею в виду, что уведомления отправляются в оперативном режиме как часть создания процесса; драйвер не пропустит никакие процессы при создании и уничтожении.
При создании процесса драйвер также получает возможность остановить создание процесса и вернуть ошибку стороне, инициировавшей создание процесса. Эта возможность доступна только в режиме ядра.
Windows предоставляет другие механизмы уведомления о создании или уничтожении процессов. Например, с механизмом ETW (Event Tracing for Windows) такие уведомления могут приниматься процессами пользовательского режима (работающими с повышенными привилегиями). Впрочем, предотвратить создание процесса при этом не удастся. Более того, у ETW существует внутренняя задержка уведомлений около 1–3 секунд (по причинам, связанным с быстродействием), так что процесс с коротким жизненным циклом может завершиться до получения уведомления. Если в этот момент будет сделана попытка открыть дескриптор для созданного процесса, произойдет ошибка.
Основная функция API для регистрации уведомлений процессов PsCreateSetProcessNotifyRoutineEx определяется так:
В настоящее время существует общесистемное ограничение на 64 регистрации, поэтому теоретически попытка регистрации может завершиться неудачей.
В первом аргументе передается функция обратного вызова драйвера, прототип которой выглядит так:
Второй аргумент PsCreateSetProcessNotifyRoutineEx указывает, что делает драйвер — регистрирует обратный вызов или отменяет его регистрацию (FALSE — первое). Обычно драйвер вызывает эту функцию с аргументом FALSE в своей функции DriverEntry, а потом вызывает ту же функцию с аргументом TRUE в своей функции выгрузки.
Аргументы функции уведомления:
- Process — объект создаваемого или уничтожаемого процесса.
- ProcessId — уникальный идентификатор процесса. Хотя аргумент объявлен с типом HANDLE, на самом деле это идентификатор.
- CreateInfo — структура с подробной информацией о создаваемом процессе. Если процесс уничтожается, то этот аргумент равен NULL.
При создании процесса функция обратного вызова драйвера выполняется создающим потоком. При выходе из процесса функция обратного вызова выполняется последним потоком, выходящим из процесса. В обоих случаях обратный вызов вызывается в критической секции (с блокировкой нормальных APC-вызовов режима ядра).
В Windows 10 версии 1607 появилась другая функция для уведомлений процессов: PsCreateSetProcessNotifyRoutineEx2. Эта «расширенная» функция создает обратный вызов, сходный с предыдущим, но обратный вызов также активизируется для процессов Pico. Процессы Pico используются хост-процессами Linux для WSL (Windows Subsystem for Linux). Если драйвер заинтересован в таких процессах, он должен регистрироваться с расширенной функцией.
У драйвера, использующего эти обратные вызовы, должен быть установлен флаг IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY в заголовке PE (Portable Executable). Без установки флага вызов функции регистрации возвращает STATUS_ACCESS_DENIED (значение не имеет отношения к режиму тестовой подписи драйверов). В настоящее время Visual Studio не предоставляет пользовательского интерфейса для установки этого флага. Он должен задаваться в параметрах командной строки компоновщика ключом /integritycheck. На рис. 8.1 показаны свойства проекта при указании этого ключа.
Структура данных, предоставляемая для создания процесса, определяется следующим образом:
Описание важнейших полей этой структуры:
- CreatingThreadId — комбинация идентификаторов потока и процесса, вызывающего функцию создания процесса.
- ParentProcessId — идентификатор родительского процесса (не дескриптор). Этот процесс может быть тем же, который предоставляется CreateThreadId.UniqueProcess, но может быть и другим, так как при создании процесса может быть передан другой родитель, от которого будут наследоваться некоторые свойства.
- ImageFileName — имя файла с исполняемым образом; доступен при установленном флаге FileOpenNameAvailable.
- CommandLine — полная командная строка, используемая для создания процесса. Учтите, что он может быть равен NULL.
- IsSubsystemProcess — этот флаг устанавливается, если процесс является процессом Pico. Это возможно только в том случае, если драйвер регистрируется PsCreateSetProcessNotifyRoutineEx2.
- CreationStatus — статус, который будет возвращен вызывающей стороне. Драйвер может остановить создание процесса, поместив в это поле статус ошибки (например, STATUS_ACCESS_DENIED).
В обратных вызовах уведомления процессов следует применять защитное программирование. В частности, перед фактическим обращением необходимо проверять, что каждый указатель, по которому вы собираетесь обратиться, отличен от NULL.
Реализация уведомлений процессов
Чтобы продемонстрировать, как работают уведомления процессов, мы построим драйвер, который будет собирать информацию о создании и уничтожении процессов и предоставлять эту информацию для потребления клиентом пользовательского режима. Как и программа Process Monitor из пакета Sysinternals, он использует уведомления процессов (и потоков) для передачи информации об активности процессов (и потоков). В процессе реализации этого драйвера будут использованы некоторые средства, описанные в предыдущих главах.
Наш драйвер будет называться SysMon (хотя он никак не связан с программой SysMon из пакета Sysinternals). Он будет хранить всю информацию о создании/уничтожении в связном списке (с использованием структур LIST_ENTRY). Так как к связному списку могут одновременно обращаться несколько потоков, необходимо защитить его мьютексом или быстрым мьютексом; мы воспользуемся быстрым мьютексом, так как он более эффективен.
Собранные данные должны быть переданы в пользовательский режим, поэтому мы должны объявить стандартные структуры, которые будут строиться драйвером и получаться клиентом пользовательского режима. Мы добавим в проект драйвера стандартный заголовочный файл с именем SysMonCommon.h и определим несколько структур. Начнем со стандартного заголовка для всех информационных структур, который определяется следующим образом:
Приведенное выше определение перечисления ItemType использует новую возможность C++ 11 — перечисления с областью видимости (scoped enums). В таких перечислениях значения имеют область видимости (ItemType в данном случае). Также размер этих перечислений может быть отличен от int — short в данном случае. Если вы работаете на C, используйте классические перечисления или даже #define.
Структура ItemHeader содержит информацию, общую для всех типов событий: тип события, время события (выраженное в виде 64-разрядного целого числа) и размер полезных данных. Размер важен, так как каждое событие имеет собственную информацию. Если позднее вы захотите упаковать массив таких событий и (допустим) предоставить его клиенту пользовательского режима, клиент должен знать, где заканчивается каждое событие и начинается новое.
При наличии такого общего заголовка можно создать другие структуры данных для конкретных событий. Начнем с простейшего — выхода из процесса:
Для события выхода из процесса существует только один интересный фрагмент информации (кроме заголовка) — идентификатор завершаемого процесса.
Если вы работаете на C, наследование вам недоступно. Впрочем, его можно имитировать — создайте первое поле типа ItemHeader, а затем добавьте конкретные поля; структура памяти остается одинаковой.
Для идентификатора процесса используется тип ULONG. Использовать тип HANDLE не рекомендуется, так как в пользовательском режиме он может создать проблемы. Кроме того, тип DWORD не используется, хотя в заголовках пользовательского режима тип DWORD (32-разрядное целое без знака) встречается часто. В заголовках WDK тип DWORD не определен. И хотя определить его явно нетрудно, лучше использовать тип ULONG — он означает то же самое, но определяется в заголовках как пользовательского режима, так и режима ядра.
Так как каждая структура должна храниться как часть связанного списка, каждая структура данных должна содержать экземпляр LIST_ENTRY со ссылками на следующий и предыдущий элементы. Так как объекты LIST_ENTRY не должны быть доступны из пользовательского режима, мы определим расширенные структуры, содержащие эти элементы, в отдельном файле, который не будет использоваться в пользовательском режиме.
В новом файле с именем SysMon.h определяется параметризованная структура, в которой хранится поле LIST_ENTRY с основной структурой данных:
Параметризованный класс используется для того, чтобы вам не приходилось создавать множество типов, по одному для каждого конкретного типа события. Например, для события выхода из процесса может быть создана следующая структура:
Также возможно наследовать от LIST_ENTRY, а затем добавить структуру ProcessExitInfo. Но такое решение менее элегантно, так как наши данные не имеют никакого отношения к LIST_ENTRY, поэтому расширение — искусственный прием, которого следует избегать.
Тип FullItem избавляет от хлопот с созданием этих отдельных типов.
Если вы используете C, то, естественно, решение с шаблонами будет недоступно, поэтому вам придется применить представленный структурный подход. Не буду снова упоминать C — всегда существует обходное решение, которым можно воспользоваться в случае необходимости.
Заголовок связанного списка должен где-то храниться. Мы создадим структуру данных для хранения всего глобального состояния драйвера (вместо набора отдельных переменных). Определение структуры выглядит так:
В определении используется тип FastMutex, который был разработан в главе 6. Также в определении встречается RAII-обертка AutoLock на C++ (тоже из главы 6).
Функция DriverEntry
Функция DriverEntry для драйвера SysMon похожа на одноименную функцию драйвера Zero из главы 7. В нее нужно добавить регистрацию уведомлений процессов и инициализацию объекта Globals:
Функция диспетчеризации для чтения позднее будет использоваться для возвращения информации о событиях пользовательскому режиму.
Обработка уведомлений о выходе из процессов
В приведенном выше коде функция уведомления процессов называется OnProcessNotify, а ее прототип был представлен ранее в этой главе. Эта функция обратного вызова обрабатывает события создания и завершения процессов. Начнем с выхода из процессов, так как это событие намного проще создания процесса (как вы вскоре увидите). Общая схема функции обратного вызова выглядит так:
В случае выхода из процесса есть только идентификатор процесса, который необходимо сохранить (наряду с данными заголовка, общими для всех событий). Сначала необходимо выделить память для всей структуры, представляющей событие:
Если попытка выделения памяти завершается неудачей, драйвер ничего сделать не сможет, поэтому он просто возвращает управление из функции обратного вызова.
Затем нужно заполнить общую информацию: время, тип и размер элемента. Получить все эти данные несложно:
Сначала мы обращаемся к самому элементу данных (в обход LIST_ENTRY) через переменную info. Затем заполняется информация заголовка: тип элемента хорошо известен, так как текущей является ветвь, обрабатывающая уведомления о завершении процессов; время можно получить при помощи функции KeQuerySystemTimePrecise, возвращающей текущее системное время (UTC, не местное время) в формате 64-разрядного целого числа, с отчетом от 1 января 1601 года. Наконец, размер элемента — величина постоянная, равная размеру структуры данных, предоставляемой пользователю (а не размеру FullItem).
Функция API KeQuerySystemTimePrecise появилась в Windows 8. В более ранних версиях следует использовать функцию API KeQuerySystemTime.
Дополнительные данные при завершении процесса состоят из идентификатора процесса. В коде используется функция HandleToULong для корректного преобразования объекта HANDLE в 32-разрядное целое без знака.
А теперь остается добавить новый элемент в конец связного списка. Для этого мы определим функцию с именем PushItem:
Сначала код захватывает быстрый мьютекс, так как функция может вызываться сразу несколькими потоками одновременно. Все дальнейшее делается под защитой быстрого мьютекса.
Кроме того, драйвер ограничивает количество элементов связного списка. Такая предосторожность необходима, потому что ничто не гарантирует, что клиент будет быстро потреблять эти события. Драйвер не должен допускать неограниченное потребление данных, так как это может повредить системе в целом. Значение 1024 выбрано совершенно произвольно. Правильнее было бы читать это число из раздела драйвера в реестре.
Реализуйте это ограничение с чтением из реестра в DriverEntry. Подсказка: используйте такие функции API, как ZwOpenKey или IoOpenDeviceRegistryKey, а также ZwQueryValueKey.
Если счетчик элементов превысил максимальное значение, самый старый элемент удаляется; фактически связанный список рассматривается как очередь (RemoveHeadList). При освобождении элемента его память должна быть освобождена. Указателем на элемент не обязательно должен быть указатель, изначально использованный для выделения памяти (хотя в данном случае это так, потому что объект LIST_ENTRY стоит на первом месте в структуре FullItem<>), поэтому для получения начального адреса объекта FullItem<> используется макрос CONTAINING_RECORD. Теперь элемент можно освободить вызовом ExFreePool.
На рис. 8.2 изображена структура объектов FullItem.
Наконец, драйвер вызывает InsertTailList, чтобы добавить элемент в конец списка, а счетчик элементов увеличивается на 1.
Использовать атомарные операции инкремента/декремента в функции PushItem не обязательно, потому что операции со счетчиком элементов всегда выполняются под защитой быстрого мьютекса.
Обработка уведомлений о создании процессов
Обработка уведомлений о создании процессов создает больше проблем из-за непостоянного объема информации. Например, длина командной строки изменяется в зависимости от процесса. Сначала необходимо решить, какая информация должна сохраняться для создания процесса. Первая попытка:
В структуре сохраняется идентификатор процесса, идентификатор родительского процесса и командная строка. На первый взгляд такое решение работает и не создает проблем, потому что размер известен заранее.
Какие проблемы могут возникнуть при использовании приведенного определения?
Потенциальная проблема связана с командной строкой. Объявление командной строки с постоянным размером — решение простое, но проблематичное. Если командная строка окажется длиннее выделенного блока, драйвер будет вынужден произвести усечение (возможно, с потерей важной информации). Если командная строка короче выделенной, драйвер будет неэффективно расходовать память.
А можно ли использовать решение следующего вида:
Нет, такое решение работать не будет. Во-первых, UNICODE_STRING обычно не определяется в заголовках пользовательского режима. Во-вторых (что намного хуже), внутренний указатель на символы обычно будет указывать в системное пространство, недоступное для пользовательского режима.
Ниже приведен другой вариант, который мы используем в драйвере:
В структуре будет храниться длина командной строки и ее смещение от начала структуры. Сами символы командной строки будут следовать за структурой в памяти. В этом случае мы не ограничиваем длину командной строки и не теряем память для коротких командных строк.
С таким объявлением можно приступить к построению реализации для создания процесса:
Суммарный размер выделяемого блока зависит от длины командной строки. Начнем с заполнения неизменяющейся информации, а именно заголовка, идентификаторов процесса и родительского процесса:
Размер элемента должен вычисляться с учетом базовой структуры и длины командной строки.
Затем необходимо скопировать командную строку по адресу за базовой структурой, а также обновить длину и смещение:
Добавьте в структуру ProcessCreateInfo имя файла образа по той же схеме, что и для командной строки. Будьте внимательны при вычислении смещения.
Передача данных в пользовательский режим
Затем следует понять, как передать собранную информацию клиенту пользовательского режима. Есть несколько возможных вариантов, но в нашем драйвере клиент будет запрашивать информацию у драйвера при помощи запроса чтения. Драйвер заполняет предоставленный буфер максимально возможным количеством событий (до исчерпания буфера или до последнего события в очереди).
Начнем обработку запроса чтения с получения адреса пользовательского буфера с применением прямого ввода/вывода (настраивается в DriverEntry):
Теперь необходимо обратиться к связанному списку и извлечь элементы из заголовка:
Сначала мы захватываем быстрый мьютекс, так как уведомления процессов продолжают поступать. Если список пуст, то делать нечего, и выполнение цикла прерывается. После этого извлекается заголовочный элемент, и если его размер не превышает размер оставшейся части пользовательского буфера, копируется его содержимое (без поля LIST_ENTRY). Далее цикл продолжает извлекать элементы от заголовка списка, пока список не опустеет или пользовательский буфер не заполнится.
Наконец, запрос завершается с текущим статусом, а в поле Information сохраняется значение переменной count:
К функции выгрузки также стоит присмотреться повнимательнее. Если в связном списке присутствуют элементы, они должны быть освобождены явно; в противном случае возникнет утечка ресурсов:
Клиент пользовательского режима
После того как все будет готово, можно написать клиент пользовательского режима, который запрашивает данные вызовом ReadFile и выводит результаты.
Функция main вызывает ReadFile в цикле с небольшой приостановкой, чтобы поток не потреблял ресурсы процессора постоянно. Поступившие данные отправляются для вывода:
Функция DisplayInfo должна разобраться в структуре полученного буфера. Так как все события начинаются с общего заголовка, функция различает события по значению ItemType. После того как событие будет обработано, поле Size в заголовке указывает, где начинается следующее событие:
Для правильного извлечения командной строки в коде используется конструктор класса C++ wstring, который может построить строку по указателю и длине строки. Вспомогательная функция DisplayTime форматирует время в виде, удобном для чтения:
Драйвер устанавливается и запускается так, как было описано в главе 4.
Пример вывода, полученного при запуске SysMonClient.exe:
Уведомления потоков
Ядро предоставляет обратные вызовы создания и уничтожения потоков, аналогичные обратным вызовам процессов. Для регистрации используется функция API PsSetCreateThreadNotifyRoutine, а для ее отмены — другая функция, PsRemoveCreateThreadNotifyRoutine. В аргументах функции обратного вызова передается идентификатор процесса, идентификатор потока, а также флаг создания/уничтожения потока.
Расширим существующий драйвер SysMon, чтобы он получал не только уведомления процессов, но и уведомления потоков. Начнем с добавления значений перечисления и структуры, представляющей информацию, — все это добавляется в заголовочный файл SysMonCommon.h:
Затем можно добавить вызов регистрации в DriverEntry, непосредственно за вызовом регистрации уведомлений процессов:
Сама функция обратного вызова весьма проста, так как структура события имеет постоянный размер. Полный код функции обратного вызова для потока:
Большая часть кода выглядит довольно знакомо.
Чтобы завершить реализацию, мы добавим в клиент код для вывода информации о создании и уничтожении потоков (в DisplayInfo):
Пример вывода с обновленным драйвером и клиентом:
Добавьте в клиент код вывода имени образа процесса при создании и завершении потока.
Уведомления о загрузке образов
Последний механизм обратного вызова, который будет рассмотрен в этой главе, — уведомления о загрузке образов. Каждый раз, когда в системе загружается файл образа (EXE, DLL, драйвер), драйвер может получать уведомление.
Функция API PsSetLoadImageNotifyRoutine регистрируется для получения этих уведомлений, а функция PsRemoveImageNotifyRoutine отменяет регистрацию. Функция обратного вызова имеет следующий прототип:
Любопытно, что парного механизма обратного вызова для уведомления о выгрузке образов не существует.
Аргумент FullImageName не так прост. Как указывает аннотация SAL, он необязателен и может содержать NULL. Но даже если он отличен от NULL, он не всегда содержит точное имя файла образа.
Причины кроются глубоко в ядре и выходят за рамки книги. В большинстве случаев решение работает нормально, а путь использует внутренний формат NT, начинающийся с «\Device\HadrdiskVolumex\…» вместо «c:\…». Преобразование может быть выполнено разными способами. Тема более подробно рассматривается в главе 11.
Аргумент ProcessId содержит идентификатор процесса, в котором загружается образ. Для драйверов (образов режима ядра) это значение равно нулю.
Аргумент ImageInfo содержит дополнительную информацию об образе; его объявление выглядит так:
Краткая сводка важных полей структуры:
- SystemModeImage — флаг устанавливается для образа режима ядра и сбрасывается для образа пользовательского режима.
- ImageSignatureLevel — уровень цифровой подписи (Windows 8.1 и выше). См. описание констант SE_SIGNING_LEVEL_ в WDK.
- ImageSignatureType — тип сигнатуры (Windows 8.1 и выше). См. описание перечисления SE_IMAGE_SIGNATURE_TYPE в WDK.
- ImageBase — виртуальный адрес, по которому загружается образ.
- ImageSize — размер образа.
- ExtendedInfoPresent — если флаг установлен, IMAGE_INFO является частью большей структуры IMAGE_INFO_EX:
Для обращения к большей структуре драйвер использует макрос CONTAINING_RECORD:
В расширенной структуре добавляется всего одно осмысленное поле — объект файла, используемый для управления образом. Драйвер может добавить ссылку на объект (ObReferenceObject) и использовать его в других функциях по мере надобности.
Добавьте в драйвер SysMon уведомления о загрузке образов; драйвер должен собирать информацию только для образов пользовательского режима. Клиент должен выводить путь образа, идентификатор процесса и базовый адрес образа.
Упражнения
1. Напишите драйвер, который отслеживает создание процессов и позволяет клиентскому приложению настроить пути к исполняемым файлам, для которых выполнение должно быть запрещено.
2. Напишите драйвер (или расширьте драйвер SysMon), который будет обнаруживать удаленное создание потоков, — то есть создание потоков в процессе, отличном от текущего. Подсказка: первый поток в процессе всегда создается «удаленно». Уведомите клиента пользовательского режима об этом событии. Напишите тестовое приложение, которое использует функцию CreateRemoteThread для тестирования.
Итоги
В этой главе были рассмотрены некоторые механизмы обратного вызова, предоставляемые ядром: уведомления процессов, потоков и образов. В следующей главе мы продолжим изучение механизмов обратного вызова — в ней будут рассмотрены уведомления объектов и реестра.
Для Хаброжителей скидка 25% по купону — Windows
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.