Подписывайтесь на наш Telegram и не пропускайте важные новости! Перейти

Гайд Принцип разработки EDR. Разрабатываем свой собственный EDR | 1 часть.

malware
EXCLUSIVE
EXCLUSIVE
Статус
Оффлайн
Регистрация
21 Июн 2025
Сообщения
137
Реакции
28
Сразу скажу что статья моя, только перезаливается на югейм с левого форума т.к тут много единомышленников которым интересна тема разработки защитных механизмов, а еще мне нужна обоснованная критика :)

Всех читателей приветствую на этой великолепной статье, в ней разложены основы разработки
Пожалуйста, авторизуйтесь для просмотра ссылки.
.
Я планирую реализовать 5 объемных частей. Наша цель - разработать полноценный EDR с корреляционным движком, телеметрией и кучей других разных функций для детектирования современной продвинутой малвари. Наша EDR будет детектировать известные в современности техники обхода антивирусных защитных обеспечений.

edr:
Expand Collapse Copy
EDR (Endpoint Detection and Response) — это
технология кибербезопасности, которая постоянно отслеживает конечные точки (компьютеры, смартфоны, серверы), обнаруживает угрозы, анализирует их в реальном времени и предоставляет инструменты для автоматического реагирования, блокировки и расследования инцидентов, выходя за рамки традиционных антивирусов

Наша EDR будет функционировать на уровне UserMode, возможно, в будущем я реализую статью с переходом на Kernel-Mode. Не 'возможно', а стопроцентно, мы будем обязаны перейти на уровень ядра.

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

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

Основа
P.S - Изначально функция называлась set_score, но ее логика была странной я ее переделал, на некоторых скринах вы можете видеть использование set_score вместо add_score. Главное поймите принцип, логика похожая только сделана правильнее.

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

add_score:
Expand Collapse Copy
void add_score(Corecial* x, int delta) {
    LONG new_score = InterlockedAdd(&x->score, delta);
    if (new_score >= 100) {
        TerminateProcess(GetCurrentProcess(), 0);
    }
}
1769192977930.png

Для этого у меня есть функция add_score, она реализует эту логику. Она принимает указатель на структуру Corecial, содержащую счетчик баллов, и значение delta, которое нужно добавить. Важной особенностью является использование функции InterlockedAdd(), которая обеспечивает атомарность операции сложения в многопоточной среде - это критически важно, поскольку разные хуки могут вызываться параллельно из разных потоков.
В общем, после того как мы объявили структуру, мы создаем функцию типа void. В параметрах мы создаем указатель на структуру - x, также создаем переменную типа int delta.
Знак -> используется для доступа к полям структуры через указатель. Это равносильно разыменованию указателя и обращению к полю: сначала мы получаем саму структуру через *x, затем обращаемся к её полю (*x).score. Оператор -> просто объединяет эти две операции в одну.
Когда новое значение счетчика достигает или превышает 100 баллов, система немедленно завершает текущий процесс. В будущих версиях мы заменим эту простую логику на более логический корреляционный движок, который будет анализировать цепочки событий и выявлять сложные атаки. В общем, будет анализ основываясь на логических связях.
Если A и B и C вызвались в течение N времени, то принимаем решение.

Хуки.
Хук - это перехватывание функции для изменения ее работоспособности. В нашем же случае, хук - это способ перехватить функцию, для анализа ее аргументов с целью вынести вердикт, какова цель вызывания этой функции, в случае если функция является подозрительной, мы задаем определенное значение с обращением в нашу структуру Corecial к score через *x.
Давайте включим аналитическое мышление, представьте, что вы - разработчик малвари, к примеру, вы разработали дроппер. Задача дроппера - доставить малварь на ПК жертвы, запустить ее от имени администратора и удалить все следы, но при этом, реализовать это нужно так, чтобы жертва даже и малейших подозрений не имела касаемо того, что ее ПК заражен.
Задайте себе вопрос: Самый известный способ обхода UAC который мы должны пофиксить в первую очередь?
Ответ проявляется перед глазами - fodhelper.
После доставки малварь на ПК, программа дроппер обязана запустить ее с полным обходом UAC.
UAC (User Account Control) Windows — это функция безопасности, которая защищает систему от несанкционированных изменений, запрашивая подтверждение пользователя перед выполнением действий, требующих прав администратора, и позволяя обычным пользователям работать с минимальными привилегиями, что снижает риск заражения вирусами и вредоносными программами.
1769193141721.png
1769193169699.png

Дроппер через ShellExecuteA вызывает системную утилиту fodhelper.exe(компонент для установки дополнительных функций Windows). В контексте UAC-обхода, малварь использует ее, т.к fodhelper.exe имеет AEM - он автоматический запускается с правами администратора без запроса UAC, если вызван из определенных условий.
Принцип прост:
1) Малварь запускается fodhelper.exe;
2) fodhelper.exe автоматически получает высокие привилегии;
3) Далее через некоторые механизмы которые показаны в коде, малварь заставляет fodhelper.exe выполнить вредоносный код(малварь)

