• XSS.stack #1 – первый литературный журнал от юзеров форума

Мануал/Книга Windows Kernel Programming by Pavel Yosifovich

Azrv3l

win32kfull
Эксперт
Регистрация
30.03.2019
Сообщения
215
Реакции
539
Windows Kernel Programming by Pavel Yosifovich
cover.png


В этом разделе буду публиковать перевод этой книги. Оригинал я залил на мегу.

В книге всего 11 глав:
  • Первые 4 главы есть в переводе(хотя это скорее вольный пересказ) от Virt
  • 5 и 6 главы есть в переводе от X-Shar
  • Оставшиеся 5 глав я постараюсь перевести сам.
Ссылка: Mega
Формат: Pdf
Размер: 5.1 MB
 
Последнее редактирование:
Глава 1 - Основные моменты и архитектура ядра

В этой главе рассматриваются:
  • Процессы;
  • Виртуальная память;
  • Потоки;
  • Системные сервисы;
  • Архитектура системы;
  • Handles и Objects.

Итак начнем:
Процессы
Процесс
- это объект исполнения и управления, представляющий работающий экземпляр программы.
Термин «процесс запускается», который используется довольно часто, является неточным.
Процессы не запускаются – процессы управляют.

Потоки - это то-что выполняет код и технически выполняется.
У процесса может-быть несколько потоков, т.е. поток это по сути «подпрограмма», какой-то глобальной программы (Процесса).
Поток может выполнятся псевдопараллельно…

С точки зрения высокого уровня, процесс имеет следующие части:
  • Исполняемая программа, которая содержит исходный код и данные, используемые для выполнения кода в процессе.
  • Привилегированное виртуальное адресное пространство, используемое для выделения памяти для любых задач кода.
  • Основной токен, который является объектом, хранит контекст безопасности процесса по умолчанию, используется потоками, выполняющими код внутри процесса.
  • Приватная таблица дескрипторов для исполняемых объектов, таких как события, семафоры и файлы.
  • Один или несколько потоков исполнения.
Процесс пользовательского режима создается с одним потоком (выполнение классической функции main/WinMain).
Процесс пользовательского режима без потоков в основном бесполезны и при нормальных обстоятельствах будут уничтожены ядром.

1.png

Описанные элементы процесса изображены на рисунке 1-1.

Процесс однозначно идентифицируется по его идентификатору процесса, который остается уникальным, пока процесс выполняется.
После того, как он уничтожен, этот же идентификатор может быть повторно использован для новых процессов.
Важно понимать, что сам исполняемый файл не является уникальным идентификатором процесса.
Например, может быть пять экземпляров «notepad.exe» работающих одновременно.

Каждый процесс имеет свое адресное пространство, свои собственные потоки, собственную таблицу дескрипторов, собственный уникальный идентификатор процесса и т. д.
Все эти пять процессов используют тот же файл (notepad.exe).

Рисунок 1-2 показывает снимок экрана с диспетчера задач.
На вкладке «Сведения» диспетчера задач показаны пять экземпляров Notepad.exe, каждый со своими атрибутами.

2.png

Рисунок 1-2. Снимок экрана с диспетчера задач

Виртуальная память
Каждый процесс имеет свое собственное виртуальное, приватное, линейное адресное пространство.

Это адресное пространство пустое (или близко к пустому, поскольку исполняемый образ и NtDll.Dll отображаются первыми, а затем
при необходимости подгружаются DLL).

Как только начинается выполнение основного (первого) потока, при необходимости загружаются больше DLL и т. д.
Это адресное пространство является приватным, что означает другие процессы не могут получить к нему доступ напрямую.
Короче, если не понятно (инглиш не мой канек), можно проще объяснить, эта память выделяется в процессе работы программы, изначально её как-бы и нет.)))

Диапазон адресного пространства начинается с нуля (хотя технически первые 64 КБ не может быть выделено или использовано каким-либо образом), и идет до максимума, который зависит от «битности» процесса (32 или 64 бит)
и «битности» операционной системы следующим образом:
  • Для 32-разрядных процессов в 32-разрядных системах Windows размер адресного пространства процесса составляет 2 ГБ.
  • Для 32-разрядных процессов в 32-разрядных системах Windows, в которых используется параметр увеличения адресного пространства пользователя.
(Флаг LARGEADDRESSAWARE в заголовке Portable Executable), размер адресного пространства этого процесса может достигать 3 ГБ (в зависимости от точной настройки).

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

Если это не так, он все равно будет ограничен 2 ГБ
  • Для 64-разрядных процессов (естественно, в 64-разрядной системе Windows) размер адресного пространства составляет 8 ТБ. (Windows 8 и более ранние версии) или 128 ТБ (Windows 8.1 и более поздние версии).
  • Для 32-разрядных процессов в 64-разрядной системе Windows размер адресного пространства составляет 4 ГБ, если исполняемый файл был собран с флагом LARGEADDRESSAWARE. В противном случае размер остается на уровне 2 ГБ.
Примечание:
Требование флага LARGEADDRESSAWARE связано с тем, что адрес объемом 2 ГБ требует только 31 бита, оставляя старший значащий бит (MSB) свободным для использования приложением.
Указание этого флага указывает на то, что программа не использует бит 31 ни для чего, и поэтому установка этого бита в 1 (что произошло бы для адресов размером более 2 ГБ) не является проблемой.


Каждый процесс имеет свое собственное адресное пространство, что делает любой адрес процесса относительным, а не абсолютным.
Например, при попытке определить, что лежит в адресе 0x20000, самого адреса недостаточно. Процесс, к которому относится этот адрес, может-быть специфичен.

Сама память называется виртуальной, что означает наличие косвенной связи между адресным диапазоном и точным местоположением, где он находится в физической памяти (RAM).
Буфер внутри процесса может быть сопоставлен с физической памятью или временно находиться в файле (например, в файле подкачки).
Термин виртуальный относится к тому факту, что с точки зрения исполнения, нет необходимости знать, находится-ли память в ОЗУ или нет.

Если память действительно отображается в ОЗУ, процессор получит доступ к данным напрямую.
Если нет, ЦП вызовет исключение ошибки страницы, которое вызовет обработчик ошибок страницы диспетчера памяти для извлечения данных из соответствующего файла и копирования их в оперативную память.

3.png

На рисунке 1-3 показано это сопоставление виртуальной и физической памяти для двух процессов.

Единица управления памятью называется страницей.

Каждый атрибут, связанный с памятью, всегда находится на детализации страницы, например ее защита.
Размер страницы определяется типом процессора и на некоторых процессорах, могут быть конфигурируемыми.
Обычный (иногда называемый маленьким) размер страницы составляет 4 КБ на всех поддерживаемых Windows архитектурах.
Помимо нормального (небольшого) размера страницы, Windows также поддерживает большие страницы.

Большой размер страницы составляет 2 МБ (x86 / x64 / ARM64) и 4 МБ (ARM).
Это основано на использовании записи каталога страниц Page Directory Entry (PDE) для отображения большой страницы без использования таблицы страниц.

Это приводит к более быстрой трансляции, но лучше использовать Translation Lookaside Buffer (TLB) - кеш недавно транслированных страниц поддерживаемых процессором.
В случае большой страницы одна запись TLB может отображать значительно больше памяти, чем маленькая страница.

Недостатком больших страниц является необходимость иметь непрерывную память в ОЗУ, что может-быть плохо, если память ограничена или сильно фрагментирована.
Кроме того, большие страницы не всегда являются страницами и должны быть защищены только доступом для чтения /записи.

Огромные страницы размером 1 ГБ поддерживаются на Windows 10 и Server 2016 и более поздних версиях.
Они используются автоматически , если выделение памяти занимает не менее 1 ГБ, и эта страница может быть расположена как непрерывная в ОЗУ.

Состояние страниц виртуальной памяти
Каждая страница в виртуальной памяти может находиться в одном из трех состояний:
  • Free — В этой странице ничего нет. Любая попытка доступа к этой страницы вызовет исключение нарушения прав доступа. Большинство страниц во вновь созданном процессе являются свободными.
  • Committed - Выделенная страница, к которой можно успешно получить доступ в соответствии атрибутами защиты (например, запись на страницу только для чтения вызывает нарушение прав доступа).
  • Reserved - страница не выделена, но диапазон адресов зарезервирован для возможного выделения в будущем. С точки зрения процессора, это то же самое, что и свободная страница - любая попытка доступа вызовет исключение нарушения доступа.
Выделенные страницы обычно отображаются в ОЗУ или в файле (например, файл страницы).

