- 38
- 28
И вновь всем привет. Приветствую вас на своей новой статье в которой мы обсудим и поговорим насчет архитектуры современных читов и античитов. Если честно, написать данную статью меня сподвигнуло то, что в комьюнити геймхакеров начали проявляться некие треды, где люди спрашивают, как им правильно реализовывать собственные SMM-драйверы для чтения и записи в физическую память и то, что я сейчас болею и делать нечего, а обучаться просто сил нет, единственный источник сил - это качественная статья написанная своими руками :).
Так, но, если посмотреть на эти обсуждения, то в частности все это обсуждалось лишь теоретически. Люди строят догадки, как это работает на практике, хотя реальных примеров или разборов почти нет. Собственно несколько месяцев я занимался активным изучением архитектурой UEFI-совместимых прошивок со своими менторами, параллельно с этим разбирал SMM. И сегодня, в данной статье я изложу все свои знания, но уже в контексте геймхакинга.
И от себя скажу так. Имхо, на 2026 год обычного драйвера все так же хватает по горло. Нет вообще никакой реальной необходимости столь глубоко уходить во все эти дебри, это лишь пустая трата времени и сил, которую вы могли бы пустить на что-то более полезное. Качественные уязвимые драйверы, которые были обнаружены вручную (т.е те, что не лежат на гитхабе и не являются общеизвестными), все еще спокойно живут вне детекта. Приятного всем чтения, жду фидбек!
Источники:
В частности 80-85 процентов статьи писал сам. но и проверял информацию из некоторых источников:
1. Книга Алексея Матросова про руткиты и буткиты;
2. Статья на Хабре от Николая - https://habr.com/ru/articles/185764/
На этом все, начнем.
Также, статья для удобного чтения в Telegram - https://teletype.in/@mosohism_protocol/UEFItypes
Если мы обсуждаем архитектуру античитов, то здесь источником телеметрии может выступать несколько компонентов, которые работают на разных уровнях привилегий. В частности сильные современные античиты имеют один драйвер работающий в пространстве ядра, имея более высокий уровень привилегий относительно компонента, работающего в пользовательском пространстве, а в пользовательском пространстве существует не только один компонент, их может быть множество. Перечисление:
Диаграмма демонстрирующая архитектуру.
Драйвер. может собирать телеметрию из достаточно большого количества источников непосредственно через функции ядра. Один из основных способов для этого - так называемые функции обратного вызова. Смысл заключается в том, что драйвер регистрирует свою функцию, а ядро самостоятельно вызывает её при наступлении определенного события. Функции обратного вызова существуют разного рода и предназначены для совершенно разных событий. Например, через ObRegisterCallbacks драйвер может получать уведомления об операциях с дескрипторами процессов и потоков, например, можно зафиксировать попытку открытия дескриптора через стандартный OpenProcess. А вот через CmRegisterCallbackEx можно наблюдать за операциями с реестром. PsSetCreateProcessNotifyRoutine позволяет получать уведомления о создании и завершении процессов, а PsSetCreateThreadNotifyRoutine о создании потоков. Отдельно существует PsSetLoadImageNotifyRoutine, через который драйвер может получать уведомления о загрузке исполняемых образов, в том числе DLL, в адресное пространство процесса.
Как демонстрационный пример напишем простой код, который выполняет некую логику. Его задача - сохранить текущий токен процесса, а затем периодически сравнивать его с текущим токеном процесса. Если обнаруживается, что токен отличается от сохранённого, мы можем зафиксировать это как подозрительное изменение и передать событие в систему детектирования. Данная проверка может использоваться для защиты от EoP-эксплойтов, связанных с кражей токена процесса, техника работает так - процесс через BYOVD драйвер получает доступ к структуре EPROCESS системного процесса, извлекает из неё указатель на первичный токен и копирует этот указатель в структуру своего EPROCESS, мгновенно присваивая себе привилегии SYSTEM
Демонстрация токена в EPROCESS.
EPROCESS - это системная структура ядра. Внутри EPROCESS находятся ссылки на ключевые подсистемы - таблицу дескрипторов, структуру токена доступа (TOKEN), список потоков (ETHREAD), информацию о виртуальной памяти процесса (VAD-дерево), а также данные, связанные с идентификацией процесса (PID, PPID) и его привилегиями.
Суть техники Inverted Call заключается в том, что пользовательский процесс античита заранее отправляет драйверу ящик пустых IOCTL-запросов (обычно через пул потоков и асинхронный ввод-вывод с использованием OVERLAPPED структур или портов завершения ввода-вывода - IOCP). Драйвер не выполняет эти запросы сразу, а ставит их в очередь и оставляет висеть в состоянии ожидания, возвращая статус STATUS_PENDING. Как только в операционной системе происходит критическое событие, например, срабатывает функция обратного вызова, создания процесса PsSetCreateProcessNotifyRoutineEx - драйвер ядра извлекает один висящий в очереди IRP-пакет, заполняет его буфер телеметрией (PID, путь к файлу и т.д) и завершает операцию вызовом IoCompleteRequest.
Потом же пользовательский процесс мгновенно просыпается, забирает готовые данные для анализа, а на место обработанного запроса тут же отправляет новый пустой IRP-пакет, данный цикл повторяется непрерывно, обеспечивая передачу логов ядра наверх в реальном времени с минимальными задержками и практически нулевой нагрузкой на процессор. Обычно для подобного существует отдельный поток в пользовательском режиме.
Диаграмма демонстрирующая коммуникацию.
Инициатором передачи данных формально остаётся пользовательский процесс, но саму передачу фактически инициирует ядро, когда появляется новое событие. Именно поэтому механизм и получил название Inverted Call.
SPI-flash.
Также стоит учесть про чипсет PCH - это современный чипсет от Intel, который управляет перефирийными устройствами и связью между компонентамина материнской плате. При включении ПК южный мост первым делом обращается к самому первому региону в SPI-flash. Чипсет(PCH) считывает карту смещений и права доступа, чтобы понять, где физически на флешке находятся прошивки для сервисных процессоров и где искать точку старта основного процессора для передачи управления в регион BIOS / UEFI.
Диаграмма демонстрирующая регионы.
В целом, единственный регион который нас интересует в рамках этой статьи - это конечно же регион с UEFI. Остальные регионы(например, Intel ME/PSP), это уже отдельная тема для разговоров, данные микроконтроллеры тоже интересны с точки зрения разработки читов, но в рамках данной статьи они разбираться не будут.
Диаграмма демонстрирующая фазы инициализацию UEFI.RESET# - это физический контакт на процессоре, через который материнская плата сообщает процессору про инициализацию с чистого листа. После того, как процессор снимает напряжение с контакта RESET#, он просыпается в реальном режиме с жестко заданным начальным состоянием регистров. Указатель команд автоматически выставляется на адрес вектора сброса - 0xFFFFFFF0.
Данный адрес является верхним краем 32-битного адресного пространства, ровно 16 байт до конца(от 0xFFFFFFF0 до 0xFFFFFFFF). В этот момент ОЗУ в этот момент еще полностью отключена(она инициализируется на одной из фаз загрузки UEFI), поэтому процессор физически не может читать из нее данные. Чтобы обойти эту проблему, чипсет аппаратно перенаправляет все запросы к этому верхнему диапазону адресов прямиком на SPI-flash микросхему через специальный контроллер. Процессор думает, что читает обычную память, но на самом деле он забирает байты напрямую из флешки.
По адресу физически невозможно уместить какую-то сложную логику, там всего-то 16 байт, что невероятно мало. Архитектурно так вышло, что в результате этого ограничения там зашита всего одно короткая инструкция безусловного перехода FAR JMP, которая указывает процессору прыгнуть на местоположение кода прошивки. Этот прыжок ведет на функцию SecEntryPoint, которая находится чуть ниже в адресном пространстве. Непосредственно с нее начинается выполнение первой фазы UEFI, которая называется SEC. Основная задача процессора на этом этапе , это проверить целостность прошивки и подготовить временную память в своем кэше, чтобы можно было начать выполнять более сложный код на языке С. Разберем более подробную каждую фазу загрузки.
Как только временная память настроена, а базовые структуры данных заполнены, фаза SEC формирует специальную структуру данных - PPI (Peer-to-Peer Interface) и передает управление следующему этапу. Процессор переходит к фазе PEI, передавая ей указатель на созданный в кэше стек.
Пока ОЗУ не инициализирована, модули фазы PEI (они называются PEIM / PEI Modules) выполняются по технологии XIP (Execute-in-Place), это значит, что код читается и исполняется процессором напрямую из SPI-flash памяти, а все переменные складываются в созданный ранее временный стек в кэше.
После того, как MRC настраивает контроллер и планки памяти начинают функционировать, происходит одно важное событие , а именно миграция стека. Код UEFI копирует все данные из кэша процессора в начало полноценной ОЗУ и отключает режим CAS.
Также стоит рассказать о том, что формат модулей PEI может как совпадать с форматом драйверов DXE (PE32+), так и отличаться от него заголовком, т.к. заголовок PE32+ содержит множество неиспользуемых в фазе PEI полей, а место в кэше процессора не резиновое. Поэтому для PEIM был разработан специальный формат TE, заголовок которого содержит только необходимые поля. TE бывают исполняемые-на-месте (XIP), перемещаемые (relocatable) и независимые от позиции (PIC). Также встречаются гибридные DXE/PEI-модули с двумя точками входа, но они обязаны быть в формате PE32+, поскольку иначе как драйвер DXE такой модуль не запустится. Переходим к фазе DXE.
В данной фазе выполняется основная окончательная инициализация всего на основе от PEIНОВ'ов. Собственно на данном этапе прошивка получает доступ к полноценной ОЗУ, что развязывает руки разработчикам. Диспетчер DXE (DxeCore) считывает переданные из предыдущей фазы структуры HOB и начинает разворачивать полноценную операционную среду.
Драйверы в данной фазе представляют собой стандартные исполняемые файлы формата PE32+ (модифицированный Portable Executable, как в Windows). Они могут свободно выделять память, создавать сложные интерфейсы (протоколы) и взаимодействовать друг с другом. В данной фазе регистрируются важные сервисы UEFI - Boot Services / Runtime Services. Архитектурно DXE-драйверы могут зависеть друг от друга, поэтому диспетчер прошивки вычисляет их зависимости по специальным секциям и запускает строго поочереди.
Как только диспетчер DXE завершает выполнение всех найденных драйверов и базовое оборудование готово к работе, фаза завершается. Прошивка вызывает специальный архитектурный протокол BDS (Boot Device Selection), который отвечает за выбор загрузочного устройства и взаимодействие с пользователем.
Процессор приостанавливает выполнение основного кода UEFI, сохраняет свое состояние и переключается на выполнение SMM-драйверов в полностью изолированной области ОЗУ, называемой SMRAM. В частности этот режим представляет огромный интерес, поскольку код внутри SMM имеет неограниченный доступ ко всему физическому железу и памяти в определенных случаях, оставаясь при этом абсолютно невидимым как для работающего кода UEFI, так и для будущей ОС. Главный DXE-драйвер, который занимается инициализацией SMM и изолированной памяти - PiSmmIpl:
EFI_SMM_ACESS2_PROTOCOL_GUID - это протокол, который используется для управления доступом к SMRAM(открытия, закрытия и блокировки этой памяти);
EFI_SMM_CONTROL2_PROTOCOL_GUID - это протокол, который нужен для того, чтобы иницировать программные прерывания SMI.
Протоколы - это по сути, обычные интерфейсы (структуры с указателями на функции), у каждого из которых есть свой уникальный 128-битный идентификатор - GUID, например, если одному драйверу нужно вызвать функцию из другого драйвера или ядра, он не может сделать это напрямую. Вместо этого он обращается к сервисам загрузки и просит найти нужный интерфейс по его GUID. Двигаемся дальше.
Кольца привилегий Intel x86-64.
Как я уже писал выше, SMM - это высокопривилегированный режим работы процессора, у которого есть своя собственная изолированная память SMRAM. Переход в этот режим осуществляется через вызов программного или аппаратного прерывания SMI. В этот момент процессор замораживает выполнение всех остальных потоков, сохраняет текущее состояние ядер и уходит выполнять код, скрытый от операционной системы. Когда срабатывает прерывание SMI, процессор аппаратно переключается в особый режим, который напоминает старый 16-битный реальный режим, но с возможностью адресовать всю физическую память. Процессор мгновенно останавливает текущую операционную систему или обычный код UEFI и делает две вещи.
Во-первых, он сохраняет состояние всех своих регистров (контекст потока) в специальную выделенную область внутри SMRAM. Эта область называется State Save Area. Она находится в самом конце региона SMRAM для каждого ядра процессора. Когда код SMM завершает свою работу, вызывается специальная инструкция RSM (Resume from System Management Mode), которая считывает данные из State Save Area обратно в регистры и возвращает процессор к обычной жизни, как будто ничего не произошло.
Во вторых, указатель команд RIP принудительно прыгает на фиксированный базовый адрес обработчика SMI. Этот адрес задается через специальный регистр процессора SMBASE, по умолчанию при старте системы SMBASE равен 0x30000, в процессе инициализации DXE-драйверов PiSmmIpl переносит его внутрь защищенной области SMRAM, чтобы никто снаружи не мог подменить код обработчика. Сама же память SMRAM физически находится в общей ОЗУ, но чипсет скрывает ее от процессора, когда тот находится в обычном режиме.
Также абсолютно все SMI-обработчики. которые, регистрируются SMM-драйвером выполняются внутри SMRAM. SMM-драйвер может инициализировать, как один обработчик, так и, например - 5.
Демонстрация регистрации SMI-обработчиков.В целом, рассказывать особо здесь из математической части нечего. Хотя ладно, звучит противоречиво, но есть, что описывать - но делать этого я не буду, ведь никому это особо таки и неинтересно. Кстати, вновь предупреждаю, если вы что-то не поняли - для более углубленного ознакомления можно воспользоваться LLM. Переходим на самую интересную главу - эксплуатация SMI-обработчиков. В целом, ради этой части многие и собрались здесь. Также, кстати, можно вызвать SMI-обработчик на Python через библиотеку CHIPSET:
Инициируя SMI (с помощью программного прерывания через порт ввода-вывода 0xB2), операционная система передает аргументы обработчику SMI в регистрах общего назначения:
Первая самая интересная и простая уязвимость, против которой уже придумали механизмы защиты - это SMM-callout, дело в том, что когда процессор переключается в режим SMM, он получает довольно высокий уровень привилегий, сохраняя при этом полный доступ ко всему адресному пространству, включая обычную ОЗУ. Уязвимость SMM-callout возникает тогда, когда код внутри SMI-обработчика по ошибке совершает вызов функции или прыжок по указателю, который физически указывает на область памяти за пределами защищенного региона SMRAM. Например, разработчик прошивки решает вызвать какую-то стандартную функцию UEFI(например, сервис из глобальной таблицы gBS), прямо из SMI-обработчика. Но таблица gBS была создана и инициализирована на этапе DXE, и все ее функции лежат в обычной ОЗУ, которая никак не защищена.
Поскольку в режиме SMM аппаратная защита памяти чипсета (TSEG) блокирует доступ снаружи внутрь SMRAM, но никак не ограничивает доступ изнутри SMRAM наружу, процессор послушно прыгает по указанному адресу в обычную ОЗУ. SMI-обработчик без этой уязвимости выглядит довольно таки чисто и тривиально, например:
Кстати, в этом примере используется таблица gSmst (SMM System Table) и функция SmmAllocatePool, которая гарантированно выделяет память внутри защищенной области SMRAM. Теперь рассмотрим код, поддающийся этой уявимости:
В целом с этой уязвимостью все понятно, на самом деле ее эксплуатация сейчас это сильный труд. Разберем на последок еще одну уязвимость которая менее популярная, но, по моему мнению более проста в эксплуатации, а именно - Comm Buffer Overflow.
Дело в том, что SMI-обработчик имеет право на легитимную коммуникацию, например с драйвером Windows через определенный протокол, собственно они могут обмениваться данными. Поскольку ОС не имеет прямого доступа к SMRAM, этот обмен происходит на нейтральной территории - в обычной ОЗУ. Драйвер ОС заполняет буфер данными, а затем триггерит прерывание SMI. Процессор переключается в SMM, и уязвимый SMI-обработчик начинает читать данные из этого внешнего буфера.
Суть уязвимости заключается в классическом отусутствии со стороны прошивки, разработчик SMI-обработчика ожидает, что из ОС придут данные строго определенного размера и начинает содержимое буфера во внутреннюю структуру данных или массив, расположенный внутри защищенного региона SMRAM. Если обработчик в коде не проверяет реальную длину входящих данных (CommBufferSize), то атакующий из-под ядра Windows может намеренно передать буфер огромного размера, переполненный кастомным шелл-кодом. Процессор в режиме SMM начнет копирование, выйдет за границы выделенного массива и перезапишет соседние участки памяти внутри SMRAM. Разберем два SMI-обработчика драйвера SmmHddSecurity. Первый обработчик выглядит довольно таки аккуратно:
Демонстрация одного SMI-обработчика.На скриншоте отлично видно, как легитимный обработчик защищается от некорректных данных. На 17-й строчке кода идет жесткая проверка размера входящего буфера , в результате если размер не равен ожидаемым 128 или 8 байтам, обработчик мгновенно завершает работу через return 0, предотвращая любую попытку переполнения памяти на следующих этапах. Также подобной проверки на определенное кол-во байт может и не быть, и SMI-обработчик может, например выглядеть так:+
Демонстрация второго SMI-обработчика.На примере этого второго обработчика видно, что жесткой проверки на фиксированный размер (как прошлые 128 или 8 байт) здесь уже нет. Вместо этого код пытается динамически рассчитать, валиден ли буфер, на основе данных, которые лежат внутри него самого. Теперь разберем непосредственно тривиальный уязвимый код, когда буфер переполняется и данные копируются внутрь SMRAM:
Выделяется фиксированный массив размером 64 байта, который физически создается на стеке SNRAM.
Проблема в том, что функция CopyMem берет размер копирования из указателя *CommBufferSize, который полностью контролируется атакующим из-под операционной системы. Если передать в обработчик буфер размером, например, 256 байт, функция послушно скопирует их, полностью уничтожив всё, что лежало на стеке дальше выделенных 64 байт. В итоге затираются адреса возврата, и при попытке выйти из обработчика процессор прыгнет выполнять код, который злоумышленник только что передал в этот буфер. Непосредственно также есть и другого рода уязвимости, которые я разбирать не стал.
Уже сегодня античиты детектируют BYOVD-драйверы как не в себя, например тот же Intel драйвер на котором основан kdmapper. В результате чего, нужно не просто искать уязвимости или способы загрузки 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/Если мы обсуждаем архитектуру античитов, то здесь источником телеметрии может выступать несколько компонентов, которые работают на разных уровнях привилегий. В частности сильные современные античиты имеют один драйвер работающий в пространстве ядра, имея более высокий уровень привилегий относительно компонента, работающего в пользовательском пространстве, а в пользовательском пространстве существует не только один компонент, их может быть множество. Перечисление:
- Привилегированным главным компонентом, который, по моему анализу внутри себя хранит 70-80 процентов защиты выступает драйвер. Задача драйвера это защита процесса игры, самозащита и защита процесса античита, также в его задачу входит обработку данных и сигналов из пользовательского режима, для коммуникации между основным пользовательским приложением(например, mrac.exe) и драйвером используются в частности IOCTL-запросы;
- Те компоненты, работающие в пользовательском режиме являются менее привилегированными. В этот ряд относятся DLL-модули, которые работают в пространстве игры и не только, задача которых это сканирование виртуальной памяти на поиск определенных сигнатур и прочее, также в этот ряд относится основной модуль античита, например. опять же - mrac.exe. Коммуникация же между компонентами юзермода - например, модулем DLL и .exe осуществляется через разные способы. В частности самый безопасный способ это создание локального RPC-сервера, что и собственно говоря делают многие.
Диаграмма демонстрирующая архитектуру.
Как демонстрационный пример напишем простой код, который выполняет некую логику. Его задача - сохранить текущий токен процесса, а затем периодически сравнивать его с текущим токеном процесса. Если обнаруживается, что токен отличается от сохранённого, мы можем зафиксировать это как подозрительное изменение и передать событие в систему детектирования. Данная проверка может использоваться для защиты от 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);
}
}
}
Демонстрация токена в EPROCESS.
Inverted Call.
Как вы уже поняли, большая часть логики защиты заложена непосредственно в драйверы. Но у античита всё так же имеется пользовательский процесс, который должен взаимодействовать с основным драйвером. Их взаимодействие происходит путем отправки из пользовательского режима IOCTL-кодов до основного драйвера для выполнения определенного действия со стороны драйвера. Пользовательский процесс открывает дескриптор устройства, и при необходимости отправляет IOCTL-запросы через функцию DeviceIoControl.Суть техники Inverted Call заключается в том, что пользовательский процесс античита заранее отправляет драйверу ящик пустых IOCTL-запросов (обычно через пул потоков и асинхронный ввод-вывод с использованием OVERLAPPED структур или портов завершения ввода-вывода - IOCP). Драйвер не выполняет эти запросы сразу, а ставит их в очередь и оставляет висеть в состоянии ожидания, возвращая статус STATUS_PENDING. Как только в операционной системе происходит критическое событие, например, срабатывает функция обратного вызова, создания процесса PsSetCreateProcessNotifyRoutineEx - драйвер ядра извлекает один висящий в очереди IRP-пакет, заполняет его буфер телеметрией (PID, путь к файлу и т.д) и завершает операцию вызовом IoCompleteRequest.
Потом же пользовательский процесс мгновенно просыпается, забирает готовые данные для анализа, а на место обработанного запроса тут же отправляет новый пустой IRP-пакет, данный цикл повторяется непрерывно, обеспечивая передачу логов ядра наверх в реальном времени с минимальными задержками и практически нулевой нагрузкой на процессор. Обычно для подобного существует отдельный поток в пользовательском режиме.
Диаграмма демонстрирующая коммуникацию.
Инициатором передачи данных формально остаётся пользовательский процесс, но саму передачу фактически инициирует ядро, когда появляется новое событие. Именно поэтому механизм и получил название Inverted Call.
Привет UEFI! Архитектура UEFI.
SPI-flash память.
SPI-flash память - это энергонезависимая микросхема памяти на материнской плате, в которой физически хранится всё программное обеспечение прошивки. Она подключается к южному мосту по последовательному интерфейсу SPI. SPI-flash память разделяется на несколько регионов, в одном из которых лежит код UEFI-прошивки. Основные регионы:- Flash Descriptor(0) - регион хранит информацию о размерах и смещениях всех остальных регионов, а также права доступа для различных компонентов системы - например, процессора или сетевой карты. Располагается в самом начале флеш-памяти с адреса 0x00000000;
- Intel ME / AMD PSP - регион, который хранит код отдельных микроконтроллеров. У ME и PSP свой процессор, своя прошивка, своя память;
- BIOS / UEFI - регион, который хранит основной код прошивки материнской платы. В частности содержит код инициализации железа(UEFI PI), драйверы разных фаз;
- GbE - регион, который содержит конфигурацию встроенного сетевого адаптера Intel/AMD;
- PDR - регион, который может хранить уникальные ключи шифрования, OEM-сертификаты и так далее.
SPI-flash.
Диаграмма демонстрирующая регионы.
В целом, единственный регион который нас интересует в рамках этой статьи - это конечно же регион с UEFI. Остальные регионы(например, Intel ME/PSP), это уже отдельная тема для разговоров, данные микроконтроллеры тоже интересны с точки зрения разработки читов, но в рамках данной статьи они разбираться не будут.
UEFI
Кстати, сразу скажу, что я упустил фазы BDS и прочие, которые выполняются уже после инициализации основных фаз UEFI, потому-что это не входит в рамки сути статьи. Наконец-то мы подошли к основному содержимому статьи, начнем. Фаза загрузки UEFI-прошивки разделяется на несколько уровней, а описывает эту фазу PI-спецификация - она описывает внутренние интерфейсы между различными частями прошивки UEFI. Полный цикл инициализации платформы - от снятия сигнала RESET# до передачи управления ОС - проходит через семь последовательных фаз. Каждая фаза работает в своём окружении и со своим набором доступных сервисов, своим режимом адресации и своими ограничениями.Диаграмма демонстрирующая фазы инициализацию UEFI.
Данный адрес является верхним краем 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)
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);
}
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 ();
}
Наконец-то! Инициализация SMM и SMRAM.
Также во время фазы DXE наступает момент, когда диспетчер загружает драйвер SMM Init, с которого начинается инициализация изолированной субсреды. SMM - это специальный высокопривилегированный режим работы процессора x86-64, в который он переходит при получении аппаратного или программного прерывания SMI.Процессор приостанавливает выполнение основного кода UEFI, сохраняет свое состояние и переключается на выполнение SMM-драйверов в полностью изолированной области ОЗУ, называемой SMRAM. В частности этот режим представляет огромный интерес, поскольку код внутри SMM имеет неограниченный доступ ко всему физическому железу и памяти в определенных случаях, оставаясь при этом абсолютно невидимым как для работающего кода UEFI, так и для будущей ОС. Главный DXE-драйвер, который занимается инициализацией SMM и изолированной памяти - PiSmmIpl:
EFI_SMM_CONTROL2_PROTOCOL_GUID - это протокол, который нужен для того, чтобы иницировать программные прерывания SMI.
Протоколы - это по сути, обычные интерфейсы (структуры с указателями на функции), у каждого из которых есть свой уникальный 128-битный идентификатор - GUID, например, если одному драйверу нужно вызвать функцию из другого драйвера или ядра, он не может сделать это напрямую. Вместо этого он обращается к сервисам загрузки и просит найти нужный интерфейс по его GUID. Двигаемся дальше.
SMM.
Кольца привилегий Intel x86-64.
Во-первых, он сохраняет состояние всех своих регистров (контекст потока) в специальную выделенную область внутри 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.
Демонстрация регистрации SMI-обработчиков.
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, чипсет открывает эту область памяти для ядер. Основные виды защиты:- Регистры TSEG - чипсет определяет границы SMRAM с помощью специальных конфигурационных регистров, самый популярный это TSEG, который резервирует фиксированный кусок памяти на самом верху физической памяти(обычно от 0 до 8 мегабайт) специально для нужд SMM;
- Бит D_LCK - в конфигурационном регистре управления SMRAM есть специальный бит блокировки -D_LCK (в архитектуре Intel он находится в регистре SMRAMC). В процессе загрузки платы драйвер PiSmmIpl настраивает границы SMRAM и в самом конце фазы DXE принудительно выставляет этот бит в единицу. После фиксации бита D_LCK аппаратная конфигурация SMRAM становится неизменяемой на уровне железа. Изменить границы памяти или отключить защиту нельзя вплоть до полной перезагрузки платформы через сигнал RESET#.
- Регистры SMRR - это MSR-регистры внутри самого процессора, их задача дублирование защиты чипсета на уровне ядер. В SMRR записываются базовый адрес и размер области SMRAM. Если ядро процессора пытается прочитать данные из этого диапазона, не находясь в режиме SMM, архитектура x86-64 аппаратно блокирует эту операци;
- 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;
}
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;
}
Дело в том, что SMI-обработчик имеет право на легитимную коммуникацию, например с драйвером Windows через определенный протокол, собственно они могут обмениваться данными. Поскольку ОС не имеет прямого доступа к SMRAM, этот обмен происходит на нейтральной территории - в обычной ОЗУ. Драйвер ОС заполняет буфер данными, а затем триггерит прерывание SMI. Процессор переключается в SMM, и уязвимый SMI-обработчик начинает читать данные из этого внешнего буфера.
Суть уязвимости заключается в классическом отусутствии со стороны прошивки, разработчик SMI-обработчика ожидает, что из ОС придут данные строго определенного размера и начинает содержимое буфера во внутреннюю структуру данных или массив, расположенный внутри защищенного региона SMRAM. Если обработчик в коде не проверяет реальную длину входящих данных (CommBufferSize), то атакующий из-под ядра Windows может намеренно передать буфер огромного размера, переполненный кастомным шелл-кодом. Процессор в режиме SMM начнет копирование, выйдет за границы выделенного массива и перезапишет соседние участки памяти внутри SMRAM. Разберем два SMI-обработчика драйвера SmmHddSecurity. Первый обработчик выглядит довольно таки аккуратно:
Демонстрация одного SMI-обработчика.
Демонстрация второго SMI-обработчика.
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;
}
Проблема в том, что функция CopyMem берет размер копирования из указателя *CommBufferSize, который полностью контролируется атакующим из-под операционной системы. Если передать в обработчик буфер размером, например, 256 байт, функция послушно скопирует их, полностью уничтожив всё, что лежало на стеке дальше выделенных 64 байт. В итоге затираются адреса возврата, и при попытке выйти из обработчика процессор прыгнет выполнять код, который злоумышленник только что передал в этот буфер. Непосредственно также есть и другого рода уязвимости, которые я разбирать не стал.
Про античиты и способы детектирования.
Выскажу только свое мнение. Как бы это странно не звучало, детектированию подобного рода трюков, например для чтения физической памяти очень легко поддаются детектированию. Например у нас есть чит, у которого в хорошем случае должен быть драйвер, собственно этот драйвер используется для коммуникации с SMI-обработчиком. Собственно современным античитам вообще не нужно следить за обращениями к портам ввода-вывода или пытаться залезть внутрь защищенной SMRAM, чтобы поймать вас за руку. Им достаточно задетектировать именно сам драйвер в системе.Уже сегодня античиты детектируют BYOVD-драйверы как не в себя, например тот же Intel драйвер на котором основан kdmapper. В результате чего, нужно не просто искать уязвимости или способы загрузки SMM-драйвера, а еще и сверху найти способ коммуникации с обработчиком так, чтобы вообще не оставлять никаких следов. Но опять же, драйвер поддается детектированию.
На этом думаю, статью я закончу, если будут вопросы - можете задавать.
Вложения
Последнее редактирование: