The Kernel-Bridge Framework: мостик в Ring0

Хотели ли Вы когда-нибудь заглянуть под капот операционной системы, посмотреть на внутреннее устройство её механизмов, покрутить винтики и посмотреть на открывшиеся возможности? Возможно, даже хотели поработать напрямую с железом, но считали, что драйвера — rocketscience?

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

Итак, представляю драйвер-фреймворк для kernel-хакинга, написанный на C++17, и призванный, по возможности, снять барьеры между ядром и юзермодом или максимально сгладить их присутствие. А также, набор юзермодных и ядерных API и обёрток для быстрой и удобной разработки в Ring0 как для новичков, так и для продвинутых программистов.

Основные возможности:

  • Доступ к портам ввода-вывода, а также проброс инструкций in, out, cli и sti в юзермод через IOPL
  • Обёртки над системной пищалкой
  • Доступ к MSR (Model-Specific Registers)
  • Набор функций для доступа к юзермодной памяти других процессов и к памяти ядра
  • Работа с физической памятью, DMI/SMBIOS
  • Создание юзермодных и ядерных потоков, доставка APC
  • Юзермодные Ob*** и Ps***-каллбэки и фильтры файловой системы
  • Загрузка неподписанных драйверов и ядерных библиотек

… и многое другое.

А начнём мы с загрузки и подключения фреймворка в наш проект на C++.

GitHub

Для сборки очень желательно пользоваться новейшей версией Visual Studio и последним доступным пакетом WDK (Windows Driver Kit), скачать который можно с официального сайта Microsoft.

Для тестирования прекрасно подойдёт бесплатный VMware Player с установленной Windows, не ниже Windows 7, любой разрядности.

Сборка тривиальная и вопросов не вызовет:

  1. Открываем Kernel-Bridge.sln
  2. Выбираем нужную разрядность
  3. Ctrl+Shift+B

В результате мы получим драйвер, юзермодную библиотеку, а также сопутствующие служебные файлы (*.inf для ручной установки, *.cab для подписи драйвера на Microsoft Hardware Certification Publisher и т.д.).

Для установки драйвера (если нет необходимой для х64 цифровой подписи кода — соответствующего EV-сертификата) нужно перевести систему в тестовый режим, игнорирующий наличие цифровой подписи у драйверов. Для этого выполним в командной строке от имени администратора:

bcdedit.exe /set loadoptions DISABLE_INTEGRITY_CHECKS
bcdedit.exe /set TESTSIGNING ON

… и перезагрузим машину. Если всё сделано правильно, в нижнем правом углу появится надпись, что Windows находится в тестовом режиме.

Настройка тестовой среды завершена, приступим к использованию API в нашем проекте.

Фреймворк имеет следующую иерархию:

/Kernel-Bridge/API — набор функций для использования в драйверах и ядерных модулях, не имеют внешних зависимостей и могут быть свободно использованы в сторонних проектах
/User-Bridge/API — набор юзермодных обёрток над драйвером и служебные функции для работы с PE-файлами, PDB-символами и т.д.
/SharedTypes/ — одновременно и юзермодные, и ядерные хедеры, содержащие необходимые общие типы

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

Итак, создадим консольный проект на C++, подключим необходимые заголовочные файлы и загрузим драйвер:

#include <Windows.h>

#include "WdkTypes.h" // Универсальные типы для x32/x64 и определения из WDK
#include "CtlTypes.h" // Определения структур IOCTL-запросов для драйвера
#include "User-Bridge.h" // API, предоставляемый драйвером

int main()
{
    using namespace KbLoader;

    BOOL Status = KbLoadAsFilter(
        L"X:\\Folder\\Path\\To\\Kernel-Bridge.sys",
        L"260000" // Высота нашего драйвера в стеке фильтров
    );

    if (!Status) 
        return 0;

    // Драйвер успешно загружен!
    // Теперь мы можем использовать API
    
    ...

    // Выгружаемся:
    KbUnload();
    return 0;
}

Отлично! Теперь мы можем использовать API и взаимодействовать с ядром.
Начнём с наиболее востребованного функционала в среде разработчиков читов — чтение и запись памяти чужого процесса:

using namespace Processes::MemoryManagement;

constexpr int Size = 64;
BYTE Buffer[Size] = {};