Тем не менее, новые попытки выделения с использованием VirtualAlloc (или NtAllocateVirtualMemory), которая не указывает конкретный адрес, не будет выделяться в зарезервированном регионе.

Классический пример использования зарезервированной памяти описана далее в этой главе в разделе «Стеки потоков».

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

Операционная система, однако, также должна находиться где-то - и это где-то это верхний диапазон адресов, который поддерживается в системе следующим образом:
  • В 32-разрядных системах, работающих без настройки увеличения виртуального адресного пространства пользователя, операционная система находится в верхних 2 ГБ виртуального адресного пространства, от адреса 0x8000000 до 0xFFFFFFFF.
  • В 32-разрядных системах, настроенных с настройкой увеличения виртуального адресного пространства пользователя, операционная система находится в левом адресном пространстве.
Например, если система настроена с 3 ГБ, адресное пространство пользователя на процесс (максимальное), ОС занимает верхний 1 ГБ (от адреса, т. е. от 0xC0000000 до 0xFFFFFFFF).
Сущность, которая в основном страдает от этого сокращения адресного пространства, это кеш файловой системы.
  • В 64-разрядных системах в Windows 8, Server 2012 и более ранних версиях ОС требуется более 8 ТБ виртуального адресного пространства.
  • В 64-разрядных системах под управлением Windows 8.1, Server 2012 R2 и более поздних версий ОС занимает верхние 128 ТБ под виртуальное адресное пространство.
Системное пространство не относится к процессу - в конце концов, это та же «система», то же ядро, также и драйверы, которые обслуживают каждый процесс в системе (за исключением некоторой системной памяти, которая включена для каждой сессии, но это не важно для этого обсуждения).

Отсюда следует, что любой адрес в системе является абсолютным, а не относительным, поскольку он «выглядит» одинаково в любом контексте процесса.
Конечно, фактический доступ из пользовательского режима в системное пространство приводит к исключению нарушения прав доступа.

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

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

У процессов пользовательского режима, память будет всегда очищена, после его завершения.
Ядро отвечает за закрытие и освобождение всего приватного для мертвого процесса (все дескрипторы закрыты и вся приватная память освобождена).

Потоки
Фактические сущности, которые выполняют код, являются потоками. Поток содержится в процессе, используя ресурсы, предоставляемые процессом для выполнения работы (например, виртуальная память и дескрипторы объектов ядра).

Самая важная информация, которой владеет поток, следующая:
  • Текущий режим доступа, пользователь или ядро.
  • Контекст выполнения, включая регистры процессора и состояние выполнения.
  • Один или два стека, используемые для распределения локальных переменных и управления вызовами.
  • Массив локального хранилища потоков (TLS), который обеспечивает способ хранения личных данных потоков с семантикой унифицированного доступа.
  • Базовый приоритет и текущий (динамический) приоритет.
  • Привязка к процессору, указывающая, на каких процессорах разрешено запускать поток.

Наиболее распространенные состояния, в которых может находиться поток:
  • Выполняется - в настоящее время выполняется код на (логическом) процессоре.
  • Готов - ожидание запланированного выполнения, потому что все соответствующие процессоры заняты или недоступен.
  • Ожидание - ожидание какого-либо события перед продолжением. Как только событие происходит, поток переходит в состояние готовности.
Цифры в скобках указывают состояние, это можно увидеть с помощью таких инструментов, как Performance Monitor.

Обратите внимание, что состояние готовности имеет одноуровневое состояние, называемое Deferred Ready, которое аналогично и необходимо, чтобы минимизировать некоторые внутренние блокировки.

4.png

Рисунок 1-4. Диаграмма состояний потоков

Стеки потоков
Каждый поток имеет стек, который он использует при выполнении, стек используется для локальных переменных, передачи параметров в функции (в некоторых случаях), в стеке также хранятся адреса возврата до вызова функций.

Поток имеет по крайней мере один стек, находящийся в системном пространстве (ядре), и он довольно мал (по умолчанию 12 КБ для
32-разрядные системы и 24 КБ в 64-разрядных системах).

Поток пользовательского режима имеет второй стек в своем процессе.
Диапазон адресов пространства пользователя значительно больше (по умолчанию может увеличиваться до 1 МБ).
Пример три потока пользовательского режима и их стеки показаны на рисунке 1-5.

На рисунке потоки 1 и 2 находятся в процессе A и поток 3 находятся в процессе B.

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

Он начинается с небольшого объема выделенной памяти (может быть как одна страница), остальное адресное пространство стека, является зарезервированной памятью, что означает, что оно никоим образом не выделено.

Идея состоит в том чтобы иметь возможность увеличивать стек в случае, если код потока использует больше места в стеке.
Чтобы сделать это, следующая страница (иногда более одной) сразу после выделенной части помечается специальной защитой PAGE_GUARD - эта страница защищена.

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

5.png

Рисунок 1-5.Стеки потоков

6.png

Рисунок 1-6.Выделение памяти в стеке потоков
  • У исполняемого образа есть выделенный стек и зарезервированные значения в его Portable Executable (PE) заголовке. Они принимаются как значения по умолчанию, если поток не указывает альтернативные значения.
  • Когда поток создается с помощью CreateThread (и аналогичных функций), вызывающая сторона может указать его требуемый размер стека, либо предопределенный фиксированный размер, либо зарезервированный размер (но не оба).
Примечание:
Любопытно, что функции CreateThread и CreateRemoteThread (Ex) позволяют указав одно значение для размера стека и может быть зафиксированным или зарезервированным размером,но не оба.
Нативная (недокументированная) функция NtCreateThreadEx позволяет указывать оба значения.


Системные сервисы (Системные вызовы)
Приложения должны выполнять различные операции, которые не являются чисто вычислительными, такие как выделение памяти, открытие файлов, создание потоков и т. д.

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

Итак, как код пользовательского режима сможет выполнить такие
операции?

Давайте рассмотрим классический пример: пользователь, запускающий процесс «Блокнот», использует меню «Файл» для запроса на открытие файла.

Код Блокнота отвечает, вызывая документированный API Windows CreateFile.

CreateFile задокументирован как реализованный в библиотеке kernel32.Dll, одной из Windows DLL подсистемы.
Эта функция по-прежнему работает в пользовательском режиме, поэтому нет возможности напрямую открыть файл.

После некоторой проверки ошибок он вызывает NtCreateFile, функцию, реализованную в NTDLL.dll, это основополагающая DLL, которая реализует API, известный как «Native API», и фактически является самым низким слоем кода, который все еще находится в режиме пользователя.

Это официально недокументированный API - это API, который осуществляет переход в режим ядра.

Перед фактическим переходом ставится число, называемое системным сервисным номером, в регистр процессора (EAX на архитектурах Intel / AMD).

Затем выдается специальная инструкция процессору (syscall на x64 или sysenter на x86), которая осуществляет фактический переход в режим ядра.

Переход к предварительно определенной подпрограмме, вызывается диспетчером системных служб.
Диспетчер системных служб, в свою очередь, использует значение в регистре EAX в качестве индекса в Service Dispatch Table (SSDT).

Используя эту таблицу, код переходит на нужный системный сервис (системный вызов).
В нашем примере с Блокнотом, запись SSDT будет указывать на функцию NtCreateFile менеджера ввода-вывода.

Обратите внимание, что функция имеет то же имя, что и в NTDLL.dll, и фактически имеет те же самые аргументы.
Как только системная служба завершена, поток возвращается в пользовательский режим для выполнения инструкции следующей за sysenter /syscall. Эта последовательность событий изображена на рисунке 1-7.

7.png

Рисунок 1-7. Процесс выполнения системного вызова в системе

Общая системная архитектура системы
Рисунок 1-8 показывает общую архитектуру Windows, состоящую из компонентов пользовательского режима и режима ядра.

8.png

Рисунок 1-8. Общая архитектура Windows

Вот краткое изложение названных блоков, показанных на рисунке 1-8:
• Пользовательские процессы:
Это обычные процессы, основанные на отображенных файлах, выполняющихся в системе, например, экземпляры: Notepad.exe, cmd.exe, explorer.exe и так далее.

• Подсистема DLL:
DLL подсистема - это библиотеки динамических ссылок (DLL), которые реализуют API подсистемы.
Технически, начиная с Windows 8.1 существует только одна подсистема - подсистема Windows.
DLL подсистемы Windows включают в себя известные файлы, такие как kernel32.dll, user32.dll, gdi32.dll, advapi32.dll, combase.dll и много других.