Реализовывать мы будем именно в этой части IAT-хуки.
Давайте реализуем хук на функцию ShellExecuteA с проверкой аргумента.
Структура ShellExecuteA:
structure:
Expand Collapse Copy
HINSTANCE ShellExecuteA(
  [in, optional] HWND   hwnd,
  [in, optional] LPCSTR lpOperation,
  [in]           LPCSTR lpFile,
  [in, optional] LPCSTR lpParameters,
  [in, optional] LPCSTR lpDirectory,
  [in]           INT    nShowCmd
);

Точно также, мы в коде после функции add_score объявляем прототип этой функции, с указателем на нее через PShellExecuteA, также, с OriginalPShellExecuteA которая хранит в себе оригинальную функцию, ее мы будем возвращать, в случае если код прошел анализ.

c:
Expand Collapse Copy
#include <stdio.h>
#include <windows.h>
#include <stdint.h>
#include <string.h>
#include <winternl.h>
typedef HINSTANCE(WINAPI* PShellExecuteA)(HWND, LPCSTR, LPCSTR, LPCSTR, LPCSTR, INT);
PShellExecuteA OriginalPShellExecuteA = NULL;

После чего сразу же объявляем функцию HookShellExecuteA, передаем ей параметры из структуры, подготавливаем возврат оригинала:
1769193295942.png

Пока что никакого условия здесь нет. Нет проверки, вообще ничего, просто функция которая возвращает оригинальную функцию.
Реализуем проверку с использованием условной конструкции:
1769193325005.png

Мы создаем условную конструкцию, где с помощью _stricmp сравниваем.
То есть:
Если IpFile содержит в себе строку fodhelper.exe, передать в структуру Corecial, параметру score значение + 35. Вычисляем: 100 - 35 = 65 баллов еще осталось передать, до завершения процесса.
Если все нормально, этой строки нет - вызов оригинальной функции, анализ пройден.
Это максимально простая проверка на fodhelper.

Теперь стоит поговорить про Process Hollowing.
Это техника, при которой малварь создает легитимный процесс в приостановленном состоянии (CREATE_SUSPENDED), затем выгружает его оригинальный код из памяти и заменяет своим вредоносным кодом.
Нам стоит реализовать хук на функцию ZwUnmapViewOfSection, прервав ее - прервутся и другие функции. Функция ZwUnmapViewOfSection отвечает за выгрузку оригинального образа из памяти.
Ее структура:
c:
Expand Collapse Copy
NTSTATUS ZwUnmapViewOfSection(
  HANDLE ProcessHandle,      // хэндл процесса
  PVOID  BaseAddress         // базовый адрес для выгрузки кода
);

Точно в такой же последовательности как с ShellExecuteA объявляем ее:
hookzw:
Expand Collapse Copy
typedef NTSTATUS(NTAPI* PZwUnmapViewOfSection)(HANDLE, PVOID);
PZwUnmapViewOfSection OriginalPZwUnmapViewOfSection = NULL;

Точно в такой же последовательности объявляем хук и смотрим - выгружается ли код из памяти своего процесса или нет:
hook:
Expand Collapse Copy
NTSTATUS HookZwUnmapViewOfSection(HANDLE ProcessHandle, PVOID BaseAddress)
{
    if (ProcessHandle != GetCurrentProcess() && BaseAddress != NULL)
    {
        add_score(&global_x, 50);
    }
    return OriginalPZwUnmapViewOfSection(ProcessHandle, BaseAddress);
}
Если хэндл процесса не равен текущему процессу, И если базовый адрес не равен 0, - мы обращаемся к нашей структуре Corecial и задаем еще +50 баллов. В случае если функция не выгружает ничего из чужих процессов - вызов оригинала.
Это самая минимальная проверка на Process Hollowing, плюс нашей функции add_score в том, что с ней мы не сразу убиваем процесс, мы проводим поведенческий анализ.
Завершать процесс основываясь только на выгрузке кода не из памяти своего процесса, - было бы нерациональным решением.

Стоит немного поговорить про W^X Bypass.
Это техника, при которой, малварь сначала пишет код в память как данные (write), а затем через WinAPI функцию VirtualProtect меняет права на исполнение (execute).
Или же когда малварь сначала пишет код как R/X, но потом меняет права на R/W/X.
Малварь подобным методом пытается снизить подозрительность, избегая регионы R/W/X, которые очень легко детектируется.
Типичный паттерн использования этой функции:

c:
Expand Collapse Copy
VirtualAlloc(PAGE_READWRITE) -> Write shellcode -> VirtualProtect(PAGE_EXECUTE_READ)
Сначала память выделяется с помощью VirtualAlloc, PAGE_READWRITE, потом, через функцию VirtualProtect подменяется на PAGE_EXECUTE_READ.
Запись шеллкода с правами PAGE_READWRITE -> изменение прав через VirtualProtect -> PAGE_EXECUTE_READ чтобы снизить подозрительность и избежать детекты.

Точно с такой же последовательностью, как и с другими функциями, реализовываем хук на NtProtectVirtualMemory что является оберткой VirtualProtectMemory:
c:
Expand Collapse Copy
typedef NTSTATUS(NTAPI* PNtProtectVirtualMemory)(
    _In_    HANDLE  ProcessHandle,
    _Inout_ PVOID* BaseAddress,
    _Inout_ PSIZE_T RegionSize,
    _In_    ULONG   NewProtection,
    _Out_   PULONG  OldProtection
    );
PNtProtectVirtualMemory OriginalNtProtectVirtualMemory = NULL;
Реализуем хук:
1769193666447.png

Обратите внимание на первую условную конструкцию. В ней мы проверяем, производится ли изменение прав доступа памяти в нашем процессе или в чужом. Если права меняются в чужом процессе, мы добавляем 15 баллов к score.
Вторая условная конструкция немного сложнее. Здесь мы проверяем, устанавливаются ли новые права доступа PAGE_EXECUTE_READ или PAGE_EXECUTE_READWRITE. Если условие выполняется, мы переходим внутрь блока.
В этом блоке создается переменная указатель типа void | returnAddress. Эта переменная нужна для функции
Пожалуйста, авторизуйтесь для просмотра ссылки.
. С помощью этой функции мы определяем, откуда был вызван наш код. Мы проверяем, вызвана ли функция из зарегистрированного модуля системы или нет.
Дело в том, что малварь часто работает из памяти, которая не принадлежит никакому модулю. Например, когда вредоносный код выделяет память через VirtualAllocEx, записывает туда шеллкод, а затем вызывает его. Такой код находится в куче, а не в секции .text какого-либо модуля, что является фактическим доказательством того, что это шеллкод.
Если функция CaptureStackBackTrace показывает, что вызов произошел не из зарегистрированного модуля, мы добавляем еще 20 баллов к нашей переменной score.

Полный код хука:
C:
Expand Collapse Copy
NTSTATUS HookNtProtectVirtualMemory(_In_ HANDLE  ProcessHandle, _Inout_ PVOID* BaseAddress, _Inout_ PSIZE_T RegionSize, _In_ ULONG NewProtection, _Out_ PULONG  OldProtection)
{
    if (ProcessHandle != (HANDLE)-1 && GetProcessId(ProcessHandle) != GetCurrentProcessId())
    {
        add_score(&global_x, 15);
    }
    if (NewProtection == PAGE_EXECUTE_READ || NewProtection == PAGE_EXECUTE_READWRITE)
    {
        void* returnAddress = NULL;
        if (CaptureStackBackTrace(1, 1, &returnAddress, NULL) > 0)
        {
            HMODULE hMod = NULL;
            if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                (LPCSTR)returnAddress, &hMod))
            {
                add_score(&global_x, 20);
            }
        }
    }
    return OriginalNtProtectVirtualMemory(ProcessHandle, BaseAddress, RegionSize, NewProtection, OldProtection);
}

NtWriteVirtualMemory
Также давайте сразу же поставим хук на эту функцию, которая выполняет запись кода в чужой процесс, ну, либо в свой.
Проверка будет максимально проста, мы будем смотреть, выполняется ли запись кода в чужой процесс или же нет, если да - запись определенное количество байт в Corecial -> score.

Точно такой же последовательностью как и в прошлые разы, объявляем структуру и хук:
c:
Expand Collapse Copy
typedef NTSTATUS(NTAPI* PNtWriteVirtualMemory)(
    IN HANDLE ProcessHandle,
    IN PVOID BaseAddress,
    IN PVOID  Buffer,
    IN ULONG  NumberOfBytesToWrite,
    OUT PULONG NumberOfBytesWritten OPTIONAL
    );
PNtWriteVirtualMemory OriginalNtWriteVirtualMemory = NULL;


NTSTATUS HookNtWriteVirtualMemory(IN HANDLE ProcessHandle, IN PVOID BaseAddress, IN PVOID Buffer, IN ULONG NumberOfBytesToWrite, OUT PULONG NumberOfBytesWritten OPTIONAL)
{
    if (ProcessHandle != (HANDLE)-1)
    {
        DWORD targetPid = GetProcessId(ProcessHandle);
        if (targetPid != 0 && targetPid != GetCurrentProcessId())
        {
            add_score(&global_x, 50);
        }
    }
    return OriginalNtWriteVirtualMemory(ProcessHandle, BaseAddress, Buffer, NumberOfBytesToWrite, NumberOfBytesWritten);
}
Объясняю. Мы создаем условную конструкцию if (ProcessHandle != (HANDLE)-1). Она проверяет, равен ли хэндл процесса значению -1.
Значение -1 в WinAPI обозначает псевдо-хэндл текущего процесса. То-есть, если ProcessHandle равен -1, это значит, что операция выполняется над памятью текущего процесса.
В случае если условие выполняется, то мы переходим непосредственно в блок, где проверяем, равен ли полученный PID процесса нулю и не совпадает ли он с PID текущего процесса. Таким способом мы проверяем, выполняется ли запись кода в чужой процесс либо же нет. В случае если проверка пройдена успешно, возвращается оригинальная функция. Прошу обратить внимание, многие могут закритиковать тем, что мол зачем 50, запись кода может использовать отладчик/JIT компилятор и так далее. Я это прекрасно понимаю, как раз таки поэтому я и реализовал функцию add_score. Отладчик может вызвать функцию записи кода в процесс, но вызовет ли отладчик параллельно с этим выгрузку кода из системного процесса для записи своего? Не думаю.

Немного поговорим про HWDB-хуки
Пожалуйста, авторизуйтесь для просмотра ссылки.
-хуки это - технология детектирования малварь, использующая аппаратные возможности процессора.
В нашем же случае, мы будем использовать аппаратные регистры отладки(dr0/dr7), первые три dr0/dr3 будут хранить в себе адреса функций. Мы говорим процессору: "Когда процесс вызовет адрес, который я тебе передал, вызови исключение #DB и передай управление моему VEH-обработчику". После того, как процессор передаст управление нашему VEH-обработчику, там будут реализовываться непосредственно хуки на эти функции и проверки аргументов функций.

Плюс HWDB хуков в том, что мы вообще не трогаем память, мы ничего там не меняем, абсолютно ничего, все происходит на уровне процессора. Мы просто перехватываем исключение и обрабатываем его через наш VEH-обработчик, все максимально просто.
Память девственно чиста, в отличие от IAT-хуков.

Проинициализируем функцию SetHardwareBreakpoint типа void, в параметры передаем ей переменную address типа PVOID и переменную index типа int.
address используется для отслеживания, а переменнная index используется для хранения номера аппаратного регистра процесса, используем мы именно те регистры, в которых можно хранить адреса(dr0/dr3)
А сама функция будет устанавливать аппаратный брейкпоинт на указанный адрес в памяти, в нашем случае, этот адрес будет адрес функции, которую мы хукаем.

Реализация функции с использованием switch/case выглядит вот так:
с:
Expand Collapse Copy
void SetHardwareBreakpoint(PVOID address, int index)
{
    if (index < 0 || index > 3) return;

    CONTEXT ctx = { 0 };
    ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;

    HANDLE hThread = GetCurrentThread();
    HANDLE hThreadCopy;

    if (!DuplicateHandle(GetCurrentProcess(), hThread, GetCurrentProcess(), &hThreadCopy, 0, FALSE, DUPLICATE_SAME_ACCESS))
    {
        return;
    }
    if (GetThreadContext(hThreadCopy, &ctx))
    {
        switch (index)
        {
        case 0: ctx.Dr0 = (DWORD64)address; break;
        case 1: ctx.Dr1 = (DWORD64)address; break;
        case 2: ctx.Dr2 = (DWORD64)address; break;
        case 3: ctx.Dr3 = (DWORD64)address; break;
        }
        ctx.Dr7 |= (1ULL << (index * 2));
        ctx.Dr7 &= ~(0xFULL << (16 + (index * 4)));

        SetThreadContext(hThreadCopy, &ctx);
    }
    CloseHandle(hThreadCopy);
}

Немного про код. Сначала функция проверяет, что индекс в допустимом диапазоне. Затем она получает контекст текущего потока и дублирует его хэндл для безопасной работы. В зависимости от выбранного индекса, адрес брейкпоинта записывается в соответствующий регистр (dr0/dr1/dr2/dr3 и т.д).
Далее настраивается регистр управления dr7: включается брейкпоинт соответствующего индекса и задаются его параметры.
Обратите внимание на эти две строчки кода:
c:
Expand Collapse Copy
ctx.Dr7 |= (1ULL << (index * 2));
ctx.Dr7 &= ~(0xFULL << (16 + (index * 4)));

Эти две строки настраивают регистр управления аппаратными брейкпоинтами dr7.
Так, первая строка включает брейкпоинт с указанным индексом, устанавливая соответствующий бит в регистре dr7, который отвечает за включение.
Вторая строка просто берет и сбрасывает настройки типа брейкпоинта и его размера для выбранного индекса. Если еще проще, то мы просто очищаем старые параметры, что затем установить новые. Сначала мы смотрим параметры через
Пожалуйста, авторизуйтесь для просмотра ссылки.
, а потом задаем эти параметры через
Пожалуйста, авторизуйтесь для просмотра ссылки.
.
Кстати, стоит помнить, что в будущем мы реализуем хуки на эти функции, дело в том, что малварь может попытаться обнулить наши регистры, которые хранят в себе адреса хукнутых функций. Далее.

Но, пока переменная adress ничего не хранит, нам нужно реализовать VEH-обработчик который будет перехватывать #DB исключение от процессора и устанавливать хуки.
Перед тем как трогать обработчик, мы должны объявить структуру функций, которые мы хотим хукнуть.

Я выбрал: NtCreateThreadEx/NtAllocateVirtualMemoryNtProtectVirtualMemory.
Многие могут задаться вопросом: зачем ставить HWDB-хук на третью функцию, если мы уже поставили IAT-хук на неё?
Друзья, это момент, когда ты понимаешь, что не всё так просто, IAT-хуки обойти очень просто, банально загрузить функции по хэшу из EAT(EAT-хуки будут разбираться в следующих статьях).
Но обойти HWDB-хуки намного сложнее, учитывая то, что мы пофиксим все дыры в безопасности этих хуков.

Структура NtProtectVirtualMemory у нас уже объявляна, так что объвляем другие две функции:
c:
Expand Collapse Copy
typedef NTSTATUS(NTAPI* PNtAllocateVirtualMemory)(
    HANDLE ProcessHandle,
    PVOID* BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect
    );
PNtAllocateVirtualMemory OriginalNtAllocateVirtualMemory = NULL;

typedef NTSTATUS(NTAPI* PNtCreateThreadEx)(
    OUT PHANDLE ThreadHandle,
    IN ACCESS_MASK DesiredAccess,
    IN PVOID ObjectAttributes OPTIONAL,
    IN HANDLE ProcessHandle,
    IN PVOID StartRoutine,
    IN PVOID Argument OPTIONAL,
    IN ULONG CreateFlags,
    IN SIZE_T ZeroBits,
    IN SIZE_T StackSize,
    IN SIZE_T MaximumStackSize,
    IN PVOID AttributeList OPTIONAL
    );
PNtCreateThreadEx OriginalNtCreateThreadEx = NULL;

Также объявите переменные которые обращаются к каждой структуре функции:
c:
Expand Collapse Copy
PVOID addrNtProtect = NULL;
PVOID addrNtAllocate = NULL;
PVOID addrNtCreateThread = NULL;
__declspec(thread) PHANDLE g_pThreadHandleAddr = NULL;
Я знаю у многих появится вопрос по поводу __declspec(thread) g_pThreadHandleAddr, очень важный момент в обработчике. В общем, просто помните, что малварь может перейти в другой поток и уже там выполнять свои зловредные намерения, в нашем VEH-обработчике мы это продумаем.

Объявляем обработчик:
1769193978639.png

В первую очередь, в обработчике мы также реализуем функцию, которая будет проверять, вызывается ли код из секции .text или нет, чтобы понять, выполняется ли шеллкод:
heap:
Expand Collapse Copy
LONG WINAPI HardwareBreakpointHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
    PCONTEXT ctx = ExceptionInfo->ContextRecord;
    PVOID faultAddr = ExceptionInfo->ExceptionRecord->ExceptionAddress;

    if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP)
    {
        void* returnAddress = NULL;
        if (CaptureStackBackTrace(2, 1, &returnAddress, NULL) > 0)
        {
            HMODULE hMod = NULL;
            if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPCSTR)returnAddress, &hMod))
            {
                add_score(&global_x, 20);
            }
        }
}
Сначала мы обращаемся к структуре CONTEXT, передаем из нее параметр ExceptionInfo. Она просто сохраняет полное состояние потока(регистры процессра(dr), флаги и так далее, хранит регистры для работы с FPU, но это другое).
1769194030160.png

Сначала мы получаем указатель на структуру CONTEXT из параметра ExceptionInfo.
После чего реализуем условную конструкцию:
с:
Expand Collapse Copy
if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP)
    {
        void* returnAddress = NULL;
        if (CaptureStackBackTrace(2, 1, &returnAddress, NULL) > 0)
        {
            HMODULE hMod = NULL;
            if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPCSTR)returnAddress, &hMod))
            {
                add_score(&global_x, 20);
            }
        }
Касаемо CaptureStackBackTrace я уже объяснял выше, не хочу заливать водой статью, пролистайте и почитай. Обратите внимание на условие if.
ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP
То-есть, если код исключения равен EXCEPTION_SINGLE_STEP, значит сработал наш аппаратный брейкпоинт, а если еще проще, наш хук.
Далее, после того как мы проверили, вызывается ли код секции .text или нет, реализовали проверку EXCEPTION_SINGLE_STEP, можем переходить к непосредственному реализацию наших хуков.

Кстати, не забудьте вернуть EXCEPTION_CONTINUE_SEARCH. Это возвращаемое значение, которое указывает системе продолжить поиск других обработчиков исключений в цепочке. Двигаемся дальше.

Установка HWDB-хуков
Поставим сначала на NtCreateThreadEx:
1769194236025.png

Обратите внимание на условную конструкцию if (faultAddr == addrNtCreateThread) она проверяет, сработал ли наш аппаратный брейкпоинт на функции NtCreateThreadEx. Если да, мы начинаем анализировать её параметры. Через ctx->R9 мы получаем параметр ProcessHandle который отвечает за хэндл процесса:
1769194291145.png

В x64 архитектуре первые 4 параметра передаются через регистры: RCX, RDX, R8, R9, остальное через стек:
asm:
Expand Collapse Copy
mov qword ptr, [rsp+28h], ЗНАЧЕНИЕ
NtCreateThreadEx имеет параметр hProcess (хэндл процесса, где создается поток), который как раз передается в R9.
Далее, если поток создается в чужом процессе (hTargetProc != -1 и PID не наш процесс), возвращаем STATUS_ACCESS_DENIED которая вызывается для любых NtAPI функций, она просто наглухо заблокирует инжектирование. Если же поток создается в текущем процессе, мы сохраняем указатель на хэндл потока в g_pThreadHandleAddr и устанавливаем дополнительный брейкпоинт на адрес возврата через ctx->Dr2, чтобы отследить завершение создания потока.

Далее, обратите внимание на строчки:

с:
Expand Collapse Copy
ctx->Dr2 = retAddr;
ctx->Dr7 |= (1ULL << 4);
В первой строчке, мы сохраняем адреса возврата из стека в регистр dr2, это адрес куда функция NtCreateThreadEx вернет управление после своего выполнения
Во второй строчке, мы включаем брейкпоинт, бит 4 в регистре dr7 управляет брейкпоинтом номер 2, то есть каждый брейкпоинт имеет свой бит включения: 0, 2, 4, 6 и т.д. Подробнее можете прчитать на форумах по типу Хабра.
Таким методом можно отслеживать создание нового потока, как я уже говорил, малварь может создать новый поток, в котором будет выполнять свои вредоносные действия
1769194394318.png

Также важно прописать ctx->EFlags |= (1 << 16); потому-что без этого процессор не будет генерировать следующие single-step исключения.
Делаем абсолютно тоже самое, в той же последовательности, с разными условиями проверки функций, с другими функциями:
c:
Expand Collapse Copy
if (faultAddr == addrNtAllocate)
    {
        ULONG Protect = *(ULONG*)(ctx->Rsp + 48);

        if (Protect == PAGE_EXECUTE_READWRITE)
        {
            return 0xC0000022;
        }
        ctx->EFlags |= (1 << 16);
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    if (faultAddr == addrNtProtect)
    {
        ULONG NewProtection = (ULONG)(ctx->R9);

        if (NewProtection == PAGE_EXECUTE_READ || NewProtection == PAGE_EXECUTE_READWRITE)
        {
            void* returnsAddress = NULL;
            if (CaptureStackBackTrace(1, 1, &returnsAddress, NULL) > 0)
            {
                HMODULE hMods = NULL;
                if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                    GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                    (LPCSTR)returnsAddress, &hMods))
                {
                    add_score(&global_x, 20);
                }
            }
        }
        ctx->EFlags |= (1 << 16);
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    return EXCEPTION_CONTINUE_SEARCH;
}
Обратите внимание, разберу для начала хук NtAllocate:
1769194450555.png

Наша условная конструкция проверяет, сработал ли наш брейкпоинт на функцию NtAllocateVirtualMemory или нет. Далее, буквально вторая строчка внутри блока функции, там мы получаем параметр Protect, как уже было сказано выше, мы уже проверяли этот параметр в IAT-хуке, тут будет совершенно тоже самое. Касаемо ctx->Rsp+48, дело в том, что этот параметр находится по смещению rsp+48 в стеке, таким методом мы обращаемся к нему, точнее на его адрес.
Далее самая важная проверка, мы смотрим, равен ли параметр Protect PAGE_EXECUTE_READWRITE, запрашивается ли он с правами R/W/X или нет. Если да - вызываем STATUS_ACCESS_DENIED.
Я все еще прекрасно помню, что R/W/X могут использовать отладчики и JIT компиляторы, в будущих статьях мы немного поменяем логику.
Далее на разборе NtProtect:
Такая же условная конструкция что и в предыдущем хуке.
Проверяем, сработал брейкпоинт на функцию или нет, если да, переходим в блок.
Обратите внимание ctx->R9, таким способом мы получаем 4-й параметр, отвечающие за новые права доступа(NewProtection).
4 параметр это регистр r9 согласно x64 соглашению.
1769194527898.png

Обратите на условную конструкцию if (NewProtection == PAGE_EXECUTE_READ || NewProtection == PAGE_EXECUTE_READWRITE), мы уже реализовывали подобное в IAT хуки, таким способом мы детектируем попытку установки исполняемых прав, думаю вы помните.
Далее наш любимый CaptureStackBackTrace, с которым уже сталкиваемся 3 раз, все также проверяем вызывается ли функция из .text или нет. Если нет - шеллкод, передаем 20 баллов в структуру Corecial.

Работа HWDB-хуков в новом потоке.
Помните, я говорил вам про новый поток, где малварь может выполнять свои операции? Так вот, наши хуки будут работать и в нем.
У нас есть переменная g_pThreadHandleAddr, я про нее объяснял, напомню, что - это указатель на хэндл вновь созданного потока, то-есть указатель на уже НОВЫЙ поток. Когда срабатывает брейкпоинт на адресе возврата из NtCreateThreadEx.
с:
Expand Collapse Copy
__declspec(thread) PHANDLE g_pThreadHandleAddr = NULL;

Я реализовал если так можно назвать многопоточные хуки, я уже объяснял что малварь может создать новый поток где будет выполнять свои зловредные действия, в то время, как в основном потоке будет, к примеру, работать калькулятор.
c:
Expand Collapse Copy
if (faultAddr == (PVOID)ctx->Dr2)
    {
        if (g_pThreadHandleAddr != NULL)
        {
            HANDLE hNewThread = *g_pThreadHandleAddr;

            if (hNewThread != NULL)
            {
                CONTEXT ThreadCtxNew = { 0 };
                ThreadCtxNew.ContextFlags = CONTEXT_DEBUG_REGISTERS;

                if (GetThreadContext(hNewThread, &ThreadCtxNew))
                {
                    ThreadCtxNew.Dr0 = (DWORD64)addrNtCreateThread;
                    ThreadCtxNew.Dr1 = (DWORD64)addrNtAllocate;
                    ThreadCtxNew.Dr3 = (DWORD64)addrNtProtect;
                    ThreadCtxNew.Dr7 = (1ULL << 0) | (1ULL << 2) | (1ULL << 6);
                    SetThreadContext(hNewThread, &ThreadCtxNew);
                }
            }
            g_pThreadHandleAddr = NULL;
        }
        ctx->Dr2 = 0;
        ctx->Dr7 &= ~(1ULL << 4);

        return EXCEPTION_CONTINUE_EXECUTION;
    }
    return EXCEPTION_CONTINUE_SEARCH;
}
С помощью GetThreadContext мы проверяем, установлены ли хуки, заполнены ли регистры, если нет - мы заполняем их все теми же значениями уже в НОВОМ потоке, задавая их через SetThreadContext.
Думаю, принцип работы теперь понятен.
Вообще тема HWDB-хуков довольно трудная для новичков, так что если вы, что-то не поняли, пожалуйста, гуглите, я пытаюсь объяснять максимально простым языком.

Обратите внимание на эту строчку:
с:
Expand Collapse Copy
ThreadCtxNew.Dr7 = (1ULL << 0) | (1ULL << 2) | (1ULL << 6);
Как уже было написано в статье ранее, через dr7 регистр мы активируем наши брейкпоинты
0 - dr0
2 - dr1
6 - dr3
А если еще проще: включаем их.

Далее:
c:
Expand Collapse Copy
ctx->Dr2 = 0;
ctx->Dr7 &= ~(1ULL << 4);
Мы очищаем временные структуры, еще проще: просто сбрасываем 4 бит, т.к он больше не нужен.

Теперь, обратите внимание обратно на функцию SetHardwareBreakpoint которую мы уже реализовали, теперь регистры содержат в себе адреса, эти адреса мы передаем в переменную address, которая их теперь уже хранит в себе:
с:
Expand Collapse Copy
switch (index)
        {
case 0: ctx.Dr0 = (DWORD64)address; break;
case 1: ctx.Dr1 = (DWORD64)address; break;
case 2: ctx.Dr2 = (DWORD64)address; break;
case 3: ctx.Dr3 = (DWORD64)address; break;
}

Полный код нашего VEH-обработчика:
c:
Expand Collapse Copy
LONG WINAPI HardwareBreakpointHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
    PCONTEXT ctx = ExceptionInfo->ContextRecord;
    PVOID faultAddr = ExceptionInfo->ExceptionRecord->ExceptionAddress;

    if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP)
    {
        void* returnAddress = NULL;
        if (CaptureStackBackTrace(2, 1, &returnAddress, NULL) > 0)
        {
            HMODULE hMod = NULL;
            if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPCSTR)returnAddress, &hMod))
            {
                add_score(&global_x, 20);
            }
        }
        if (faultAddr == addrNtCreateThread)
        {
            HANDLE hTargetProc = (HANDLE)ctx->R9;
            PVOID startRountine = *(PVOID*)(ctx->Rsp + 40);

            if (hTargetProc != (HANDLE)-1 && GetProcessId(hTargetProc) != GetCurrentProcessId())
            {
                return 0xC0000022;
            }
            if (hTargetProc == (HANDLE)-1 || GetProcessId(hTargetProc) == GetCurrentProcessId())
            {
                g_pThreadHandleAddr = (PHANDLE)ctx->Rcx;
                DWORD64 retAddr = *(DWORD64*)(ctx->Rsp);
                ctx->Dr2 = retAddr;
                ctx->Dr7 |= (1ULL << 4);
            }
        }
        ctx->EFlags |= (1 << 16);
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    if (faultAddr == addrNtProtect)
    {
        BOOL bs = FALSE;
        ULONG NewProtection = (ULONG)(ctx->R9);

        if (NewProtection == PAGE_EXECUTE_READ || NewProtection == PAGE_EXECUTE_READWRITE)
        {
            void* returnsAddress = NULL;
            if (CaptureStackBackTrace(1, 1, &returnsAddress, NULL) > 0)
            {
                HMODULE hMods = NULL;
                if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                    GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                    (LPCSTR)returnsAddress, &hMods))
                {
                    add_score(&global_x, 20);
                }
            }
        }
        ctx->EFlags |= (1 << 16);
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    if (faultAddr == (PVOID)ctx->Dr2)
    {
        if (g_pThreadHandleAddr != NULL)
        {
            HANDLE hNewThread = *g_pThreadHandleAddr;

            if (hNewThread != NULL)
            {
                CONTEXT ThreadCtxNew = { 0 };
                ThreadCtxNew.ContextFlags = CONTEXT_DEBUG_REGISTERS;

                if (GetThreadContext(hNewThread, &ThreadCtxNew))
                {
                    ThreadCtxNew.Dr0 = (DWORD64)addrNtCreateThread;
                    ThreadCtxNew.Dr1 = (DWORD64)addrNtAllocate;
                    ThreadCtxNew.Dr3 = (DWORD64)addrNtProtect;
                    ThreadCtxNew.Dr7 = (1ULL << 0) | (1ULL << 2) | (1ULL << 6);
                    SetThreadContext(hNewThread, &ThreadCtxNew);
                }
            }
            g_pThreadHandleAddr = NULL;
        }
        ctx->Dr2 = 0;
        ctx->Dr7 &= ~(1ULL << 4);

        return EXCEPTION_CONTINUE_EXECUTION;
    }
    return EXCEPTION_CONTINUE_SEARCH;
}
Стоит помнить, что малварь может попытаться вызвать GetThreadContext, посмотреть состояние регистров, Для защиты от этого мы ставим хук на GetThreadContext и возвращаем фейковую информацию. Когда функция вызывается с флагом CONTEXT_DEBUG_REGISTERS, мы обнуляем все debug-регистры в возвращаемой структуре Вы должны помнить последовательность реализации IAT хука. Инициализируем структуру:
С:
Expand Collapse Copy
typedef BOOL(WINAPI* PGetThreadContext)(
    HANDLE hThread,
    LPCONTEXT lpContext
    );
PGetThreadContext OriginalGetThreadContext = NULL;
Хукаем:
c:
Expand Collapse Copy
BOOL WINAPI HookGetThreadContext(HANDLE hThread, LPCONTEXT lpContext)
{
    BOOL result = OriginalGetThreadContext(hThread, lpContext);

    if (result && lpContext != NULL)
    {
        if ((lpContext->ContextFlags & CONTEXT_DEBUG_REGISTERS) == CONTEXT_DEBUG_REGISTERS)
        {
            lpContext->Dr0 = 0;
            lpContext->Dr1 = 0;
            lpContext->Dr2 = 0;
            lpContext->Dr3 = 0;
            lpContext->Dr6 = 0;
            lpContext->Dr7 = 0;
        }
    }
    return result;
}
Таким образом, малварь, проверяющая регистры через GetThreadContext, увидит, что аппаратные брейкпоинты не установлены, хотя на самом деле они активны и работают, все просто.
Важно помнить, что хук применяется только если в ContextFlags установлен CONTEXT_DEBUG_REGISTERS.

Тоже самое реализовываем с SetThreadContext, когда малварь увидит, что регистры пустые, она может подумать что это обманка и на всякий случай занулить их через SetThreadContext, но не тут то было, на ней тоже хук:
c:
Expand Collapse Copy
BOOL WINAPI HookSetThreadContext(HANDLE hThread, const CONTEXT* lpContext)
{
    if ((lpContext->ContextFlags & CONTEXT_DEBUG_REGISTERS) == CONTEXT_DEBUG_REGISTERS)
    {
        if (lpContext->Dr0 == 0 && lpContext->Dr1 == 0 && lpContext->Dr2 == 0)
        {
            add_score(&global_x, 50);
        }
    }
    return OriginalSetThreadContext(hThread, lpContext);
}
Тут тоже все довольно просто, мы создаем условную конструкцию, где проверяем, пытается ли вызывающий код установить, debug-регистры через SetThreadContext. Если флаг CONTEXT_DEBUG_REGISTERS установлен и значения регистров dr0/dr1/dr2 равны нулю, это подозрительно, скорее всего малварь может пытаться сбросить наши брейкпоинты. В таком случае мы добавляем 50 баллов к счетчику угроз, но все же разрешаем вызов оригинальной функции, чтобы не прервать работу легитимных приложений.
Реализация защиты VEH-обработчика будет во 2 части.
 

Вложения

  • 1769194211421.png
    1769194211421.png
    978.8 KB · Просмотры: 5
  • 1769194494148.png
    1769194494148.png
    387.8 KB · Просмотры: 7
Места не хватило)

Немного поговорим про эмуляцию.
Когда файл попадает на диск/запускается как процесс, дефендер принимает его, после чего данные принимает драйвер(WdFilter.sys).
Драйвер может остановить выполнение процесса/потока, для того чтобы отправить их на анализ UserMode службе дефендера которая называется - MsMpEng.exe.
После того как данная служба принимает файл, она проводит анализ состоящий из нескольких последовательностей:
1) Эмуляция, дефендер запускает файл/процесс в отдельном микро-эмуляторе чтобы сдампить память файла, с целью посмотреть на его голую архитектуру. Чаще всего малвари накрыты пакерами(UPX/какой-то индивидуальный), дефендер это придвидел, как раз таки для этого и реализуется непосредственная эмуляция.
Так вот, малвари умеют обходить подобные трюки, довольно просто.
Дело в том, что эмуляция не длится вечно (обычно это несколько миллионов инструкций или ограничение по времени в миллисекундах). Если пакер слишком сложный или долгий, эмулятор может сдаться, не дойдя до OEP.
Малварь может просто запустить бессмысленный цикл 1кк тактов, эмуляция упадет.

А есть способ поинтереснее, как мы знаем в эмуляции ядро-процессора составляет 1-2.
Малварь может проверить через GetSystemInfo количество ядер процессора через условную конструкцию с обращением к структуре SYSTEM_INFO
c:
Expand Collapse Copy
SYSTEM_INFO si;
GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) {
    exit(0);
}

Все просто, малварь обращается к структуре, а именно к dwNumberOfProccesors и проверяет, меньше ли двух ядер процессора или нет, если условие являвется истиной - остановка.
Обмануть малварь довольно просто, мы можем показать ей дизинформацию, для этого нужно просто поставить хук на функцию GetSystemInfo:
c:
Expand Collapse Copy
void WINAPI HookGetSystemInfo(LPSYSTEM_INFO lpSystemInfo)
{
    pGetSystemInfoOriginal(lpSystemInfo);

    if (lpSystemInfo->dwNumberOfProcessors < 2)
    {
        lpSystemInfo->dwNumberOfProcessors = 8;
    }
}
В этом случае мы сначала вызываем оригинальную функцию(pGetSystemInfoOriginal), потом создаем условную конструкцию где обращаемся к dwNumberOfProcessors, сравниваем с двойкой и внутри блока выводим информацию о том, что ядер - 8.
Малварь подумает что она всемогущая и смогла обойти эмуляцию, но на самом деле тут ей мягко-говоря приходит п#зда.

Всех благодарю за прочтение данной статьи, жду обоснованную критику, ведь критика это саморазвитие.
 
Последнее редактирование:
Назад
Сверху Снизу