BOOL Status = KbReadProcessMemory( // Или KbWriteProcessMemory, чтобы записать
    ProcessId,
    0x7FFF0000, // Желаемый адрес в контексте ProcessId
    &Buffer,
    Size
);

Ничего сложного! Опустимся на уровень ниже — чтение и запись ядерной памяти:

using namespace VirtualMemory;

constexpr int Size = 64;
BYTE Buffer[Size];

// И "куда", и "откуда" могут быть и ядерными, 
// и юзермодными адресами в контексте ТЕКУЩЕГО процесса:
BOOL Status = KbCopyMoveMemory(
    reinterpret_cast<WdkTypes::PVOID>(Buffer), // Куда
    0xFFFFF80000C00000, // Откуда
    Size,
    FALSE // Говорим драйверу, что буферы не пересекаются
);

А что насчёт функций для взаимодействия с железом? Например, I/O-порты.

Пробросим их в юзермод, взведя 2 бита IOPL в регистре EFlags, отвечающие за уровень привилегий, на котором доступны инструкции in/out/cli/sti.

Таким образом, мы сможем выполнять их в юзермоде без ошибки Privileged Instruction:

#include <intrin.h>

using namespace IO::Iopl;

// Для примера, включим системную пищалку!

KbRaiseIopl(); // Теперь in/out/cli/sti доступны в юзермоде!

ULONG Frequency = 1000; // 1 kHz
ULONG Divider = 1193182 / Frequency;

__outbyte(0x43, 0xB6); // Установим режим прямоугольных импульсов

// Устанавливаем делитель для частоты тактового генератора:
__outbyte(0x42, static_cast<unsigned char>(Divider));
__outbyte(0x42, static_cast<unsigned char>(Divider >> 8));

__outbyte(0x61, __inbyte(0x61) | 3); // Включаем пищалку (подачу импульсов на мембрану)

for (int i = 0; i < 5000; i++); // Можно попробовать Sleep(), но IOPL может сброситься при возврате в юзермод!

__outbyte(0x61, __inbyte(0x61) & 252); // Останавливаем пищалку

KbResetIopl();

Но что насчёт настоящей свободы? Ведь зачастую хочется выполнить произвольный код с привилегиями ядра. Напишем весь ядерный код в юзермоде и передадим на него управление из ядра (SMEP отключается автоматически, перед вызовом драйвер сохраняет FPU-контекст и сам вызов происходит внутри try..except-блока):

using namespace KernelShells;

// Для примера вызовем KeStallExecutionProcessor:
ULONG Result = 1337;
KbExecuteShellCode(
    [](
        _GetKernelProcAddress GetKernelProcAddress,
        PVOID Argument
    ) -> ULONG { // Этот код будет выполнен в Ring0
        // Динамически импортируем нужные ядерные функции:
        using _KeStallExecutionProcessor = VOID(WINAPI*)(ULONG Microseconds);
        auto Stall = reinterpret_cast<_KeStallExecutionProcessor>(
            GetKernelProcAddress(L"KeStallExecutionProcessor")
        );
        Stall(1000 * 1000); // Останавливаем процессор на одну секунду

        ULONG Value = *static_cast<PULONG>(Argument);
        return Value == 1337 ? 0x1EE7C0DE : 0;
    },
    &Result, // Argument
    &Result  // Result
);

// Функция вернёт Result = 0x1EE7C0DE

Но кроме баловства с шеллами, есть и серьёзный функционал, позволяющий создавать простейшие DLP на основе подсистемы файловых, объектных и процессных фильтров.

Фреймворк позволяет фильтровать CreateFile/ReadFile/WriteFile/DeviceIoControl, а также события открытия\дуплицирования хэндлов (ObRegisterCallbacks) и события запуска процессов\потоков и подгрузки модулей (PsSet***NotifyRoutine). Это позволит, к примеру, блокировать доступ к произвольным файлам или подменять информацию о серийных номерах жёсткого диска.

Принцип работы:

  1. Драйвер регистрирует файловые фильтры и устанавливает Ob***/Ps***-каллбэки
  2. Драйвер открывает Communication-порт, к которому подключаются клиенты, желающие подписаться на то или иное событие
  3. Юзермодные приложения присоединяются к порту и получают от драйвера данные о произошедшем событии, выполняют фильтрацию (урезают хэндлы в правах, блокируют доступ к файлу и т.д.) и возвращают событие в ядро
  4. Драйвер применяет полученные изменения

