Информация Гайд [UEFI/SMM] Немного о важном. Новая эра читов/античитов и архитектура UEFI-совместимых прошивок.

sashka123555

Участник
Автор темы
38
25
И вновь всем привет. Приветствую вас на своей новой статье в которой мы обсудим и поговорим насчет архитектуры современных читов и античитов. Если честно, написать данную статью меня сподвигнуло то, что в комьюнити геймхакеров начали проявляться некие треды, где люди спрашивают, как им правильно реализовывать собственные SMM-драйверы для чтения и записи в физическую память и то, что я сейчас болею и делать нечего, а обучаться просто сил нет, единственный источник сил - это качественная статья написанная своими руками :).

Так, но, если посмотреть на эти обсуждения, то в частности все это обсуждалось лишь теоретически. Люди строят догадки, как это работает на практике, хотя реальных примеров или разборов почти нет. Собственно несколько месяцев я занимался активным изучением архитектурой UEFI-совместимых прошивок со своими менторами, параллельно с этим разбирал SMM. И сегодня, в данной статье я изложу все свои знания, но уже в контексте геймхакинга.

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

Источники:
В частности 80-85 процентов статьи писал сам. но и брал информацию из некоторых источников:
1. Книга Алексея Матросова про руткиты и буткиты;
2. Статья на Хабре от Николая - https://habr.com/ru/articles/185764/

На этом все, начнем.

Также, статья для удобного чтения в Telegram - https://teletype.in/@mosohism_protocol/UEFItypes

Поверхностная архитектура современных античитов.​

Что-что, а вот писать статьи на тему архитектуры защитных компонентов мне уже наскучало, углубленно рассказывать каждый чих я не буду, а скорее лишь поверхностно, для новичков. Так что, друзья мои, немного информации я перезалил со своей статьи с Хабра лишь подправив ее под архитектуру античитов. https://habr.com/ru/articles/1072252/comments/

Если мы обсуждаем архитектуру античитов, то здесь источником телеметрии может выступать несколько компонентов, которые работают на разных уровнях привилегий. В частности сильные современные античиты имеют один драйвер работающий в пространстве ядра, имея более высокий уровень привилегий относительно компонента, работающего в пользовательском пространстве, а в пользовательском пространстве существует не только один компонент, их может быть множество. Перечисление:
  1. Привилегированным главным компонентом, который, по моему анализу внутри себя хранит 70-80 процентов защиты выступает драйвер. Задача драйвера это защита процесса игры, самозащита и защита процесса античита, также в его задачу входит обработку данных и сигналов из пользовательского режима, для коммуникации между основным пользовательским приложением(например, mrac.exe) и драйвером используются в частности IOCTL-запросы;
  2. Те компоненты, работающие в пользовательском режиме являются менее привилегированными. В этот ряд относятся DLL-модули, которые работают в пространстве игры и не только, задача которых это сканирование виртуальной памяти на поиск определенных сигнатур и прочее, также в этот ряд относится основной модуль античита, например. опять же - mrac.exe. Коммуникация же между компонентами юзермода - например, модулем DLL и .exe осуществляется через разные способы. В частности самый безопасный способ это создание локального RPC-сервера, что и собственно говоря делают многие.
1789853812739.png

Диаграмма демонстрирующая архитектуру.
Драйвер. может собирать телеметрию из достаточно большого количества источников непосредственно через функции ядра. Один из основных способов для этого - так называемые функции обратного вызова. Смысл заключается в том, что драйвер регистрирует свою функцию, а ядро самостоятельно вызывает её при наступлении определенного события. Функции обратного вызова существуют разного рода и предназначены для совершенно разных событий. Например, через ObRegisterCallbacks драйвер может получать уведомления об операциях с дескрипторами процессов и потоков, например, можно зафиксировать попытку открытия дескриптора через стандартный OpenProcess. А вот через CmRegisterCallbackEx можно наблюдать за операциями с реестром. PsSetCreateProcessNotifyRoutine позволяет получать уведомления о создании и завершении процессов, а PsSetCreateThreadNotifyRoutine о создании потоков. Отдельно существует PsSetLoadImageNotifyRoutine, через который драйвер может получать уведомления о загрузке исполняемых образов, в том числе DLL, в адресное пространство процесса.

