
Динамический резолвер и анхукер Windows API, который обнаруживает и восстанавливает перехваченные функции (IAT, EAT, inline patches) для вызова неотслеживаемых системных вызовов из вредоносного ПО Red Team.
В эпоху навязчивых антивирусов и EDR, которые внедряют «горячие патчи» в запущенные процессы для улучшения своих возможностей наблюдения, современным злоумышленникам необходим надежный инструмент, чтобы обойти эти системы охраны. Предлагаемая реализация динамического разрешителя импортов, способного снимать хуки с используемых функций на лету, — это еще один шаг к усилению устойчивости атакующих.
Решение, которое я предлагаю, заключается в отказе от использования разрешаемых компоновщиком импортов WinAPI (которые остаются видимыми в PE-заголовках скомпилированного исполняемого файла, в частности в таблице адресов импорта) в пользу полностью динамического подхода, разрешающего импорты только во время выполнения. Такой динамический разрешитель может быть оснащен логикой снятия хуков, работающей в фоне, без какого-либо вмешательства со стороны оператора.
Вот так можно гарантированно вызвать MessageBoxW без хуков и мониторинга:
RESOLVE(user32, MessageBoxW);
_MessageBoxW(0, L"Смотри, мам! Я без хука!", L"Третий — без хука", 0);
Вся магия происходит внутри макроопределения RESOLVE, которое создает объект ImportResolver<T> с именем _MessageBoxW.

Вот как работает пример UnhookMe:
MessageBoxW, который не подвергается хукингу.MessageBoxW, чтобы он всегда возвращал 0, не отображая сообщение.MessageBoxW с помощью разрешителя UnhookingImportResolver, который обнаружит примененные патчи пролога и восстановит оригинальные байты, эффективно снимая хук с MessageBoxW.В процессе появления окон с сообщениями в консоль (stdout) выводятся следующие строки:
[~] Разрешен символ kernel32.dll!CreateFileA
[~] Разрешен символ kernel32.dll!ReadProcessMemory
[~] Разрешен символ kernel32.dll!MapViewOfFile
[~] Разрешен символ kernel32.dll!VirtualProtectEx
[#] Обнаружен трамплин-хук в символе: MessageBoxW. Восстановлены исходные байты из файла.
[~] Разрешен символ user32.dll!MessageBoxW
Всего есть 5 файлов исходного кода/заголовков на C++, которые необходимо включить в ваш проект. Однако ваш основной файл программы должен включать только два обязательных заголовка, как описано ниже.
resolver.h — заголовок, содержащий большую часть реализации UnhookingImportResolver и удобные макроопределения.resolver.cpp — исходный код с определенными глобальными опциями.usings.h — большой и громоздкий файл заголовка, содержащий десятки определений типов using для часто используемых WinAPI.PE.cpp — исходный код пользовательского парсера PE.PE.h — заголовок пользовательского парсера PE.Вашей программе потребуется включить только два заголовка:
#include "usings.h"
#include "resolver.h"
Существует несколько глобальных опций, которые можно изменить, влияя на то, как работает разрешитель или как он сообщает о своей активности. Они определены в самом начале файла resolver.cpp:
Глобальные опции разрешителя:
globalQuietOption — установите в true, если не хотите никакого вывода.globalVerboseOption — установите в true, если хотите подробный вывод.globalAntiSplicingOption — снимать хуки с разрешенных функций, если они захуканы.globalLogFilePath — куда перенаправлять строки вывода лога. Если пусто, выбирается stdout.bool globalQuietOption = false;
bool globalVerboseOption = true;
bool globalAntiSplicingOption = true;
wchar_t globalLogFilePath[MAX_PATH] = L"";
Чтобы использовать разрешитель, сначала необходимо объявить тип указателя на функцию с помощью оператора using в строгой форме:
using fn_FunctionName = ReturnType WINAPI (
ParamType1 paramName1,
...,
ParamTypeN paramNameN,
);
Репозиторий поставляется с заголовочным файлом usings.h, содержащим предопределенные типы using для десятков популярных Windows API.
Имя функции будет соответствовать WinAPI, который мы хотим разрешить с помощью ImportResolver, и этот указатель на функцию должен быть помечен как имеющий соглашение вызова WINAPI (__stdcall на x86 и __fastcall на x64). ReturnType должен предшествовать модификатору типа WINAPI.
Имея определенный тип указателя на функцию, как указано выше, мы сможем использовать его следующим образом:
RESOLVE(libraryName, FunctionName);
ReturnType output = _FunctionName(param1, ..., paramN);
Макрос RESOLVE заботится о создании экземпляра шаблонного объекта ImportResolver и корректировке указанного имени библиотеки.
Разрешитель предоставляет несколько дополнительных макроопределений для удобного вызова конструктора в различных ситуациях:
#define RESOLVE(mod, func) RESOLVE_PARAMETERIZED(mod, func, ::globalVerboseOption, ::globalAntiSplicingOption)
#define RESOLVE_NO_UNHOOK(mod, func) RESOLVE_PARAMETERIZED(mod, func, ::globalVerboseOption, false)
#define RESOLVE_VERBOSE_UNHOOK(mod, func) RESOLVE_PARAMETERIZED(mod, func, true, true)
#define RESOLVE_VERBOSE_NOUNHOOK(mod, func) RESOLVE_PARAMETERIZED(mod, func, true, false)
#define RESOLVE_NOVERBOSE_UNHOOK(mod, func) RESOLVE_PARAMETERIZED(mod, func, false, true)
#define RESOLVE_NOVERBOSE_NOUNHOOK(mod, func) RESOLVE_PARAMETERIZED(mod, func, false, false)
Конструктор разрешителя:
template<typename Ret, typename ...Args>
ImportResolver<Ret WINAPI(Args...)>(
std::string dllName,
std::string funcName,
bool _verbose = false,
bool _unhook = false,
bool *_wasItHooked = nullptr
)
Лежащий в основе разрешитель использует пользовательский парсер PE-заголовков, который обрабатывает каждый указанный DLL-модуль, чтобы сопоставить их экспорты и проверить целостность PE-заголовков модуля, а также целостность байтов заглушки указанной функции.
Идея следующая:
Сначала мы выдаем LoadLibrary, чтобы загрузить указанную пользователем библиотеку (ту, что указана первым параметром макроса RESOLVE), если ее не удалось получить через GetModuleHandle.
Затем мы обрабатываем PE-заголовки загруженной/указанной библиотеки, сопоставляем ее экспорты, извлекаем массив адресов экспортов, а также вычисляем эти адреса самостоятельно для перекрестной проверки.
Если адрес процедуры, определенный в таблице адресов экспорта DLL, не соответствует ожидаемому, экспорт считается захуканным через EAT. То же самое происходит, если запись в таблице адресов импорта (IAT) нашего исполняемого файла для этой функции была изменена и больше не указывает на правильное место в секции кода DLL — тогда функция считается захуканной через IAT.
Если хуки не были обнаружены, мы извлекаем первые N байтов пролога функции и сравниваем их с тем, что хранится в DLL-файле на диске. Если есть несоответствие между байтами, полученными из памяти, и из файла, то мы считаем, что функция была пропатчена встраиванием (inline patch / hot-patch).
Если функция считается захуканной, мы возвращаем исходный адрес экспорта (который мы вычислили сами) и/или снимаем хук. Если были байты патча, мы их восстанавливаем.
Наконец, чтобы оптимизировать влияние разрешителя на производительность, мы кэшируем все базовые адреса загруженных модулей и адреса разрешенных функций и возвращаем их из кэша (в виде std::map) при последующих обращениях.
Среди проблем, с которыми сталкивается такой динамический разрешитель со снятием хуков, — проблемы с обходом пересылаемых API (forwarded APIs) (DLL может содержать заглушку экспорта, указывающую, что эта функция не реализована в данном модуле, а находится в другом) — хотя данная реализация поддерживает их, иногда это нарушает логику обхода.
Этот и другие проекты — результат бессонных ночей и немалой тяжелой работы. Если вам нравится то, что я делаю, и вы цените, что я всегда отдаю должное сообществу, Купите мне чашечку кофе (или лучше пива), чтобы просто сказать спасибо! 💪
Mariusz Banach / mgeeky, 21
<mb [at] binary-offensive.com>
(https://github.com/mgeeky)