Пример подписки на ObRegisterCallbacks и урезание доступа к текущему процессу:

#include <Windows.h>
#include <fltUser.h>
 
#include "CommPort.h"
#include "WdkTypes.h"
#include "FltTypes.h"
#include "Flt-Bridge.h"
 
...
 
// Слушатель событий ObRegisterCallbacks:
CommPortListener<KB_FLT_OB_CALLBACK_INFO, KbObCallbacks> ObCallbacks;
 
// Предотвратим открытие хэндла нашего процесса с правами PROCESS_VM_READ:
Status = ObCallbacks.Subscribe([](
    CommPort& Port,
    MessagePacket<KB_FLT_OB_CALLBACK_INFO>& Message
) -> VOID {
    auto Data = static_cast<PKB_FLT_OB_CALLBACK_INFO>(Message.GetData());
    if (Data->Target.ProcessId == GetCurrentProcessId()) {
        Data->CreateResultAccess &= ~PROCESS_VM_READ;
        Data->DuplicateResultAccess &= ~PROCESS_VM_READ;
    }
    ReplyPacket<KB_FLT_OB_CALLBACK_INFO> Reply(Message, ERROR_SUCCESS, *Data);
    Port.Reply(Reply); // Отвечаем драйверу
});

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

Весь API и обёртки расположены в соответствующей папке: /Kernel-Bridge/API/
Они включают работу с памятью, с процессами, со строками и блокировками, и много с чем ещё, существенно упрощая разработку своих собственных драйверов. API и обёртки зависят только от самих себя и не зависят от внешнего окружения: Вы можете свободно использовать их в своём собственном драйвере.

Пример работы со строками в ядре — камень преткновения всех новичков:

#include <wdm.h>
#include <ntstrsafe.h>
#include <stdarg.h>

#include "StringsAPI.h"

WideString wString = L"Some string";
AnsiString aString = wString.GetAnsi().GetLowerCase() + " and another string!";

if (aString.Matches("*another*"))
    DbgPrint("%s\r\n", aString.GetData());

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

  1. Пишете обработчик в /Kernel-Bridge/Kernel-Bridge/IOCTLHandlers.cpp
  2. В этом же файле добавляете обработчик в конец массива Handlers в функции DispatchIOCTL
  3. Добавляете индекс запроса в перечисление Ctls::KbCtlIndices в CtlTypes.h в ТУ ЖЕ ПОЗИЦИЮ, что и в массиве Handlers в п.2
  4. Вызываете свой обработчик из юзермода, написав обёртку в User-Bridge.cpp, произведя вызов с помощью функции KbSendRequest

Поддерживаются все три вида ввода-вывода (METHOD_BUFFERED, METHOD_NEITHER и METHOD_IN_DIRECT/METHOD_OUT_DIRECT), по-умолчанию используется METHOD_NEITHER.

Вот и всё! В статье охвачена лишь малая толика всех возможностей. Надеюсь, фреймворк будет полезен начинающим разработчикам компонентов ядра, реверс-инженерам, разработчикам читов, античитов и защит.

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

  • Обёртки для прямых манипуляций с PTE-записями и проброс ядерной памяти в юзермод
  • Инжекторы на основе уже существующих функций создания потоков и доставки APC
  • GUI-платформа для живого реверс-инжиниринга и исследования ядра Windows
  • Скриптовый движок для выполнения кусочков ядерного кода
  • Поддержка SEH в динамически загружаемых модулях
  • Прохождение HLK-тестов

Благодарю за внимание!
Поделиться публикацией
Комментарии 15
    0
    А что из С++17 реально используется?
    С первого взгляда, там и С++11 могло бы хватить, но я пару файлов всего глянул.
      +1
      Мелочи, типа [[fallthrough]]. В ядерном коде с нововведениями не разойтись, но выставил сразу поддержку последнего черновика C++ (формально, уже C++20), чтобы при попытке обновить стандарт не возникло неприятных неожиданностей и варнингов. 99%, конечно же, C++11.
        0
        Так оно того стоило? Далеко не все горят желанием и возможностью обновлять тулчейн по желанию. Может, стоило макросом закрыть для «бедных»?
        Что кстати с клангом для сборки драйверов, не знаете? Там по моему с SEH у них нестыковки по прежнему.
          +1
          Я сторонник использования новейшего стека технологий, и использование устаревшего стандарта было бы ударом по моим перфекционистическим чувствам) А вообще, коль скоро драйвера в подавляющем количестве пишут на C без плюсов, уже сам факт использования C++ в API может добавить неудобств тем, кто захочет перенести часть функций в уже существующий продукт. А если писать с нуля — тогда и смысла нет привязываться к старым стандартам, когда ничто не мешает сразу использовать всё самое новое.

          Про клэнг ничего не могу сказать — не пробовал. Но, судя по постам на unknowncheats, им успешно собирают драйвера с поддержкой STL из коробки. А как разруливают исключения — не знаю. Но, раз клэнг используют в ядре, с сехами проблем быть не должно. С другой стороны, какие есть причины собирать им ядерные модули и не лучше ли использовать официальный тулчейн?
      0

      Я правильно понимаю, что это предназначено исключительно для прототипирования и ни в коем случае нельзя оставлять на production?

        0
        Зависит от используемого функционала. Например, функции для работы с IO, MSR, чтение\запись памяти (в т.ч. ядерной) совершенно безопасны для продакшна и сделаны со всеми необходимыми проверками и ловушками исключительных ситуаций (например, с учётом, что приложение может попытаться освободить память или поменять её права доступа, пока мы с ней работаем). Но функции для, скажем, прямых манипуляций с Mdl небезопасны по самой своей сути: если приложение создало отображение и по каким-то причинам закрылось, не сделав анмаппинг — гарантированно получим BSOD при попытке отдать системе смапленные адреса. Такие функции я бы не рекомендовал использовать в юзермодном коде в реальных продуктах. Нужно смотреть, что именно требуется сделать и какие вообще пути для решения есть.

        Что касается ядерной части, вполне можно использовать драйвер или API как готовый шаблон для написания своих ядерных компонентов в продакшн.

        Если интересует надёжность каких-то конкретных частей драйвера — спрашивайте, обсудим.
          0

          Честно говоря, вопрос был, скорее, риторический. :) Просто при первом взгляде напомнило описываемое в статье Драйвер компьютерной игры Street Fighter V отключает встроенный механизм защиты Windows, поэтому мне показалось, что было бы полезно описать некие меры предосторожности, которые, вероятно, есть. То есть речь не только о safety, но в большей мере о security.

            0
            К слову, такой же способ исполнения пользовательского кода в Ring0 используется и у меня: отключаем SMEP, сохраняем FPU-контекст и прыгаем на юзермодный код внутри __try..__except-блока (см. KbExecuteShellCode). А сейчас пишу обновление, чтобы напрямую работать с PTE-записями из юзермода (т.е., сможем открыть юзермоду всё ядерное пространство). С точки зрения секьюрности — полное ай-ай-ай! Но основная цель драйвера и заключается в том, чтобы максимально упростить выполнение необходимого кода в ядре и делать то, что нельзя.
        0
        ХР64 поддерживается? Собирать можно Mingw64? Есть желание скрестить с открытыми дровами на радеон с полярисом.
          0
          ХР64 не поддерживается (не проверял, но, скорей всего, в ядре не хватит нужных экспортов, попробуйте), а про MinGW не подскажу, использую только MSVC. Если не секрет, что именно хотите реализовать и почему столь странный тулчейн и требование к 10 лет, как устаревшей, ОС?
            0
            Извините, ответил ниже.
          0
          Mingw работает на ХР, есть пакеты и для линукса, т.е. разработку можно вести где угодно. Я пропустил момент, когда можно было купить новые gtx 960/970 за небольшие деньги, сижу пока с радеоном 6850.
          Проект — перенос открытых драйверов для радеон полярис.
          Этот проект актуален и для владельцев 7ки — через год и она устареет.
            0
            Тоже случайно ответил ниже… бывает!
            0
            А почему именно ХР? Судя по карте, железо позволяет обновиться до Win10 и использовать официальный тулчейн без проблем, не заморачиваясь с поддержкой легаси. Или проблема именно в специфике открытых драйверов?
              0
              Потому, что ХР — лучшая ОС от микрософт, семерка чуть хуже. Десятка, гммм. Даром не надо. Потыкал в виртуалке и снес. Выбор gcc обусловлен тем, что драйвера под Линукс им собираются и решать косяки не поддерживаемого компилятора не собираюсь.
              И некоторые игры на более новых ос работают некорректно.

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

            Самое читаемое