Как демонстрационный пример напишем простой код, который выполняет некую логику. Его задача - сохранить текущий токен процесса, а затем периодически сравнивать его с текущим токеном процесса. Если обнаруживается, что токен отличается от сохранённого, мы можем зафиксировать это как подозрительное изменение и передать событие в систему детектирования. Данная проверка может использоваться для защиты от EoP-эксплойтов, связанных с кражей токена процесса, техника работает так - процесс через BYOVD драйвер получает доступ к структуре EPROCESS системного процесса, извлекает из неё указатель на первичный токен и копирует этот указатель в структуру своего EPROCESS, мгновенно присваивая себе привилегии SYSTEM
C++:
namespace shield::sploit
{
    static VOID check_thread_token(HANDLE process_id) {
        PEPROCESS eprocess = nullptr;
        if (NT_SUCCESS(PsLookupProcessByProcessId(process_id, &eprocess))) { // получаем eprocess
            PACCESS_TOKEN current_token = PsReferencePrimaryToken(eprocess);

            if (current_token != nullptr) {
                ExAcquireFastMutex(&core::ProcessListMutex);

                PLIST_ENTRY link = core::TargetProcessListHead.Flink;
                while (link != &core::TargetProcessListHead) {
                    auto* entry = CONTAINING_RECORD(link, core::ProcessTokenEntry, ListEntry);
                    if (entry->ProcessId == process_id) {
                        if (entry->OrigToken != current_token) { // сверяем токен
                            KdPrint(("steal detected pid: %p\n", process_id));
                        }
                        break;
                    }
                    link = link->Flink;
                }
                ExReleaseFastMutex(&core::ProcessListMutex);
                PsDereferencePrimaryToken(current_token);
            }
            ObDereferenceObject(eprocess);
        }
    }

    static VOID save_original_token(HANDLE process_id) {
        PEPROCESS eprocess = nullptr;
        if (NT_SUCCESS(PsLookupProcessByProcessId(process_id, &eprocess))) {
            PACCESS_TOKEN token = PsReferencePrimaryToken(eprocess);
            if (token != nullptr) {
                auto* entry = static_cast<core::ProcessTokenEntry*>(ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(core::ProcessTokenEntry), 'shld'));

                if (entry != nullptr) {
                    entry->ProcessId = process_id;
                    entry->OrigToken = token;

                    ExAcquireFastMutex(&core::ProcessListMutex);
                    InsertTailList(&core::TargetProcessListHead, &entry->ListEntry);
                    ExReleaseFastMutex(&core::ProcessListMutex);
                }
                else {
                    PsDereferencePrimaryToken(token);
                }
            }
            ObDereferenceObject(eprocess);
        }
    }
}

1789860159398.png

Демонстрация токена в EPROCESS.
EPROCESS - это системная структура ядра. Внутри EPROCESS находятся ссылки на ключевые подсистемы - таблицу дескрипторов, структуру токена доступа (TOKEN), список потоков (ETHREAD), информацию о виртуальной памяти процесса (VAD-дерево), а также данные, связанные с идентификацией процесса (PID, PPID) и его привилегиями.

Inverted Call.​

Как вы уже поняли, большая часть логики защиты заложена непосредственно в драйверы. Но у античита всё так же имеется пользовательский процесс, который должен взаимодействовать с основным драйвером. Их взаимодействие происходит путем отправки из пользовательского режима IOCTL-кодов до основного драйвера для выполнения определенного действия со стороны драйвера. Пользовательский процесс открывает дескриптор устройства, и при необходимости отправляет IOCTL-запросы через функцию DeviceIoControl.
Суть техники Inverted Call заключается в том, что пользовательский процесс античита заранее отправляет драйверу ящик пустых IOCTL-запросов (обычно через пул потоков и асинхронный ввод-вывод с использованием OVERLAPPED структур или портов завершения ввода-вывода - IOCP). Драйвер не выполняет эти запросы сразу, а ставит их в очередь и оставляет висеть в состоянии ожидания, возвращая статус STATUS_PENDING. Как только в операционной системе происходит критическое событие, например, срабатывает функция обратного вызова, создания процесса PsSetCreateProcessNotifyRoutineEx - драйвер ядра извлекает один висящий в очереди IRP-пакет, заполняет его буфер телеметрией (PID, путь к файлу и т.д) и завершает операцию вызовом IoCompleteRequest.

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

1789854115637.png

Диаграмма демонстрирующая коммуникацию.

Инициатором передачи данных формально остаётся пользовательский процесс, но саму передачу фактически инициирует ядро, когда появляется новое событие. Именно поэтому механизм и получил название Inverted Call.

Привет UEFI! Архитектура UEFI.​

SPI-flash память.​

SPI-flash память - это энергонезависимая микросхема памяти на материнской плате, в которой физически хранится всё программное обеспечение прошивки. Она подключается к южному мосту по последовательному интерфейсу SPI. SPI-flash память разделяется на несколько регионов, в одном из которых лежит код UEFI-прошивки. Основные регионы:
  1. Flash Descriptor(0) - регион хранит информацию о размерах и смещениях всех остальных регионов, а также права доступа для различных компонентов системы - например, процессора или сетевой карты. Располагается в самом начале флеш-памяти с адреса 0x00000000;
  2. Intel ME / AMD PSP - регион, который хранит код отдельных микроконтроллеров. У ME и PSP свой процессор, своя прошивка, своя память;
  3. BIOS / UEFI - регион, который хранит основной код прошивки материнской платы. В частности содержит код инициализации железа(UEFI PI), драйверы разных фаз;
  4. GbE - регион, который содержит конфигурацию встроенного сетевого адаптера Intel/AMD;
  5. PDR - регион, который может хранить уникальные ключи шифрования, OEM-сертификаты и так далее.

1789861973076.png

SPI-flash.
Также стоит учесть про чипсет PCH - это современный чипсет от Intel, который управляет перефирийными устройствами и связью между компонентамина материнской плате. При включении ПК южный мост первым делом обращается к самому первому региону в SPI-flash. Чипсет(PCH) считывает карту смещений и права доступа, чтобы понять, где физически на флешке находятся прошивки для сервисных процессоров и где искать точку старта основного процессора для передачи управления в регион BIOS / UEFI.

1789854850972.png

Диаграмма демонстрирующая регионы.

В целом, единственный регион который нас интересует в рамках этой статьи - это конечно же регион с UEFI. Остальные регионы(например, Intel ME/PSP), это уже отдельная тема для разговоров, данные микроконтроллеры тоже интересны с точки зрения разработки читов, но в рамках данной статьи они разбираться не будут.

UEFI​

Кстати, сразу скажу, что я упустил фазы BDS и прочие, которые выполняются уже после инициализации основных фаз UEFI, потому-что это не входит в рамки сути статьи. Наконец-то мы подошли к основному содержимому статьи, начнем. Фаза загрузки UEFI-прошивки разделяется на несколько уровней, а описывает эту фазу PI-спецификация - она описывает внутренние интерфейсы между различными частями прошивки UEFI. Полный цикл инициализации платформы - от снятия сигнала RESET# до передачи управления ОС - проходит через семь последовательных фаз. Каждая фаза работает в своём окружении и со своим набором доступных сервисов, своим режимом адресации и своими ограничениями.

1789856673402.png

Диаграмма демонстрирующая фазы инициализацию UEFI.
RESET# - это физический контакт на процессоре, через который материнская плата сообщает процессору про инициализацию с чистого листа. После того, как процессор снимает напряжение с контакта RESET#, он просыпается в реальном режиме с жестко заданным начальным состоянием регистров. Указатель команд автоматически выставляется на адрес вектора сброса - 0xFFFFFFF0.

Данный адрес является верхним краем 32-битного адресного пространства, ровно 16 байт до конца(от 0xFFFFFFF0 до 0xFFFFFFFF). В этот момент ОЗУ в этот момент еще полностью отключена(она инициализируется на одной из фаз загрузки UEFI), поэтому процессор физически не может читать из нее данные. Чтобы обойти эту проблему, чипсет аппаратно перенаправляет все запросы к этому верхнему диапазону адресов прямиком на SPI-flash микросхему через специальный контроллер. Процессор думает, что читает обычную память, но на самом деле он забирает байты напрямую из флешки.

По адресу физически невозможно уместить какую-то сложную логику, там всего-то 16 байт, что невероятно мало. Архитектурно так вышло, что в результате этого ограничения там зашита всего одно короткая инструкция безусловного перехода FAR JMP, которая указывает процессору прыгнуть на местоположение кода прошивки. Этот прыжок ведет на функцию SecEntryPoint, которая находится чуть ниже в адресном пространстве. Непосредственно с нее начинается выполнение первой фазы UEFI, которая называется SEC. Основная задача процессора на этом этапе , это проверить целостность прошивки и подготовить временную память в своем кэше, чтобы можно было начать выполнять более сложный код на языке С. Разберем более подробную каждую фазу загрузки.

SEC.​

Главная задача этой фазы выполняется в контексте так называемого базового кода, когда полноценная ОЗУ еще полностью недоступна, в результате чего процессор не может использовать привычный стек для вызова функций на языке С. Чтобы решить эту проблему, на этапе SEC настраивается технология Cache-as-RAM (CAR). Суть метода заключается в том, что процессор конфигурирует свой собственный кэш данных второго или третьего уровня (L2/L3) в качестве временного оперативного запоминающего устройства. В этот кэш прописывается стек. Непосредственно полноценная инициализация ОЗУ выполняется на этапе PEI.
C++:
// код взят с tiancore/edk2 SecEntry.nasm
DEFAULT REL
SECTION .text

extern ASM_PFX(SecCoreStartupWithStack)

global ASM_PFX(_ModuleEntryPoint)
ASM_PFX(_ModuleEntryPoint):

    cli

    mov     eax, 0
    mov     cr3, eax

    mov     eax, cr4
    or      eax, 0x00000020
    mov     cr4, eax

    mov     ecx, 0xC0000080 // выбор msr-регистра
    rdmsr // чтение его текущего состояния
    or      eax, 0x00000100 // выставление бита lme для активации long-мода
    wrmsr // запись обновленноого значения обратно

    mov     eax, cr0
    or      eax, 0x80000001 // включение защищенного режима
    mov     cr0, eax

    jmp     0x38:long_mode_entry

BITS 64
long_mode_entry:
    mov     ax, 0x30
    mov     ds, ax
    mov     es, ax
    mov     ss, ax

    mov     rsp, 0x80000

    nop

    mov     rcx, rdi
    mov     rdx, rsi

    sub     rsp, 0x20
    call    ASM_PFX(SecCoreStartupWithStack)
Как только временная память настроена, а базовые структуры данных заполнены, фаза SEC формирует специальную структуру данных - PPI (Peer-to-Peer Interface) и передает управление следующему этапу. Процессор переходит к фазе PEI, передавая ей указатель на созданный в кэше стек.

PEI.​

После того как фаза SEC подготовила базовое окружение и временную память, управление переходит к фазе PEI. Главная задача этого этапа - это инициализация полноценной ОЗУ и загрузки туда DXE-драйверов, которые мы разбирем чуть позже. Проблема в том, что современная память DDR4/DDR5 требует сложнейшей процедуры калибровки контроллера памяти , нужнонастроить тайминги, напряжения и частоты, чтобы сигналы шли без искажений. Всем этим занимается специальный бинарный модуль, который часто называют MRC, интегрируемый производителем чипсета.

Пока ОЗУ не инициализирована, модули фазы PEI (они называются PEIM / PEI Modules) выполняются по технологии XIP (Execute-in-Place), это значит, что код читается и исполняется процессором напрямую из SPI-flash памяти, а все переменные складываются в созданный ранее временный стек в кэше.
После того, как MRC настраивает контроллер и планки памяти начинают функционировать, происходит одно важное событие , а именно миграция стека. Код UEFI копирует все данные из кэша процессора в начало полноценной ОЗУ и отключает режим CAS.
C++:
// код взят с tianocore/edk2 PeiMain.c
VOID
EFIAPI
PeiCore (
  IN CONST EFI_SEC_PEI_HAND_OFF    *SecCoreData,
  IN CONST EFI_PEI_PPI_DESCRIPTOR  *PpiList,
  IN VOID                          *Data
  )
{
  EFI_STATUS            Status;
  PEI_CORE_INSTANCE     PrivateData;
  EFI_PEI_SERVICES      *PeiServices;

  ProcessModuleInMemoryList (SecCoreData, Data);

  PeiServices = &PrivateData.ServiceTable;
  InitializePeiServices (&PrivateData, PeiServices, SecCoreData);

  if (PpiList != NULL) {
    Status = PeiInitializePpiServices (&PrivateData, PeiServices, PpiList);
    ASSERT_EFI_ERROR (Status);
  }

  PeiDispatcher (SecCoreData, &PrivateData);

  Status = PeiLoadFixupTable (&PrivateData);
  ASSERT_EFI_ERROR (Status);

  PeiCoreHobManagement (SecCoreData, &PrivateData);
}
Также стоит рассказать о том, что формат модулей PEI может как совпадать с форматом драйверов DXE (PE32+), так и отличаться от него заголовком, т.к. заголовок PE32+ содержит множество неиспользуемых в фазе PEI полей, а место в кэше процессора не резиновое. Поэтому для PEIM был разработан специальный формат TE, заголовок которого содержит только необходимые поля. TE бывают исполняемые-на-месте (XIP), перемещаемые (relocatable) и независимые от позиции (PIC). Также встречаются гибридные DXE/PEI-модули с двумя точками входа, но они обязаны быть в формате PE32+, поскольку иначе как драйвер DXE такой модуль не запустится. Переходим к фазе DXE.

DXE.​

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

В данной фазе выполняется основная окончательная инициализация всего на основе от PEIНОВ'ов. Собственно на данном этапе прошивка получает доступ к полноценной ОЗУ, что развязывает руки разработчикам. Диспетчер DXE (DxeCore) считывает переданные из предыдущей фазы структуры HOB и начинает разворачивать полноценную операционную среду.

Драйверы в данной фазе представляют собой стандартные исполняемые файлы формата PE32+ (модифицированный Portable Executable, как в Windows). Они могут свободно выделять память, создавать сложные интерфейсы (протоколы) и взаимодействовать друг с другом. В данной фазе регистрируются важные сервисы UEFI - Boot Services / Runtime Services. Архитектурно DXE-драйверы могут зависеть друг от друга, поэтому диспетчер прошивки вычисляет их зависимости по специальным секциям и запускает строго поочереди.
C++:
// код взят с tianocore/edk2 DxeMain.c
VOID
EFIAPI
DxeMain (
  IN VOID  *HobStart
  )
{
  EFI_STATUS  Status;

  gHobList = HobStart;

  InitializeMemoryServices (HobStart);

  InitializePoolServices ();

  InitializePageServices ();

  InitializeLanguageServices ();

  Status = InitializeGlobalSectionProperties ();
  ASSERT_EFI_ERROR (Status);

  InitializeArchProtocolServices (HobStart);

  CoreInitializeDispatcher ();

  CoreDispatcher ();
}
Как только диспетчер DXE завершает выполнение всех найденных драйверов и базовое оборудование готово к работе, фаза завершается. Прошивка вызывает специальный архитектурный протокол BDS (Boot Device Selection), который отвечает за выбор загрузочного устройства и взаимодействие с пользователем.

Наконец-то! Инициализация SMM и SMRAM.​

Также во время фазы DXE наступает момент, когда диспетчер загружает драйвер SMM Init, с которого начинается инициализация изолированной субсреды. SMM - это специальный высокопривилегированный режим работы процессора x86-64, в который он переходит при получении аппаратного или программного прерывания SMI.

Процессор приостанавливает выполнение основного кода UEFI, сохраняет свое состояние и переключается на выполнение SMM-драйверов в полностью изолированной области ОЗУ, называемой SMRAM. В частности этот режим представляет огромный интерес, поскольку код внутри SMM имеет неограниченный доступ ко всему физическому железу и памяти в определенных случаях, оставаясь при этом абсолютно невидимым как для работающего кода UEFI, так и для будущей ОС. Главный DXE-драйвер, который занимается инициализацией SMM и изолированной памяти - PiSmmIpl:

1789857015734.png


EFI_SMM_ACESS2_PROTOCOL_GUID - это протокол, который используется для управления доступом к SMRAM(открытия, закрытия и блокировки этой памяти);
EFI_SMM_CONTROL2_PROTOCOL_GUID - это протокол, который нужен для того, чтобы иницировать программные прерывания SMI.

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

SMM.​

300966

Кольца привилегий Intel x86-64.
Как я уже писал выше, SMM - это высокопривилегированный режим работы процессора, у которого есть своя собственная изолированная память SMRAM. Переход в этот режим осуществляется через вызов программного или аппаратного прерывания SMI. В этот момент процессор замораживает выполнение всех остальных потоков, сохраняет текущее состояние ядер и уходит выполнять код, скрытый от операционной системы. Когда срабатывает прерывание SMI, процессор аппаратно переключается в особый режим, который напоминает старый 16-битный реальный режим, но с возможностью адресовать всю физическую память. Процессор мгновенно останавливает текущую операционную систему или обычный код UEFI и делает две вещи.

Во-первых, он сохраняет состояние всех своих регистров (контекст потока) в специальную выделенную область внутри SMRAM. Эта область называется State Save Area. Она находится в самом конце региона SMRAM для каждого ядра процессора. Когда код SMM завершает свою работу, вызывается специальная инструкция RSM (Resume from System Management Mode), которая считывает данные из State Save Area обратно в регистры и возвращает процессор к обычной жизни, как будто ничего не произошло.
SCSS:
format binary
use64

start:
    pushfq
    push rax
    push rbx
    push rcx
    push rdx
    push rsi
    push rdi
    push rbp
    push r8
    push r9
    push r10
    push r11
    push r12
    push r13
    push r14
    push r15

    XOR EAX, EAX
    pop     r15
    pop     r14
    pop     r13
    pop     r12
    pop     r11
    pop     r10
    pop     r9
    pop     r8
    pop     rbp
    pop     rdi
    pop     rsi
    pop     rdx
    pop     rcx
    pop     rbx
    pop     rax
    popfq
    rsm      // выход из режима

Во вторых, указатель команд RIP принудительно прыгает на фиксированный базовый адрес обработчика SMI. Этот адрес задается через специальный регистр процессора SMBASE, по умолчанию при старте системы SMBASE равен 0x30000, в процессе инициализации DXE-драйверов PiSmmIpl переносит его внутрь защищенной области SMRAM, чтобы никто снаружи не мог подменить код обработчика. Сама же память SMRAM физически находится в общей ОЗУ, но чипсет скрывает ее от процессора, когда тот находится в обычном режиме.

Также абсолютно все SMI-обработчики. которые, регистрируются SMM-драйвером выполняются внутри SMRAM. SMM-драйвер может инициализировать, как один обработчик, так и, например - 5.

1789857696498.png

Демонстрация регистрации SMI-обработчиков.
В целом, рассказывать особо здесь из математической части нечего. Хотя ладно, звучит противоречиво, но есть, что описывать - но делать этого я не буду, ведь никому это особо таки и неинтересно. Кстати, вновь предупреждаю, если вы что-то не поняли - для более углубленного ознакомления можно воспользоваться LLM. Переходим на самую интересную главу - эксплуатация SMI-обработчиков. В целом, ради этой части многие и собрались здесь. Также, кстати, можно вызвать SMI-обработчик на Python через библиотеку CHIPSET:
Python:
import chipsec.chipset
import chipsec.hal.interrupts

# номер обработчика
SMI_NUMBER = 0x25
cs = chipsec.chipset.cs()
cs.init(None, True)
ints = chipsec.hal.interrupts.Interrupts(cs)

cs.ints.send_SW_SMI(0, SMI_NUMBER, 0, 0, 0, 0, 0, 0, 0)

Инициируя SMI (с помощью программного прерывания через порт ввода-вывода 0xB2), операционная система передает аргументы обработчику SMI в регистрах общего назначения:
C++:
mov rax, rdx ; rax_value
mov ax, cx ; smi_code_data
mov rdx, r10 ; rdx_value
mov dx, 0B2h ; порт управления SMI (0xB2)
mov rbx, r8 ; rbx_value
mov rcx, r9 ; rcx_value
mov rsi, r11 ; rsi_value
mov rdi, r12 ; rdi_value
; записать значение данных smi в порты данных и управления SMI (0xB2/0xB3)
out dx, ax

Защита SMRAM.​

Доступ к SMRAM защищен разными аппаратными защитами безопасности, реализованы они для того, чтобы злоумышленник не мог просто взять и писать в SMRAM. В процессе их работы чипсет аппаратно блокирует любые попытки чтения или записи по физическим адресам, выделенным под SMRAM. Любой запрос извне возвращает либо нули, либо мусор, защищая код SMM от анализа и патчинга в реальном времени. Но как только процессор получает прерывание SMI и аппаратно переключается в режим SMM, чипсет открывает эту область памяти для ядер. Основные виды защиты:
  1. Регистры TSEG - чипсет определяет границы SMRAM с помощью специальных конфигурационных регистров, самый популярный это TSEG, который резервирует фиксированный кусок памяти на самом верху физической памяти(обычно от 0 до 8 мегабайт) специально для нужд SMM;
  2. Бит D_LCK - в конфигурационном регистре управления SMRAM есть специальный бит блокировки -D_LCK (в архитектуре Intel он находится в регистре SMRAMC). В процессе загрузки платы драйвер PiSmmIpl настраивает границы SMRAM и в самом конце фазы DXE принудительно выставляет этот бит в единицу. После фиксации бита D_LCK аппаратная конфигурация SMRAM становится неизменяемой на уровне железа. Изменить границы памяти или отключить защиту нельзя вплоть до полной перезагрузки платформы через сигнал RESET#.
  3. Регистры SMRR - это MSR-регистры внутри самого процессора, их задача дублирование защиты чипсета на уровне ядер. В SMRR записываются базовый адрес и размер области SMRAM. Если ядро процессора пытается прочитать данные из этого диапазона, не находясь в режиме SMM, архитектура x86-64 аппаратно блокирует эту операци;
  4. SMMCAC - Более современная технология (управляемая через MSR MSR_SMM_MCA_CAP), которая работает в обратную сторону , она запрещает процессору, находящемуся в режиме SMM, выполнять инструкции(интерпретировать байты как код), если они физически расположены в обычной ОЗУ за пределами SMRAM. Изначально создавалась против атак вроде SMM-callout, которые разобраны ниже.

Тривиальные способы эксплуатации SMI-обработчиков.​

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

Первая самая интересная и простая уязвимость, против которой уже придумали механизмы защиты - это SMM-callout, дело в том, что когда процессор переключается в режим SMM, он получает довольно высокий уровень привилегий, сохраняя при этом полный доступ ко всему адресному пространству, включая обычную ОЗУ. Уязвимость SMM-callout возникает тогда, когда код внутри SMI-обработчика по ошибке совершает вызов функции или прыжок по указателю, который физически указывает на область памяти за пределами защищенного региона SMRAM. Например, разработчик прошивки решает вызвать какую-то стандартную функцию UEFI(например, сервис из глобальной таблицы gBS), прямо из SMI-обработчика. Но таблица gBS была создана и инициализирована на этапе DXE, и все ее функции лежат в обычной ОЗУ, которая никак не защищена.

Поскольку в режиме SMM аппаратная защита памяти чипсета (TSEG) блокирует доступ снаружи внутрь SMRAM, но никак не ограничивает доступ изнутри SMRAM наружу, процессор послушно прыгает по указанному адресу в обычную ОЗУ. SMI-обработчик без этой уязвимости выглядит довольно таки чисто и тривиально, например:
C++:
EFI_STATUS
EFIAPI
SafeSmiHandler (
  IN EFI_HANDLE  DispatchHandle,
  IN CONST VOID  *Context,
  IN OUT VOID    *CommBuffer,
  IN OUT UINTN   *CommBufferSize
  )
{
  EFI_STATUS  Status;
  VOID        *Buffer;

  if (CommBuffer == NULL || CommBufferSize == NULL) {
    return EFI_INVALID_PARAMETER;
  }

  Status = gSmst->SmmAllocatePool (
                    EfiRuntimeServicesData,
                    0x1000,
                    &Buffer
                    );

  if (EFI_ERROR (Status))
{
    return Status;
  }

  ZeroMem (Buffer, 0x1000);

  gSmst->SmmFreePool (Buffer);

  return EFI_SUCCESS;
}
Кстати, в этом примере используется таблица gSmst (SMM System Table) и функция SmmAllocatePool, которая гарантированно выделяет память внутри защищенной области SMRAM. Теперь рассмотрим код, поддающийся этой уявимости:
C++:
EFI_STATUS
EFIAPI
VulnerableSmiHandler (
  IN EFI_HANDLE  DispatchHandle,
  IN CONST VOID  *Context,
  IN OUT VOID    *CommBuffer,
  IN OUT UINTN   *CommBufferSize
  )
{
  EFI_STATUS  Status;
  VOID        *Buffer;

  if (CommBuffer == NULL || CommBufferSize == NULL) {
    return EFI_INVALID_PARAMETER;
  }

  // вот и ошибка - вызов функции выделения памяти из обычной незащищенной таблицы gBS
  Status = gBS->AllocatePool (
                  EfiBootServicesData,
                  0x1000,
                  &Buffer
                  );

  if (EFI_ERROR (Status))
{
    return Status;
  }

  ZeroMem (Buffer, 0x1000);

  gBS->FreePool (Buffer);

  return EFI_SUCCESS;
}
В целом с этой уязвимостью все понятно, на самом деле ее эксплуатация сейчас это сильный труд. Разберем на последок еще одну уязвимость которая менее популярная, но, по моему мнению более проста в эксплуатации, а именно - Comm Buffer Overflow.

Дело в том, что SMI-обработчик имеет право на легитимную коммуникацию, например с драйвером Windows через определенный протокол, собственно они могут обмениваться данными. Поскольку ОС не имеет прямого доступа к SMRAM, этот обмен происходит на нейтральной территории - в обычной ОЗУ. Драйвер ОС заполняет буфер данными, а затем триггерит прерывание SMI. Процессор переключается в SMM, и уязвимый SMI-обработчик начинает читать данные из этого внешнего буфера.

Суть уязвимости заключается в классическом отусутствии со стороны прошивки, разработчик SMI-обработчика ожидает, что из ОС придут данные строго определенного размера и начинает содержимое буфера во внутреннюю структуру данных или массив, расположенный внутри защищенного региона SMRAM. Если обработчик в коде не проверяет реальную длину входящих данных (CommBufferSize), то атакующий из-под ядра Windows может намеренно передать буфер огромного размера, переполненный кастомным шелл-кодом. Процессор в режиме SMM начнет копирование, выйдет за границы выделенного массива и перезапишет соседние участки памяти внутри SMRAM. Разберем два SMI-обработчика драйвера SmmHddSecurity. Первый обработчик выглядит довольно таки аккуратно:

1789858797475.png

Демонстрация одного SMI-обработчика.
На скриншоте отлично видно, как легитимный обработчик защищается от некорректных данных. На 17-й строчке кода идет жесткая проверка размера входящего буфера , в результате если размер не равен ожидаемым 128 или 8 байтам, обработчик мгновенно завершает работу через return 0, предотвращая любую попытку переполнения памяти на следующих этапах. Также подобной проверки на определенное кол-во байт может и не быть, и SMI-обработчик может, например выглядеть так:+

1789858905051.png

Демонстрация второго SMI-обработчика.
На примере этого второго обработчика видно, что жесткой проверки на фиксированный размер (как прошлые 128 или 8 байт) здесь уже нет. Вместо этого код пытается динамически рассчитать, валиден ли буфер, на основе данных, которые лежат внутри него самого. Теперь разберем непосредственно тривиальный уязвимый код, когда буфер переполняется и данные копируются внутрь SMRAM:
C++:
EFI_STATUS
EFIAPI
VulnerableCommBufferHandler (
  IN EFI_HANDLE  DispatchHandle,
  IN CONST VOID  *Context,
  IN OUT VOID    *CommBuffer,
  IN OUT UINTN   *CommBufferSize
  )
{
  UINT8  LocalSecureBuffer[64];

  if (CommBuffer == NULL || CommBufferSize == NULL) {
    return EFI_INVALID_PARAMETER;
  }

  // копирование. если из ОС передадут данные больше 64 байт , случится переполнение стека внутри SMRAM
  CopyMem (LocalSecureBuffer, CommBuffer, *CommBufferSize);

  return EFI_SUCCESS;
}
Выделяется фиксированный массив размером 64 байта, который физически создается на стеке SNRAM.
Проблема в том, что функция CopyMem берет размер копирования из указателя *CommBufferSize, который полностью контролируется атакующим из-под операционной системы. Если передать в обработчик буфер размером, например, 256 байт, функция послушно скопирует их, полностью уничтожив всё, что лежало на стеке дальше выделенных 64 байт. В итоге затираются адреса возврата, и при попытке выйти из обработчика процессор прыгнет выполнять код, который злоумышленник только что передал в этот буфер. Непосредственно также есть и другого рода уязвимости, которые я разбирать не стал.

Про античиты и способы детектирования.​

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

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

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

Вложения

  • 1789853887440.png
    1789853887440.png
    56 KB · Просмотры: 5
  • 1789865255994.png
    1789865255994.png
    421 KB · Просмотры: 0
Последнее редактирование: