Тихий вызов. Маскируем вызовы NTAPI от средств защиты

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Все чаще системы EDR стали прибегать к такой могучей технике, как трассировка стека вызовов, чтобы обнаруживать деятельность зловредных приложений и портить жизнь редтимерам.
В этой статье я расскажу, как работает этот метод, а потом попробуем сыграть в прятки с EDR и вызвать NTAPI так, чтобы даже раскрутка стека не обнаружила подвоха.

Как устроены WinAPI Indirect Calls​

Зачем изобретать что‑то новое, если есть такая техника, как Indirect Calls для вызовов WinAPI? У нее есть свои сильные стороны, она проста в реализации и хорошо документирована. В двух словах напомню, в чем ее суть.
Indirect Syscalls вызывает NTAPI не напрямую через ntdll.dll, а через собственный код, который эмулирует вызов функции NTAPI: формируется стек аргументов, в eax заносится номер системного сервиса для его вызова, далее выполняется переход по адресу инструкции syscall в самой ntdll.dll. Вот условный код:

// Ранее сформирован стек аргументов, далее сам вызов WinAPI из ntdll.dll
Код: Скопировать в буфер обмена
Код:
mov r10, rcx
mov eax, <syscall_number>
jmp <address_of_syscall_instruction_in_ntdll>
Со стороны выглядит хорошо, и это работало, пока EDR не научились использовать трассировку событий ETW для раскрутки системного стека вызовов. Все дело в том, что техника Indirect Syscalls позволяет избежать детектирования на уровне пользовательского режима, но оставляет следы в стеке вызовов. EDR может анализировать стек, раскручивая его, и обнаруживать эти аномалии.

Стек, фреймы и EDR​

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

C: Скопировать в буфер обмена
Код:
void func1() {
    int x = 10;
    func2(x);
    ...
    ...
}

void func2(int y) {
    int z = y + 5;
}
При вызове func2(x) будет сформирован фрейм:
  • RSP — переменная z;
  • RBP — базовый указатель предыдущего фрейма;
  • RBP + 8 — указатель на следующую инструкцию в func1() (адрес возврата);
  • RBP + 16 — параметр функции func2.
Итак, стало ясно, что стековые фреймы формируются при вызове каждой функции и содержат массу полезной информации, в том числе указатель на предыдущий фрейм (считай, функцию). Именно так EDR и разворачивают поток управления, если замечают подозрительное поведение в системе.
Как ты понимаешь, против этой техники обнаружения не спасут популярные методы обфускации вызовов WinAPI, но мы попробуем подделать фрейм, чтобы обмануть EDR.

Как EDR наблюдает за стеком​

Все дело в том, что EDR знает, как должен выглядеть стек при той или иной ситуации, и, если в его структуре наблюдается отклонение от нормы, срабатывает алерт. Например, EDR проверяет:
  • откуда был выполнен переход к syscall. Если переход был выполнен из региона памяти, который не принадлежит ntdll.dll (например, куча или выделенная память), это считается аномалией;
  • права доступа к региону памяти, из которого был выполнен переход. Если регион имеет права PAGE_EXECUTE_READWRITE, это может быть признаком косвенного вызова или шелл‑кода;
  • куда вернется выполнение после завершения системного вызова. Если возвращаемый адрес указывает на пользовательскую память, это тоже считается аномалией.
Теперь посмотрим, как выглядит правильная с точки зрения EDR структура стека вызовов при аллокации памяти из пользовательского приложения:

Код: Скопировать в буфер обмена
Код:
0x00007ffb`12345678  ntdll!NtAllocateVirtualMemory
0x00007ffb`12345555  kernel32!VirtualAlloc
0x00007ffb`12345432  MyProgram!Main

Здесь приложение MyProgram из функции main выделяет память при помощи VirtualAlloc, которая вызывает NTAPI NtAllocateVirtualMemory из ntdll.dll. Что происходит, если выполняется ручной вызов NTAPI (собственноручное формирование стека и регистров для вызова)? Программа выполняет косвенный вызов:

Код: Скопировать в буфер обмена
Код:
mov r10, rcx
// Номер NtAllocateVirtualMemory в сервисной таблице
mov eax, 0x18
// Вызов сискола по адресу в ntdll.dll
jmp 0x00007ffb`12345678

EDR захватывает стек вызовов:
Код: Скопировать в буфер обмена
Код:
0x00007ffb`12345678  ntdll!NtAllocateVirtualMemory
// Адрес в пользовательской памяти (где мы формировали аргументы), откуда был выполнен переход к сисколу внутри ntdll.dll
0x000001a3`45678901  [RX Region]
0x00007ffb`12345432  MyProgram!Main

Мы видим, что возвращаемый адрес указывает на ntdll.dll, но стек начинается из пользовательской памяти, — алерт!
Оказывается, косвенные вызовы не так сложно обнаружить, как могло казаться. Что ж, будем придумывать лекарство!

Запускаем NTAPI без следов​

В одной из предыдущих статей я рассказывал об инъекции кода методом PoolParty, который использовал механизм Windows Thread Pools. Здесь я покажу вызов NTAPI при помощи того же механизма. Надо сказать, что это не единственный механизм Windows, при помощи которого можно проксировать вызовы NTAPI, но один из более‑менее изученных.

Функции TpAllocWork, TpPostWork и TpReleaseWork, которые нам интересны, используются для управления задачами в Windows Thread Pools. Эти функции позволяют создавать, запускать и освобождать задачи, которые обрабатываются пулом потоков Windows, что облегчает параллельное выполнение кода. Разумеется, эти функции полностью недокументированные и находятся в ntdll.dll. Их мы и используем для вызова NTAPI в обход мониторинга стека. Для этого посмотрим на прототипы.

Функция TpAllocWork создает новое задание, которое может быть выполнено пулом потоков. При создании задания нужно указать колбэк‑функцию, которая будет выполнена, когда пул потоков начнет обработку этого задания.

C: Скопировать в буфер обмена
Код:
PTP_WORK TpAllocWork(
    PTP_WORK_CALLBACK WorkCallback,
    PVOID Context,
    PTP_CALLBACK_ENVIRON CallbackEnviron
);
Параметры:

  • WorkCallback — указатель на функцию обратного вызова, которая будет вызвана для выполнения задания;
  • Context — указатель на пользовательский контекст, который передается в функцию обратного вызова;
  • CallbackEnviron — указатель на структуру TP_CALLBACK_ENVIRON, которая настраивает окружение для задания. Этот параметр может быть nullptr, чтобы использовать среду по умолчанию.
Отработав, TpAllocWork возвращает указатель на PTP_WORK (объект работы пула потоков) при успехе или nullptr при ошибке.

Далее идет функция TpPostWork, которая ставит созданное задание в очередь пула потоков для выполнения. Эта функция активирует выполнение задания, созданного с помощью TpAllocWork.

C: Скопировать в буфер обмена
Код:
void TpPostWork(
    PTP_WORK Work
);
Единственный параметр PTP_WORK Work — указатель на рабочий объект пула потоков, который был создан с помощью TpAllocWork.

Последняя функция, TpReleaseWork, освобождает объект задания. Она необходима, чтобы освободить ресурсы, выделенные для задания, созданного с помощью TpAllocWork. Если задание находится в очереди или выполняется, вызов TpReleaseWork дождется завершения задания, а затем освободит ресурсы.

C: Скопировать в буфер обмена
Код:
void TpReleaseWork(
    PTP_WORK Work
);
Как и в предыдущей функции, здесь всего один аргумент — PTP_WORK Work.

Вызов API с применением этих функций будет формироваться таким образом:

C: Скопировать в буфер обмена
Код:
typedef NTSTATUS(NTAPI* ALLOCWORK)(PTP_WORK* ptpWork, PTP_WORK_CALLBACK ptpCallback, PVOID arg, PTP_CALLBACK_ENVIRON CallbackEnv);
typedef VOID(NTAPI* POSTWORK)(PTP_WORK);
typedef VOID(NTAPI* RELEASEWORK)(PTP_WORK);


...


VOID MakeCall(PTP_WORK_CALLBACK work_callback, PVOID args) {
    PTP_WORK ptpWork = NULL;


    ((TPALLOCWORK)ptr_TpAllocWork)(&ptpWork, (PTP_WORK_CALLBACK)work_callback, args, NULL);
    ((TPPOSTWORK)ptr_TpPostWork)(ptpWork);
    ((TPRELEASEWORK)ptr_TpReleaseWork)(ptpWork);


    WaitForSingleObject((HANDLE)-1, 0x1000);
}
Здесь PTP_WORK_CALLBACK work_callback — указатель на ассемблерную вставку, которая и сделает сам вызов NTAPI, а PVOID args — это пакет аргументов вызываемой функции.

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

C: Скопировать в буфер обмена
Код:
// Структура аргументов
typedef struct _ZWALLOCATEVIRTUALMEMORY_ARG {
    // Первый аргумент структуры — указатель на функцию NtAllocateVirtualMemory
    UINT_PTR    pNtAllocateVirtualMemory;
    HANDLE      ProcessHandle;
    PVOID*      BaseAddress;
    ULONG_PTR   ZeroBits;
    PSIZE_T     RegionSize;
    ULONG       AllocationType;
    ULONG       Protect;
} ZWALLOCATEVIRTUALMEMORY_ARG, * PZWALLOCATEVIRTUALMEMORY_ARG;


...


PVOID NtAllocateVirtualMemory(HANDLE hProcess) {
    PVOID alloc_addr = NULL;
    SIZE_T alloc_size = 0x1000;


    ZWALLOCATEVIRTUALMEMORY_ARG AllocateVirtualMemory_Arg = { 0 };
        // Здесь получили указатель на NtAllocateVirtualMemory
    AllocateVirtualMemory_Arg.pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(GetModuleHandleA("ntdll"), "NtAllocateVirtualMemory");
    AllocateVirtualMemory_Arg.ProcessHandle = hProcess;
    AllocateVirtualMemory_Arg.BaseAddress = &alloc_addr;
    AllocateVirtualMemory_Arg.ZeroBits = 0;
    AllocateVirtualMemory_Arg.RegionSize = &alloc_size;
    AllocateVirtualMemory_Arg.AllocationType = MEM_RESERVE;
    AllocateVirtualMemory_Arg.Protect = PAGE_EXECUTE_READWRITE;


    MakeCall((PTP_WORK_CALLBACK)NtAllocateVirtualMemoryCallback, &AllocateVirtualMemory_Arg);


    return alloc_addr;
}
Сам work_callback написан на MASM, и его задача — разбирать аргументы, формировать стек для NtAllocateVirtualMemory и делать сам вызов функции. В x64 аргументы в функцию передаются так: первые четыре аргумента через регистры rcx, rdx, r8, r9, а если аргументов больше, то следующие передаются через стек с применением регистра rsp.

Код: Скопировать в буфер обмена
Код:
NtAllocateVirtualMemoryCallback proc
    mov rbx, rdx
    ; Возьмем адрес NTAPI из структуры
    mov rax, [rbx]
    ; Получим все оставшиеся ее аргументы
    mov rcx, [rbx + 8]
    mov rdx, [rbx + 10h]
    xor r8, r8
    mov r9, [rbx + 18h]
    mov r10, [rbx + 20h]
    mov [rsp + 30h], r10
    mov r10, 1000h
    mov [rsp + 28h], r10
    ; Сам вызов NtAllocateVirtualMemory
NtAllocateVirtualMemoryCallback endp
    jmp rax
Не забываем, что из кода на C++ функцию ассемблера можно вызвать, объявив ее при помощи extern в одном из заголовков.

C: Скопировать в буфер обмена
extern "C" VOID CALLBACK NtAllocateVirtualMemoryCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
После этого можно просто вызвать NtAllocateVirtualMemory(HANDLE hProcess), и стек будет выглядеть полностью чистым, потому что вызов функции возьмет на себя сам механизм Windows Thread Pools.

1742752944323.png


Ничто в стэке не указывает на нас

Выводы​

Сегодня ты узнал, что такое раскрутка стека, как она помогает обнаруживать выполнение злонамеренного кода и как можно этот механизм обнаружения запутать. Что примечательно, в этом случае сама Windows помогла нам справиться с обнаружением.
Можно ли этот метод считать серебряной пулей? Конечно, нет! Как только EDR станут следить за колбэками в интересующих ее функциях, эта тайна раскроется. Но, как и всегда, борьба щита и меча продолжается!

Автор: Nik Zerof
Источник: xakep.ru
 
Сверху Снизу