К ним относится в основном официально документированный API Windows.

Можно проще, в этих библиотеках находятся API Windows, которые можно использовать в своих проектах.)

• NTDLL.DLL:
Общесистемная DLL, реализующая собственный API Windows. Это самый низкий уровень кода который все еще находится в режиме пользователя. Его самая важная роль - сделать переход в режим ядра.
NTDLL также реализует диспетчер кучи, загрузчик изображений и некоторую часть пула потоков пользовательского режима.

• Сервисные процессы:
Сервисные процессы - это обычные процессы Windows, которые взаимодействуют с Service Control Manager (SCM, реализован в services.exe) и позволяет контролировать время жизни сервисов.
SCM может запускать, останавливать, приостанавливать, возобновлять и отправлять другие сообщения службам.

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

Исполнение (Executive):
Исполнение - это верхний уровень NtOskrnl.exe («ядро»). Он содержит большую часть кода, который в режиме ядра.
В основном это различные «менеджеры»: диспетчер объектов, диспетчер памяти, диспетчер ввода-вывода, Plug & Play Manager, Power Manager, Configuration Manager и т. д.

• Ядро:
Уровень ядра реализует наиболее фундаментальные и чувствительные ко времени части ОС в режиме ядра.
Ядро включает в себя планирование потоков, обработку прерываний и исключений, а также реализацию различных примитивов ядра, таких как мьютекс и семафор.

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

• Драйверы устройств:
Драйверы устройств - это загружаемые модули ядра. Их код выполняется в режиме ядра. Эта книга посвящена написанию определенных типов драйверов ядра.

Win32k.sys:
«Компонент режима ядра подсистемы Windows». По сути это модуль ядра
(драйвер), который обрабатывает часть пользовательского интерфейса Windows и классические API-интерфейсы графического устройства (GDI). Это означает, что все оконные операции (CreateWindowEx, GetMessage, PostMessage и т.l .) обрабатываются этим компонентом.

Уровень аппаратной абстракции (HAL):
HAL - это уровень абстракции над оборудованием, ближайшим к процессору. Это позволяет драйверам устройств использовать API, которые не требуют подробных и конкретных знаний таких вещей, как контроллер прерываний или контроллер DMA. Естественно, этот слой в основном полезен для драйверов устройств, написанных для обработки аппаратных устройств.

• Системные процессы:
Системные процессы - это общий термин, используемый для описания процессов, которые обычно «просто существуют».
Данные процессы нужны для совершения некоторых системных действий, например вход пользователя в систему (Winlogon.exe) .

Тем не менее, это критичные процессы, завершение некоторых из них может привести к сбою системы, или неправильной её работы.
Некоторые системные процессы являются нативными процессами, это означает, что они используют только собственный API (API, реализованный NTDLL).
Пример системых процессов: Smss.exe, Lsass.exe, Winlogon.exe, Services.exe и другие.

• Подсистема Процесс:
Процесс подсистемы Windows, выполняющий образ Csrss.exe, может рассматриваться как помощник ядра для управления процессами, работающими в системе Windows.
Это критический процесс, это означает, что если он будет остановлен, система потерпит крах.)
Обычно есть один экземпляр Csrss.exe для сеанса, поэтому в стандартной системе существует два экземпляра - один для сеанса ядра (0) и один для сеанса пользователя, вошедшего в систему (обычно 1).

Hyper-V Hypervisor
Гипервизор Hyper-V существует в Windows 10 и сервере 2016 (и более поздних версиях).
Поддержка виртуализации на основе безопасности (VBS).

VBS обеспечивает дополнительный слой уровня 0, фактически машина является виртуальной машиной, управляемой Hyper-V.
Для получения дополнительной информации, ознакомьтесь с книгой «Windows Internals».

Примечание:
В Windows 10 версии 1607 была представлена подсистема Windows для Linux (WSL).
Хотя это может выглядеть как еще одна подсистема, например, поддерживаемые старые подсистемы POSIX и OS / 2 Windows, но это совсем не так.
Старые подсистемы могли выполнять POSIX и приложения OS / 2, если программы были скомпилированы на компиляторе Windows.
WSL, с другой стороны, не имеет такого требования. Существующие исполняемые файлы из Linux (хранящиеся в формате ELF) могут быть запущены как есть на винде, без перекомпиляции.

Чтобы это сделать, был создан новый тип процесса - процесс Pico вместе с провайдером Pico.
Вкратце, процесс Pico - это пустое адресное пространство (минимальный процесс), который используется для процессов WSL, где каждый системный вызов (системный вызов Linux) должен быть перехвачен и переведен в эквивалент системных вызовов Windows, используя для этого провайдер Pico (драйвер устройства).

На компьютере с Windows установлен настоящий Linux (часть пользовательского режима).


Handles and Objects
Ядро Windows предоставляет различные типы объектов для использования процессами пользовательского режима, ядром и драйверами режима ядра.

Экземплярами этих типов являются структуры данных в системном пространстве, созданные диспетчером объектов (часть исполнения) по запросу пользователя или кода режима ядра.
Объекты считаются ссылками - только когда последняя ссылка на объект будет освобождена, этот объект может-быть уничтоженным и освобожденным в памяти.

Поскольку эти экземпляры объектов находятся в системном пространстве, они не могут быть доступны напрямую в режиме пользователя.
Пользовательский режим должен использовать механизм косвенного доступа, известный как дескрипторы.

Дескриптор является указателем на запись в таблице, поддерживаемая процессом по каждому процессу, которая логически указывает на объект ядра проживающий в системном пространстве.
Существуют различные функции Create * и Open * для создания/открытия объектов и восстанавливающие обратные маркеры для этих объектов.

Например, функция пользовательского режима CreateMutex позволяет делать создание или открытие мьютекса (в зависимости от того, существует-ли объект).
В случае успеха функция возвращает дескриптор объекта.

Возвращаемое значение ноль означает недопустимый дескриптор (и сбой вызова функции). Функция OpenMutex, с другой стороны, пытается открыть дескриптор для именованного мьютекса. Если мьютекс с таким именем не существует, функция завершается ошибкой и возвращает ноль (0).

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

Мы обсудим эти подробности в следующей главе.

Примечание:
Большинство функций возвращают ноль (ноль) при сбое, но некоторые нет. В частности, CreateFile возвращает INVALID_HANDLE_VALUE (-1), если произошла ошибка.

Значения дескриптора кратны 4, где первый действительный дескриптор равен 4.
Ноль никогда не является допустимым значением дескриптора.

Код режима ядра может использовать дескрипторы при создании /открытии объектов, но они также могут использовать прямой указатель на объекты ядра.
Обычно это делается, когда этого требует определенный API. Код ядра может получить указатель на объект с заданным допустимым дескриптором, используя функцию ObReferenceObjectByHandle.

В случае успеха счетчик ссылок на объект увеличивается.
Если пользовательская программа (клиент) решила закрыть дескриптор, то вызывается функция ObDerefenceObject, которая уменьшает счетчик ссылок.

Если код ядра пропустит этот вызов, то произойдет утечка ресурсов, которая будет решена только при следующей перезагрузки системы.
Как только объект больше не нужен, его клиент должен закрыть дескриптор (если дескриптор был использован для доступа к объекту) или разыменования объекта (если клиент ядра использует указатель).

После этого, код должен считать свой дескриптор/указатель недействительным. Менеджер объектов уничтожит объект, если его счетчик ссылок достигает нуля.
Каждый объект указывает на тип объекта, который содержит информацию о самом типе

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

Имена объектов
Некоторые типы объектов могут иметь имена. Эти имена могут быть использованы для открытия объектов по имени с подходящей функцией Open.

Обратите внимание, что не все объекты имеют имена; например, процессы и потоки у них нет имен - у них есть идентификаторы.
Вот почему функции OpenProcess и OpenThread требуют идентификатор процесса/потока (число), а не строковое имя.

Еще один интересный случай - объект, который не имеет имени, является файлом.
Имя файла не является именем объекта – это разные понятия.

Из кода пользовательского режима вызов функции Create с именем создает объект с этим именем, если объект с таким именем не существует, но если он существует, он просто открывает существующий объект.

В этом случае вызов GetLastError возвращает ERROR_ALREADY_EXISTS, указывая, что это не новый объект и возвращенный дескриптор является еще одним дескриптором существующего объекта.

Имя, предоставленное функции Create, на самом деле не является окончательным именем объекта.

Это предварительно добавленная строка: with\Sessions\x\BaseNamedObjects\ где:

X - идентификатор сеанса вызывающего абонента. Если сеанс равен нулю, к имени добавляется [I]\BaseNamedObjects\.[/I]

Если вызывающая сторона работает в AppContainer (обычно это процесс универсальной платформы Windows), тогда предварительно добавленная строка является более сложной и состоит из уникального SID AppContainer:
\Sessions\x\AppContainerNamedObjects\{AppContainerSID}

Все вышеизложенное означает, что имена объектов являются относительными к сеансу.

Если объект должен быть разделен между сеансами, он может быть создан в сеансе 0, добавление имени объекта к Global\.
Например, создание мьютекса с помощью функции CreateMutex с именем Global\MyMutex создаст ее в \BaseNamedObjects.

Обратите внимание, что значения AppContainersHandle кратны 4, где первый действительный дескриптор равен 4.
Ноль никогда не является допустимым значением дескриптора.

Обратите внимание, что AppContainers не могут использовать пространство имен объекта сеанса 0.

9.png

Рисунок 1-9.Иерархия имен объектов

Эту иерархию можно просмотреть с помощью инструмента Sysinternals WinObj (запуск с правами администратора), как показано на рисунке 1-9.
Показанное на рисунке 1-9, является пространством имен менеджера объектов, состоящим из иерархии именованных объектов.

Вся эта структура хранится в памяти и управляется диспетчером объектов по мере необходимости. Обратите внимание, что неназванные объекты не являются частью этой структуры, то есть объекты, видимые в WinObj, включают не все существующие объекты, а все объекты, которые были созданs с именем.

Каждый процесс имеет собственную таблицу дескрипторов объектов ядра (именованных или нет), которые можно просматреть с помощью инструментов Process Explorer и/или Handles Sysinternals.
Снимок экрана процесса «Проводник», показывающий маркеры, показан на рисунке 1-10.

Столбцы по умолчанию, показывают «handles», «тип объекта» и «имя».
Тем не менее, есть и другие доступные столбцы, как показано на рисунке 1-10.

10.png

Рисунок 1-10.Снимок экрана процесса «Проводник»

По умолчанию Process Explorer показывает только дескрипторы для объектов, которые имеют имена .
Чтобы просмотреть все маркеры в процессе, выберите «Show Unnamed Handles and Mappings from Process» из меню «View» в Process Explorer.

Доступ к существующим объектам
Столбец «Access» в представлении дескрипторов Process Explorer показывает маску доступа, которая использовалась для открытия или создания объекта. Эта маска доступа является ключом к тому, какие операции разрешено выполнять с определенным объектом.

Например, если клиентский код хочет завершить процесс, он должен вызвать сначала функцию OpenProcess, чтобы получить дескриптор требуемого процесса с маской доступа (минимум) PROCESS_TERMINATE, иначе не будет возможности завершить процесс с этим дескриптором.

Если вызов успешен, то вызов TerminateProcess обязательно будет успешным. Вот пример кода завершения процесса с указанным идентификатором процесса:
C:
bool KillProcess(DWORD pid) {
// open a powerful-enough handle to the process
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
if (!hProcess)
return false;
// now kill it with some arbitrary exit code
BOOL success = TerminateProcess(hProcess, 1);
// close the handle
CloseHandle(hProcess);
return success != FALSE;
}

Столбец «Decoded Access» в Process Explorer, содержит текстовое описание маски доступа (для некоторых типов объектов), упрощая распознавание точного доступа, разрешенного для определенного дескриптора.
Двойной щелчок по записи дескриптора показывает некоторые свойства объекта. Рисунок 1-11 показывает свойства объекта.

11.png

Рисунок 1-11.Свойства объекта

Свойства на рисунке 1-11 включают имя объекта (если есть), его тип, описание, его адрес в памяти ядра, число открытых дескрипторов и некоторая детальная информация об объекте, такая как состояние и тип отображаемого объекта события.
Обратите внимание, что показанные ссылки не указывают на фактическое количество ссылок на объект.
Правильный способ увидеть фактический счетчик ссылок для объекта, должен использовать команду!trueref отладчика ядра, как показано здесь:
Bash:
lkd> !object 0xFFFFA08F948AC0B0
Object: ffffa08f948ac0b0 Type: (ffffa08f684df140) Event
ObjectHeader: ffffa08f948ac080 (new version)
HandleCount: 2 PointerCount: 65535
Directory Object: ffff90839b63a700 Name: ShellDesktopSwitchEvent

lkd> !trueref ffffa08f948ac0b0
ffffa08f948ac0b0: HandleCount: 2 PointerCount: 65535 RealPointerCount: 3

Мы рассмотрим более подробно атрибуты объектов и отладчик ядра в следующих главах.

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

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

От ТС

Напоминаю что перевод делал не я, автор - Virt
Сам перевод взят вот отсюда - Ru-sfera
 
Глава 2 - Начало работы с инструментами разработчика ядра
В этой главе:
  • Установка инструментов.
  • Создание проекта драйвера.
  • Описание процедур DriverEntry и Unload.
  • Разработка драйвера.
  • Простая трассировка.
Установка инструментов
В старые времена (до 2012 года) процесс разработки и сборки драйверов включал использование особого инструмента для сборки из комплекта драйверов устройств (DDK).
Нужно-было качать специальный пакет компиляторов, далее в командной строке собирать драйвер, не было никакой интеграции в Visual Studio.
Были некоторые обходные пути, но ни один из них не был ни идеален, ни официально поддержан.

К счастью, начиная с Visual Studio 2012 и Windows Driver Kit 8, Microsoft начала официально поддерживать сборку драйверов с помощью Visual Studio (и msbuild), без необходимости использовать отдельный
компилятор и инструменты сборки.Чтобы начать разработку драйверов, необходимо установить следующие инструменты (в следующем порядке):
  • Visual Studio 2017 или 2019 с последними обновлениями (Убедитесь, что C ++ выбрана во время установки. Обратите внимание, что подойдет любой SKU, включая бесплатная версия Community)
  • Windows 10 SDK.
  • Windows 10 Driver Kit (WDK).
  • Инструменты Sysinternals, необходимы для отладки драйверов, можно скачать бесплатно на Windows Sysinternals - Windows Sysinternals.

Нажмите на Sysinternals Suite слева от этой веб-страницы и загрузите файл Sysinternals Suite. Распакуйте в любую папку, и инструменты готовы к работе.

Быстрый способ убедиться, что шаблоны WDK установлены правильно, - это открыть Visual Studio и выберите «Новый проект» и найдите проекты драйверов, например «Пустой драйвер WDM».

Создание проекта драйвера
При наличии вышеуказанных пакетов можно создать новый проект драйвера.
Шаблон, который вы будете использовать в этом разделе «Пустой драйвер WDM».
Рисунок 2-1 показывает, как выглядит диалог New Project для этого типа драйвера в Visual Studio 2017. На рисунке 2-2 показан тот же начальный мастер в Visual Studio 2019.

1.png

Рисунок 2-1.Новый проект драйвера в Visual Studio 2017

2.png

Рисунок 2-2.Новый проект драйвера в Visual Studio 2019


После создания проекта в обозревателе решений отображается один файл - Sample.inf. Вам не нужен этот файл в этом примере, так что просто удалите его.

Теперь пришло время добавить исходный файл. Щелкните правой кнопкой мыши узел «Исходные файлы» в обозревателе решений и выберите:

Добавить/Новый элемент ... из меню Файл.

Выберите исходный файл C ++ и назовите его Sample.cpp. Нажмите ОК, чтобы создать файл.

Функции DriverEntry и Unload
По умолчанию каждый драйвер имеет точку входа DriverEntry.
Это можно считать «main» драйвера, если сопоставить с классическим основным приложением пользовательского режима.

DriverEntry имеет следующий прототип, показанный здесь:
C:
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath);

_In_ аннотации являются частью языка аннотаций исходного кода (SAL). Эти аннотации прозрачны для компилятора, но предоставляют метаданные, полезные для читателей и инструментов статического анализа.

Мы постараемся максимально использовать их для улучшения ясности кода.
DriverEntry может просто вернуть успешный статус, например так:
C:
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    return STATUS_SUCCESS;
}

Этот код еще не скомпилируется. Во-первых, вам нужно включить заголовок, который имеет определения для типов, присутствующих в DriverEntry.
Например так:
C:
#include <ntddk.h>

Теперь код имеет больше шансов на компиляцию, но все равно потерпит неудачу.

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

Эти предупреждения могут быть устранены путем полного удаления имен аргументов (или комментирования их), что хорошо для файлов C ++.
Существует еще один классический способ решения этой проблемы, который заключается в использовании макроса UNREFERENCED_PARAMETER:
C:
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(DriverObject);
    UNREFERENCED_PARAMETER(RegistryPath);
    return STATUS_SUCCESS;
}

Вот объявление данного макроса:

#define UNREFERENCED_PARAMETER(P) (P)

Сборка проекта теперь компилируется нормально, но вызывает ошибку компоновщика.

Функция DriverEntry должна-быть C-linkage, который не используется по умолчанию при компиляции C ++.
Вот финальная версия успешной сборки драйвера, состоящего только из функции DriverEntry:
C:
extern "C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(DriverObject);
    UNREFERENCED_PARAMETER(RegistryPath);
    return STATUS_SUCCESS;
}

В какой-то момент драйвер может быть выгружен.
В то время все, что сделано в функции DriverEntry должно быть отменено.

Невыполнение этого требования приводит к утечке, которую ядро не очистит до следующей перезагрузки.
Драйверы могут иметь процедуру выгрузки, которая автоматически вызывается перед выгрузкой драйвера из памяти.
Указатель на функцию, которая должна выполнится перед выгрузкой из памяти должна быть установлена с помощью элемента DriverUnload объекта драйвера:

DriverObject->DriverUnload = SampleUnload;

Процедура выгрузки принимает объект драйвера (тот же, что передан в DriverEntry).
Поскольку пример нашего драйвера ничего не делает с точки зрения распределения ресурсов в DriverEntry, то и в процедуре Unload ничего не нужно делать, поэтому мы можем пока оставить ее пустой:
C:
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
}

Вот полный код драйвера на данный момент:
C:
#include <ntddk.h>

void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
   UNREFERENCED_PARAMETER(DriverObject);
}

extern "C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);
    DriverObject->DriverUnload = SampleUnload;
    return STATUS_SUCCESS;
}

Развертывание и запуск драйвера
Теперь у нас есть успешно скомпилированный файл драйвера Sample.sys, давайте установим его в систему, а затем загрузим его.
Обычно лучше устанавливать и загружать драйвер на виртуальную машину, чтобы устранить риск сбоя вашей основной машины.

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

Один из известных инструментов для этого программа Sc.exe, это встроенный в Windows инструмент для управления сервисами. Мы будем использовать этот инструмент для установки и затем загрузим драйвер.
Обратите внимание, что установка и загрузка драйверов является привилегированной операцией, обычно разрешено только для администраторов.

Откройте командное окно с повышенными правами и введите следующее (последняя часть должна быть указана для вашей системы, где находится файл SYS):
sc create sample type= kernel binPath= c:\dev\sample\x64\debug\sample.sys

Обратите внимание, что между типом и знаком равенства нет пробела, а между знаком равенства и kernel пробел. То же самое касается второй части.
Если все идет хорошо, результат должен указывать на успех. Чтобы проверить установку, вы можете открыть реестр редактор (regedit.exe) и найдите драйвер в HKLM\System\CurrentControlSet\Services\Sample[/B].

Рисунок 2-3 показывает снимок экрана редактора реестра после предыдущей команды.

3.png

Рисунок 2-3. Снимок экрана редактора реестра

Чтобы загрузить драйвер, мы можем снова использовать инструмент Sc.exe, на этот раз с параметром запуска, который использует API-интерфейс StartService для загрузки драйвера (тот же API, который используется для загрузки служб).

Однако на 64 бит системные драйверы должны быть подписаны, и поэтому обычно следующая команда не будет работать:
sc start sample

Поскольку подписывать драйвер во время разработки неудобно (возможно, даже невозможно, если вы не имете соответствующий сертификат), лучший вариант - перевести систему в тестовый режим.
В этом режиме неподписанные драйверы могут быть загружены без проблем.

С командным окном с повышенными правами тестовый режим может быть включен следующим образом:
Bash:
bcdedit /set testsigning on

К сожалению, эта команда требует перезагрузки для вступления в силу. После перезагрузки можно стартовать драйвер.

ВАЖНО:
Если вы тестируете в Windows 10 с включенной безопасной загрузкой, нельзя включить тестовый режим.
Это одно из свойств, защиты Secure Boot (также защищен от локальной отладки ядра).

Если вы не можете отключить безопасную загрузку через настройки BIOS, из-за ИТ-политики или по какой-то другой причине, ваш лучший вариант - это тестирование на виртуальной машине.

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

Диалоговое окно, как показано на рисунке 2-4.
Обратите внимание, что я выбрал все конфигурации и все платформы, чтобы при переключении конфигураций (Debug / Release) или платформ (x86 / x64 / ARM / ARM64), настройка сохранялась.

4.png

Рисунок 2-4. Настройки целевой системы, под которую будет собираться драйвер

Когда тестовый режим включен и драйвер загружен, вы должны увидеть следующее (sc state sample):
5.png


Это означает, что все хорошо, и драйвер загружен. Чтобы это проверить, мы можем открыть Process Explorer и найти Sample.sys в процессе System:

6.png

Рисунок 2-5. Скриншет Process Explorer и поиск драйвера Sample

Что-бы выгрузить драйвер, достаточно ввести команду:sc stop sample.
Выгрузка драйвера вызывает вызов процедуры Unload, которая в этом драйвере ничего не делает.
Вы можете убедиться, что драйвер действительно выгружен, снова взглянув на Process Explorer.

Простая трассировка
Как мы можем точно знать, что процедуры DriverEntry и Unload действительно выполнены ?
Давайте добавим базовое отслеживание этих функций. Драйверы могут использовать макрос KdPrint для вывода текста в стиле printf, который можно просмотреть с помощью отладчика ядра и других инструментов.

KdPrint - это макрос, который компилируется только в Debug,создает и вызывает базовый API ядра DbgPrint.

Пример обновленного драйвера, который использует отладочные выводы:
C:
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {

UNREFERENCED_PARAMETER(DriverObject);
    KdPrint(("Sample driver Unload called\n"));
}

extern "C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);
    DriverObject->DriverUnload = SampleUnload;
    KdPrint(("Sample driver initialized successfully\n"));

    return STATUS_SUCCESS;
}

Обратите внимание на двойные скобки при использовании KdPrint. Это необходимо, потому что KdPrint является макросом, но по-видимому, принимает любое количество аргументов, а-ля printf.
Поскольку макросы не могут получить переменное число аргументов, трюк компилятора используется для вызова реальной функции DbgPrint.

Теперь мы должны снова загрузить драйвер и увидеть эти сообщения.
Мы будем использовать отладчик ядра в главе 4, но сейчас мы будем использовать полезный инструмент Sysinternals с именем DebugView.

Перед запуском DebugView вам нужно сделать некоторые приготовления.

Во-первых, начиная с Windows Vista, вывод DbgPrint фактически не генерируется, если в реестре не указано определенное значение.

Вам нужно добавить ключ с именем Debug Print Filter в HKLM\SYSTEM\CurrentControlSet\Control\Session Manager[/B][/I]
Ключ обычно не существует. В этом новом ключе добавьте значение DWORD с именем DEFAULT (это не значение по умолчанию, которое существует в любом ключе) и установите его значение равным 8

Рисунок 2-6 показывает настройку в RegEdit.
К сожалению, вам придется перезагрузить систему чтобы этот параметр вступил в силу.

7.png

Рисунок 2-6. Включение отображение дебажного вывода в драйвере

После применения этого параметра запустите DebugView (DbgView.exe) с повышенными правами.
В меню «Параметры» убедитесь, что выбрано Capture Kernel (или нажмите Ctrl + K).

Соберите драйвер, если вы этого еще не сделали. Теперь вы можете снова загрузить драйвер.
Вы должны увидеть выходные данные в DebugView, как показано на рисунке 2-7.
Третья строка вывода получена из другого драйвера и не имеет ничего общего с нашим примером драйвера.

8.png

Рисунок 2-7. Вывод утилиты DebugView

Упражнения
Добавьте код в образец DriverEntry для вывода версии ОС Windows: мажорной, минорной и номер сборки. Используйте функцию RtlGetVersion для получения информации. Проверьте результаты с DebugView.

Резюме
Мы увидели инструменты, необходимые для разработки ядра, и написали очень маленький драйвер, что-бы проверить основные инструменты в работе.

В следующей главе мы рассмотрим основные API ядра, концепции и структуры.

От ТС
Напоминаю что перевод делал не я, автор - Virt
Сам перевод взят вот отсюда - Ru-sfera
 
Глава 3 - Основы программирования ядра windows
В этой главе мы углубимся в API, структуры и определения ядра.
Мы также рассмотрим некоторые механизмы, которые вызывают код в драйвере.
Наконец, мы объединим все эти знания, чтобы создать наш первый функциональный драйвер.

В этой главе:
  • Общие рекомендации по программированию ядра.
  • Отладка и сборки релизов.
  • API ядра.
  • Функции и коды ошибок.
  • Строки.
  • Динамическое распределение памяти.
  • Списки.
  • Объект драйвера.
  • Объекты устройства.
Общие рекомендации по программированию ядра
Для разработки драйверов ядра требуется Windows Driver Kit (WDK), где находятся соответствующие заголовки и необходимые библиотеки.

API ядра состоит из функций, написанных на языке «C», очень похожих по сути на разработку пользовательского режима.
Однако есть несколько отличий. Таблица 3-1 показывает важные различия между программированием в пользовательском режиме и программированием в режиме ядра.

1.png


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

Код режима ядра, с другой стороны, будучи доверенным, не может восстановиться после необработанного исключения.
Такое исключение вызывает сбой системы с печально известным синим экраном смерти (BSOD) (более новые версии Windows имеют более разнообразные цвета для аварийного экрана).

Поначалу BSOD может показаться наказанием, но по сути это защитный механизм.

Рациональный в то время как продолжение выполнения кода может привести к необратимому повреждению Windows (например, удаление важных файлов или повреждение реестра), которые могут привести к сбою загрузки системы.
Поэтому лучше немедленно все остановить, чтобы предотвратить потенциальный ущерб. Мы обсудим BSOD более подробно в главе 6.

Все это приводит по крайней мере к одному простому выводу: код ядра должен быть тщательно закодирован, тщательно и не пропуская какие-либо детали или проверки ошибок.

Завершение работы
Когда процесс завершается по какой-либо причине, то вся личная память освобождается, все дескрипторы закрываются и т. д.

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

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

Почему так ?
Ядро не может отслеживать распределение драйверов и использование ресурсов, поэтому они не могут быть освобождены автоматически при выгрузке драйвера.

Теоретически этого было бы возможно достичь (хотя в настоящее время ядро не отслеживает такое использование ресурса).
Реальная проблема заключается в том, что ядру было бы слишком опасно пытаться очистить ресурсы драйвера.
В ядре нет способа узнать, утекли-ли ресурсы драйвера по какой-то причине.

Например, драйвер может выделить некоторый буфер и затем передать его другому драйверу.
Этот второй драйвер может использовать буфер памяти и в конце концов освободить его.

Если ядро попытается освободить буфер при выгрузке первого драйвера, второй драйвер вызовет нарушение при доступе к этому теперь освобожденному буферу, и в итоге вызовет сбой системы.

Это еще раз подчеркивает ответственность драйвера ядра за правильную очистку после себя; никто остальной не сделает это.

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

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

Игнорирование возвращаемых значений из API ядра намного опаснее, и вообще следует избегать. Даже на первый взгляд «невинные» функции могут потерпеть неудачу, поэтому золотое правило здесь - всегда проверяйте возвращаемые значения статуса из API ядра.

IRQL

Interrupt Request Level (IRQL) является важной концепцией ядра, которая будет более подробно рассмотрена в главе 6.

Достаточно сказать, что обычно IRQL процессора равен нулю, а точнее, это всегда ноль, когда код пользовательского режима выполняется.
В режиме ядра большую часть времени он все еще равен нулю, но не все время.
Эффекты IRQL выше нуля будут обсуждаться в главе 6.

Использование С++
В программировании пользовательского режима C ++ использовался много лет, и он хорошо работает в сочетании с вызовами API пользовательского режима.

С кодом ядра Microsoft начала официально поддерживать C ++ с Visual Studio 2012 и WDK 8.
C ++, конечно, не является обязательным, но имеет ряд важных преимуществ, связанных с очисткой ресурсов, используя идиому C ++ под названием Resource Acquisition Is Initialization (RAII).

C ++ как язык почти полностью поддерживается для кода ядра.
Но в C ++ нет времени исполнения в ядре, и поэтому некоторые функции C ++ просто не могут быть использованы:
  • Операторы new и delete не поддерживаются и не будут компилироваться. Это потому что их нормальная работа - выделять из кучи пользовательского режима, что, конечно, невозможно в ядре.
  • API ядра имеет функции «замены», malloc и free. Мы обсудим эти функции позже в этой главе. Возможно,однако перегрузить эти операторы аналогично тому, как это делается в пользовательском режиме C ++, и вызвать распределение ядра и свободные функции. Мы увидим, как это сделать позже в этой главе.
  • Глобальные переменные, которые имеют конструкторы, отличные от заданных по умолчанию, не будут вызываться. Этих ситуаций можно избежать несколькими способами:
  • Избегайте любого кода в конструкторе и вместо этого создайте некоторую функцию Init для вызова явно из кода драйвера (например, из DriverEntry).
  • Выделите указатель только как глобальную переменную и динамически создайте фактический экземпляр.
  • Компилятор сгенерирует правильный код для вызова конструктора. Это работает при условии, если операторы new и delete были перегружены, как описано далее в этой главе.
  • Ключевые слова обработки исключений в C ++ (try, catch, throw) не компилируются. Это потому что механизм обработки исключений C ++ требует собственной среды выполнения, которой нет в ядре.
  • Обработка исключений может быть выполнена только с использованием структурированной обработки исключений (SEH). Мы подробно рассмотрим SEH в главе 6.
  • Стандартные библиотеки C ++ недоступны в ядре. Хотя большинство из них основано на шаблонах, они не компилируются, потому что они зависят от библиотек пользовательского режима и семантики.
  • Шаблоны C ++ как языковая функция прекрасно работают и могут быть использованы, например, для создания альтернативных типов для типов библиотеки пользовательского режима, такие как std::vector<>, std::wstring и т. д.
Примеры кода в этой книге используют C ++.

Функции, в основном используемые в примерах используют следующие типы:
  • Ключевое слово nullptr, представляющее истинный указатель NULL.
  • Ключевое слово auto, позволяющее вывести тип при объявлении и инициализации переменных.
Это полезно для уменьшения беспорядка, и сосредоточения внимания на важных деталях.
  • Шаблоны будут использоваться, когда они имеют смысл.
  • Перегрузка новых операторов.
  • Конструкторы и деструкторы, особенно для построения типов RAII.

Строго говоря, драйверы могут быть написаны на чистом C без каких-либо проблем. Если вы предпочитаете идти по этому пути, используйте файлы с расширением C, а не CPP. Это автоматически вызовет компилятор Си.

Тестирование и отладка

При использовании кода пользовательского режима тестирование обычно выполняется на компьютере разработчика (если все требуемые зависимости могут быть удовлетворены).

Отладка обычно выполняется путем подключения отладчика (Visual Studio в большинстве случаев) к запущенному процессу (или процессам).

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

Это означает, что на машине разработчика находится сам отладчик, а на второй машине (опять же, как правило, виртуальная машина) выполняет код драйвера.
Эти две машины должны быть связаны через некоторый механизм, чтобы данные могли проходить между хостом (где работает отладчик) и таргетом. Мы рассмотрим отладку ядра более подробно в главе 5.

Отладка и сборка проекта

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

Действительными терминами в терминологии ядра являются Checked (Debug) и Free (Release).
Хотя Visual Studio продолжает использовать термины Debug/Release, более старая документация использует Debug/free.
С точки зрения компиляции, сборки отладки ядра определяют дефайн DBG и устанавливают его значение равным 1 (по сравнению с дефайном _DEBUG, определенным в режиме пользователя).

Это означает, что вы можете использовать дефайн DBG, чтобы различать сборки Debug и Release с условной компиляцией.
Фактически это то, что делает макрос KdPrint: в сборках Debug он компилируется в вызов DbgPrint, а в сборке Release он компилируется в пустую строку, в результате чего вызовы KdPrint не влияют на сборку Release.

API ядра
Драйверы ядра используют экспортированные функции из компонентов ядра. Эти функции будут называться API ядра. Большинство функций реализовано в самом модуле ядра (NtOskrnl.exe), но некоторые могут быть реализованы другими модулями ядра, такими как HAL (hal.dll).

Kernel API - это большой набор функций языка Си. Большинство из этих функций начинаются с префикса, компонента, реализующий эту функцию.

В таблице 3-2 приведены некоторые распространенные префиксы и их смысл:
2.png


Если вы посмотрите на список экспортируемых функций из NtOsKrnl.exe, вы найдете больше функций, которые фактически задокументированы в комплекте драйверов Windows; это просто факт жизни разработчика ядра - не все задокументировано.

На этом этапе обсуждается один набор функций - функции с префиксом Zw.
Эти функции зеркальное отображение собственных API-интерфейсов, доступных в виде шлюзов из NtDll.Dll (Обертки).

Когда функция Nt вызывается из пользовательского режима, как например NtCreateFile, она фактически вызывает NtCreateFile в ядерном режиме.

При этом NtCreateFile может сделать различные проверки, основанные на том, что исходный вызывающий абонент находится в режиме пользователя.

Эта информация вызывающей стороны хранится по отдельности, в недокументированном элементе [ICODE]PreviousMode[/ICODE] в KTHREAD структуры для каждого потока.

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

Как работаю Zw функции:
Вызов функции Zw устанавливает текущий режим вызова в KernelMode(0), а затем вызывается родная функция (Системный вызов).

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

Суть в том, что драйверы ядра должны вызывать функции Zw если нет веской причины поступить иначе.

Функции и коды ошибок
Большинство функций API ядра возвращают статус, указывающий на успех или неудачу операции.
Возвращающее значение как NTSTATUS, 32-разрядное целое число со знаком.

Значение STATUS_SUCCESS(0) указывает на успех. Отрицательный значение указывает на какую-то ошибку.

Часто проверку на ошибки можно сделать с помощью макроса NT_SUCCESS.

Вот пример, который проверяет на неудачу и регистрирует ошибку, если это так:
C:
NTSTATUS DoWork() {
    NTSTATUS status = CallSomeKernelFunction();
    if(!NT_SUCCESS(Statue)) {
        KdPirnt((L"Error occurred: 0x%08X\n", status));
        return status;
    }
    // continue with more operations
    return STATUS_SUCCESS;
}

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

В этих случаях значение STATUS_xxx преобразуется в некоторое значение ERROR_yyy, которое доступно для пользовательский режима через функцию GetLastError.
Обратите внимание, что это не одинаковые цифры.В этом случае это обычно не относится к драйверу ядра.

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

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

В некоторых случаях эти строки являются простыми Unicode-указателями (wchar_t * или один из их typedef-ов, таких как WCHAR), но большинство функций работают со строками ожидая структуру типа UNICODE_STRING.

Термин Unicode, используемый в этой книге, примерно эквивалентен UTF-16, что означает 2 байта на символ. Вот так строки хранятся внутри компонентов ядра.
Структура UNICODE_STRING представляет строку с известной длиной и максимальной длиной.
Вот упрощенное определение структуры:
C:
typedef struct _UNICODE_STRING {
    USHORT Length;
    USHORT MaximumLength;
    PWCH
    Buffer;
}UNICODE_STRING;

typedef UNICODE_STRING *PUNICODE_STRING;
typedef const UNICODE_STRING *PCUNICODE_STRING;

Управление структурами UNICODE_STRING обычно выполняется с помощью набора функций Rtl, которые имеют дело со строками.

Некоторые из общих функций для работы со строками обеспечивается функциями Rtl:
  • RtlInitUnicodeString - Инициализирует UNICODE_STRING на основе существующей C-строки. Он устанавливает буфер, затем вычисляет длину и устанавливает MaximumLength к тому же значению. Обратите внимание, что эта функция не выделяет никакой памяти - она просто инициализирует внутренние элементы.
  • RtlCopyUnicodeString - Копирует один UNICODE_STRING в другой. Строка назначения (указатель на буфер) должен быть выделен перед копированием и максимальная длина установлена соответствующим образом.
  • RtlCompareUnicodeString - Сравнивает два UNICODE_STRING (равно, меньше, больше), указывая делать ли сравнение с учетом регистра или без учета регистра.
  • RtlEqualUnicodeString — Сравнивает две строки, на равенство.
  • RtlAppendUnicodeStringToString - Добавляет один UNICODE_STRING к другому.
  • RtlAppendUnicodeToString - Добавляет UNICODE_STRING к строке в стиле C.

В дополнение к вышеупомянутым функциям существуют функции, которые работают с указателями на C-строку.

Более того, некоторые из хорошо известных строковых функций из библиотеки C Runtime реализованы в ядре для удобства: wcscpy, wcscat, wcslen, wcscpy_s, wcschr, strcpy, strcpy_s и другие.

Префикс wcs работает со строками C Unicode, а префикс str работает со строками C Ansi.

Суффикс _s в некоторых функциях указывает на безопасную функцию, где дополнительный аргумент с указанием максимальной длины строки, чтобы функции не передать больше данных, чем этот размер.

Динамическое распределение памяти
Драйверам часто нужно динамически выделять память.

Как обсуждалось в главе 1, размер стека ядра довольно маленький, поэтому любой большой кусок памяти должен выделяться динамически.

Ядро предоставляет два общих пула памяти для использования драйверами (само ядро также использует их).
  • Выгружаемый пул - пул памяти, который может быть выгружен при необходимости.
  • Non Paged Pool - пул памяти, который никогда не выгружается и гарантированно останется в оперативной памяти.
Ясно, что невыгружаемый пул является «лучшим» пулом памяти, поскольку он никогда не может вызвать сбой страницы.

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

Ниже приведены наиболее полезные функции, используемые для работы с пулами памяти ядра:
  • ExAllocatePool - Выделение памяти из одного из пулов с помощью тега по умолчанию. Эта функция считается устаревшей. Следующая функция должна использоваться вместо этой.
  • ExAllocatePoolWithTag — Выделение памяти из одного из пулов с указанным тегом.
  • ExAllocatePoolWithQuotaTag - Выделение памяти из одного из пулов с указанным тегом и устанавливает квоту текущего процесса для распределения.
  • ExFreePool — Оcвобождение пула. Функция знает из какого пула сделано выделение.
Аргумент tag в некоторых функциях позволяет именовать выделение памяти 4-байтовым значением логически идентифицирующих драйвер, или некоторую часть.
Эти распределения пула (с их тегами) можно просмотреть с помощью инструмента Poolmon WDK, или мой собственный инструмент PoolMonX (можно загрузить с zodiacon/AllTools).

Рисунок 3-1 показывает снимок экрана PoolMonX (v2).
3.png


В следующем примере кода показано распределение памяти и копирование строки для сохранения пути к реестру.

Строка передается в DriverEntry и освобождение этой строки происходит в подпрограмме Unload:
C:
// define a tag (because of little endianess, viewed in PoolMon as 'abcd')
#define DRIVER_TAG 'dcba'
UNICODE_STRING g_RegistryPath;
extern "C" NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    DriverObject->DriverUnload = SampleUnload;
    g_RegistryPath.Buffer = (WCHAR*)ExAllocatePoolWithTag(PagedPool,
            RegistryPath->Length, DRIVER_TAG);
    if (g_RegistryPath.Buffer == nullptr) {
        KdPrint(("Failed to allocate memory\n"));
        return STATUS_INSUFFICIENT_RESOURCES;
    }
    g_RegistryPath.MaximumLength = RegistryPath->Length;
    RtlCopyUnicodeString(&g_RegistryPath, (PCUNICODE_STRING)RegistryPath);
    // %wZ is for UNICODE_STRING objects
    KdPrint(("Copied registry path: %wZ\n", &g_RegistryPath));
    //...
    return STATUS_SUCCESS;
}

void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
    ExFreePool(g_RegistryPath.Buffer);
    KdPrint(("Sample driver Unload called\n"));
}

Списки

Ядро использует круговые двусвязные списки во многих своих внутренних структурах данных.
Например, все процессы в системе управляются структурами EPROCESS, связанными в круговой двойной список, где хранится его голова, в переменной PsActiveProcessHead.

Все эти списки построены аналогичным образом, вокруг структуры LIST_ENTRY, определенной следующим образом:
C:
typedef struct _LIST_ENTRY {
    struct _LIST_ENTRY *Flink;
    struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;

4.png


Одна такая структура встроена в основную структуру.

Например, в EPROCESS структуре, член ActiveProcessLinks имеет тип LIST_ENTRY, указывая на следующий и предыдущие объект LIST_ENTRY других структур EPROCESS. Заголовок списка хранится отдельно.

В случае процесса это PsActiveProcessHead.
Получить указатель на фактическую структуру с учетом адреса LIST_ENTRY можно с помощью макроса CONTAINING_RECORD.
Например, предположим, что вы хотите управлять списком структур типа MyDataItem, определенных следующим образом:
C:
struct MyDataItem {
// some data members
LIST_ENTRY Link;
// more data members
};

При работе с этими связанными списками у нас есть заголовок для списка, который хранится в переменной.
Это означает, что обход осуществляется с помощью члена списка Flink для указания следующего LIST_ENTRY в списке.

Учитывая указатель на LIST_ENTRY, мы ищем MyDataItem, который содержит этот элемент списка.
Вот где приходит CONTAINING_RECORD (Поиск списка):
C:
MyDataItem* GetItem(LIST_ENTRY* pEntry) {
    return CONTAINING_RECORD(pEntry, MyDataItem, Link);
}

Макрос выполняет правильный расчет смещения и выполняет приведение к фактическому типу данных (MyDataItem в примере).

Ниже приведены функции работы со списками:
  • InitializeListHead - Инициализирует заголовок списка, чтобы создать пустой список.
  • InsertHeadList — Вставить элемент в начало списка.
  • InsertTailList — Вставить элемент в конец списка.
  • IsListEmptyПроверка списка на пустоту.
  • RemoveHeadList - Удалить элемент в начале списка.
  • RemoveTailList — Удалить элемент в конце списка.
  • RemoveEntryList - Удалить конкретный элемент из списка.
  • ExInterlockedInsertHeadList - Вставить элемент в начало списка атомарно, используя указанный спинлок.
  • ExInterlockedInsertTailList - Вставить элемент в конец списка атомарно, используя указанный спинлок.
  • ExInterlockedRemoveHeadList - Удалить элемент из начала списка атомарно, используя указанный спинлок.
Объекты драйвера
Мы уже видели, что функция DriverEntry принимает два аргумента, первый - это объект драйвера.
Это частично документированная структура с именем DRIVER_OBJECT, определенная в WDK заголовке.

«Полу документированный» означает, что некоторые его члены документированы , а некоторые нет.
Эта структура выделяется ядром и частично инициализируется.

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

Одну из таких «операций» мы видели в главе 2 - процедура выгрузки.
Другой важный набор операций для инициализации называется диспетчерскими процедурами.

Это массив указателей функций, член MajorFunction в DRIVER_OBJECT. Этот набор указывает, какие конкретные операции драйвер поддерживает, например, создание, чтение, запись и т. д. Эти показатели определяются с префиксом IRP_MJ_.

Основные коды основных функций и их значение:
  • IRP_MJ_CREATE (0) - Создать файл. Обычно вызывается для CreateFile или ZwCreateFile.
  • IRP_MJ_CLOSE (2) - Закрыть операцию. Обычно вызывается для CloseHandle или ZwClose.
  • IRP_MJ_READ (3) - Операция чтения. Обычно вызывается для ReadFile(ZwReadFile и подобные API чтения)
  • IRP_MJ_WRITE (4) - Операция записи. Обычно вызывается для WriteFile(ZwWriteFile и подобные API записи)
  • IRP_MJ_DEVICE_CONTROL (14) - Общий вызов драйверу, вызванный вызовами DeviceIoControl или ZwDeviceIoControlFile.
  • IRP_MJ_INTERNAL_DEVICE_CONTROL (15) - Аналогичен предыдущему, но доступен только для ядра.
  • IRP_MJ_PNP (31) - Plug and play обратный вызов, вызванный менеджером Plug and play.
  • IRP_MJ_POWER (22) - Power callback, вызываемый Power Manager.

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

Это означает, что драйвер в своей процедуре DriverEntry должен только инициализировать фактические операции, которые он поддерживает, оставляя все остальные записи в их значениях по умолчанию.

Например, наш драйвер Sample на данный момент не поддерживает какие-либо процедуры отправки, что означает нет возможности общаться с драйвером.

Драйвер должен по крайней мере поддерживать IRP_MJ_CREATE и операции IRP_MJ_CLOSE, чтобы позволить открыть дескриптор для одного объекта устройства драйвера.

Мы воплотим эти идеи в жизнь в следующей главе.

Объекты устройства
Хотя объект драйвера может выглядеть хорошим кандидатом для общения с клиентами, это не так.
Фактические конечные точки связи для клиентов, чтобы общаться с драйверами, являются объектами устройства.

Объекты устройства являются экземплярами полу-документированной структуры DEVICE_OBJECT.
Драйвер должен создать хотя бы один объект устройства и получить имя, чтобы клиенты могли с ним связаться.

Функция CreateFile (и ее варианты) принимает первый аргумент, который называется «имя файла», но на самом деле это должно указывать на имя объекта устройства, где фактический файл является лишь частным случаем.

Название CreateFile несколько вводит в заблуждение - слово «файл» здесь фактически означает объект файла.
Открытие дескриптора файла или устройства создает экземпляр структуры ядра FILE_OBJECT.

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

Все символические ссылки, которые можно использовать из пользовательского режима CreateFile или CreateFile2 находится в каталоге диспетчера объектов с именем ??. Это можно посмотреть с помощью Sysinternals инструмента WinObj. Рисунок 3-3 показывает этот каталог (с именем Global ?? в WinObj).
5.png

Рисунок 3-3: Символические ссылки в WinObj

Некоторые имена кажутся знакомыми, такие как C :, Aux, Con и другие. Другие записи выглядят как длинные загадочные строки, и они на самом деле генерируется системой ввода/вывода для аппаратных драйверов, которые вызывают IoRegisterDeviceInterface.

Эти типы символических ссылок бесполезны для целей этой книги.
Большинство символических ссылок в каталог указывает на внутреннее имя устройства.

Имена в этом каталоге не доступны напрямую из пользовательского режима. Но они могут быть доступным вызывающим ядрам с помощью API IoGetDeviceObjectPointer.
Каноническим примером является драйвер для Process Explorer.

Когда Process Explorer запускается с правами администратора, он устанавливает драйвер. Этот драйвер дает Process Explorer полномочия помимо тех, которые могут быть получены с помощью API пользовательского режима, даже если они работают с повышенными правами.

Например, Process Explorer в своем диалоге потока для процесса может показать полный стек вызовов потока, включая функции в режим ядра. Этот тип информации невозможно получить из пользовательского режима.

Драйвер, установленный Process Explorer, создает один объект устройства, чтобы Process Explorer мог открыть дескриптор этого устройства и сделать запросы.

Это означает, что объект устройства должен быть назван и должен иметь символическую ссылку в каталог, с именем PROCEXP152, вероятно, с указанием версии драйвера 15.2 (на момент написания этой статьи).

Рисунок 3-4 показывает эту символическую ссылку Process Explorer в WinObj.
6.png


Обратите внимание, что символическая ссылка для устройства Process Explorer указывает на \Device\PROCEXP152, который является внутренним именем доступным только для ядра.

Фактический вызов CreateFile, выполненный Process Explorer (или любым другим клиентом), основанный на символической ссылке, должен начинаться с \\. \.

Вот как Process Explorer может открыть дескриптор своего устройства (обратите внимание на двойную обратную косую черту):

HANDLE hDevice = CreateFile(L"\\\\.\\PROCEXP152",

GENERIC_WRITE | GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr);


Драйвер создает объект устройства с помощью функции IoCreateDevice. Эта функция выделяет и инициализирует структуру объекта устройства и возвращает его указатель вызывающей стороне.

Экземпляр объекта устройства хранится в элементе DeviceObject структуры DRIVER_OBJECT.

Если более одного устройства, они образуют односвязный список, в котором член NextDevice из DEVICE_-OBJECT указывает на следующий объект устройства.

Обратите внимание, что объекты устройства вставляются в начало списка, поэтому первый созданный объект устройства сохраняется последним; его NextDevice указывает на NULL.
7.png


Итоги главы

Мы рассмотрели некоторые фундаментальные структуры данных ядра и API. В следующей главе мы напишем полный драйвер и его клиент.

От ТС
Напоминаю что перевод делал не я, автор - Virt
Сам перевод взят вот отсюда - Ru-sfera
 
Ого, это мои пререводы что-ли ?
А-то я начал читать, смотрю что-то знакомое...???

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

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

Вообще не думал что это кому-то нужно, переводы эти, но раз перепостили, значит кому-то помогает чем-то.

Я на сфере и в гите периодически обновляю, поэтому кому интересно можете подписаться на раздел, как будут создаваться темы, будете получать письмо, либо в гите можно подписаться на ветку...)

Если-что Virt, это тоже я на сфере, просто не всегда захожу из акка админа.;)
 
Последнее редактирование:


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх