Содержание статьи
warning
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
Как устроены WinAPI Indirect Calls
Зачем изобретать что‑то новое, если есть такая техника, как Indirect Calls для вызовов WinAPI? У нее есть свои сильные стороны, она проста в реализации и хорошо документирована. В двух словах напомню, в чем ее суть.
Indirect Syscalls вызывает NTAPI не напрямую через ntdll.
, а через собственный код, который эмулирует вызов функции NTAPI: формируется стек аргументов, в eax
заносится номер системного сервиса для его вызова, далее выполняется переход по адресу инструкции syscall
в самой ntdll.
. Вот условный код:
// Ранее сформирован стек аргументов, далее сам вызов WinAPI из ntdll.dll
mov r10, rcx
mov eax, <syscall_number>
jmp <address_of_syscall_instruction_in_ntdll>
Со стороны выглядит хорошо, и это работало, пока EDR не научились использовать трассировку событий ETW для раскрутки системного стека вызовов. Все дело в том, что техника Indirect Syscalls позволяет избежать детектирования на уровне пользовательского режима, но оставляет следы в стеке вызовов. EDR может анализировать стек, раскручивая его, и обнаруживать эти аномалии.
Стек, фреймы и EDR
Для дальнейшего понимания придется немного погрузиться в матчасть и понять, как формируются и работают стековые фреймы. Стековый фрейм (или кадр стека) — это область памяти в стеке, выделяемая для выполнения каждой функции в программе. Он содержит такие данные, как параметры функции, локальные переменные, значения регистров, адрес возврата. Простой пример кода:
void func1() { int x = 10; func2(x); ... ...}void func2(int y) { int z = y + 5;}
При вызове func2(
будет сформирован фрейм:
-
RSP
— переменнаяz
; -
RBP
— базовый указатель предыдущего фрейма; -
RBP
— указатель на следующую инструкцию в+ 8 func1(
(адрес возврата);) -
RBP
— параметр функции+ 16 func2
.
Продолжение статьи доступно только продписчикам
Итак, стало ясно, что стековые фреймы формируются при вызове каждой функции и содержат массу полезной информации, в том числе указатель на предыдущий фрейм (считай, функцию). Именно так EDR и разворачивают поток управления, если замечают подозрительное поведение в системе. Как ты понимаешь, против этой техники обнаружения не спасут популярные методы обфускации вызовов WinAPI, но мы попробуем подделать фрейм, чтобы обмануть EDR.
info
Event Tracing for Windows (ETW) — это технология, встроенная в Windows, которая позволяет собирать и записывать данные о событиях из различных системных компонентов и приложений. ETW предоставляет эффективные средства для телеметрии, включая сбор информации о стеке вызовов, что особенно полезно для диагностики, мониторинга производительности и анализа безопасности.
Как 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.
. Что происходит, если выполняется ручной вызов 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.
, но стек начинается из пользовательской памяти, — алерт!
Оказывается, косвенные вызовы не так сложно обнаружить, как могло казаться. Что ж, будем придумывать лекарство!
Запускаем NTAPI без следов
В одной из предыдущих статей я рассказывал об инъекции кода методом PoolParty, который использовал механизм Windows Thread Pools. Здесь я покажу вызов NTAPI при помощи того же механизма. Надо сказать, что это не единственный механизм Windows, при помощи которого можно проксировать вызовы NTAPI, но один из более‑менее изученных.
Функции TpAllocWork
, TpPostWork
и TpReleaseWork
, которые нам интересны, используются для управления задачами в Windows Thread Pools. Эти функции позволяют создавать, запускать и освобождать задачи, которые обрабатываются пулом потоков Windows, что облегчает параллельное выполнение кода. Разумеется, эти функции полностью недокументированные и находятся в ntdll.
. Их мы и используем для вызова NTAPI в обход мониторинга стека. Для этого посмотрим на прототипы.
Функция TpAllocWork
создает новое задание, которое может быть выполнено пулом потоков. При создании задания нужно указать колбэк‑функцию, которая будет выполнена, когда пул потоков начнет обработку этого задания.
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
.
void TpPostWork( PTP_WORK Work);
Единственный параметр PTP_WORK
— указатель на рабочий объект пула потоков, который был создан с помощью TpAllocWork
.
Последняя функция, TpReleaseWork
, освобождает объект задания. Она необходима, чтобы освободить ресурсы, выделенные для задания, созданного с помощью TpAllocWork
. Если задание находится в очереди или выполняется, вызов TpReleaseWork
дождется завершения задания, а затем освободит ресурсы.
void TpReleaseWork( PTP_WORK Work);
Как и в предыдущей функции, здесь всего один аргумент — PTP_WORK
.
Вызов API с применением этих функций будет формироваться таким образом:
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
— указатель на ассемблерную вставку, которая и сделает сам вызов NTAPI, а PVOID
— это пакет аргументов вызываемой функции.
Пакет аргументов формируется в виде обычной структуры, содержащей параметры функции. Небольшое исключение — первым аргументом в структуре будет указатель на NtAllocateVirtualMemory
. На нашем примере это происходит таким образом:
// Структура аргументов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 ; Сам вызов NtAllocateVirtualMemoryNtAllocateVirtualMemoryCallback endp jmp rax
Не забываем, что из кода на C++ функцию ассемблера можно вызвать, объявив ее при помощи extern
в одном из заголовков.
extern "C" VOID CALLBACK NtAllocateVirtualMemoryCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
После этого можно просто вызвать NtAllocateVirtualMemory(
, и стек будет выглядеть полностью чистым, потому что вызов функции возьмет на себя сам механизм Windows Thread Pools.

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