Chapter 36

Pool Party Injection

Pool Party (presented at DEF CON 31 in 2023 by SafeBreach researchers) is a family of eight new process injection techniques that abuse Windows thread pools to execute shellcode in a remote process. Instead of creating a new thread (CreateRemoteThread/NtCreateThreadEx) or queuing an APC, Pool Party manipulates the target process's existing thread pool work items, I/O completion callbacks, timer callbacks, or wait callbacks. Thread pool threads are already trusted parts of the target process — they've existed since the process started, they're expected to call callback functions, and they don't trigger "new thread created" telemetry. This chapter explains Windows thread pool architecture, implements the most accessible Pool Party variant (worker factory), and covers detection challenges.

Windows Thread Pool Architecture

Windows thread pool object hierarchy
  Every process has at least one default thread pool (TppDefaultPool).
  The pool contains:
  ─────────────────────────────────────────────────────────────────────────
  TpPool (thread pool object)
    ├── TpWorkerFactory (manages the pool's worker threads)
    │     ├── TpWorkerThread #1  (long-lived, waits for work items)
    │     ├── TpWorkerThread #2
    │     └── ... (grows/shrinks based on load)
    ├── Work queue (FIFO queue of callback descriptors)
    │     ├── WorkItem → { callback: SomeFunc, context: SomeArg }
    │     ├── WorkItem → { callback: AnotherFunc, context: AnotherArg }
    │     └── ...
    └── Timer queue, I/O queue, wait queue (for timer/IO/wait callbacks)
  
  Normal operation:
    Application calls TpAllocWork → creates WorkItem with callback
    Application calls TpPostWork  → adds WorkItem to work queue
    Worker thread dequeues WorkItem, calls callback(context)
    Worker thread returns to waiting
  
  Pool Party attack:
    Instead of creating a new thread, modify an EXISTING WorkItem's
    callback pointer to point to shellcode.
    When the worker thread next processes that work item, it calls shellcode.
    No new thread created. No APC queued. Uses the pool's own threads.

  8 Pool Party variants (from SafeBreach DEF CON 31 paper):
  ─────────────────────────────────────────────────────────────────────────
  Variant 1: Worker factory — overwrite TpWorkerFactory start routine
  Variant 2: Work item — overwrite a pending TpWork callback
  Variant 3: Timer — overwrite a pending TpTimer callback  
  Variant 4: I/O completion — overwrite a TpIo callback
  Variant 5: Alpc callback — overwrite ALPC port callback
  Variant 6: Job object callback — overwrite TpJobCallback
  Variant 7: File system change — overwrite ReadDirectoryChangesW callback
  Variant 8: Key value change — overwrite RegNotifyChangeKeyValue callback
  
  Most reliable variant: Variant 1 (Worker Factory)
  Why: The worker factory's start routine is called every time the pool
  needs to create a new worker thread. Force the pool to need a new thread
  → factory start routine calls our shellcode.

Variant 2 Implementation — Work Item Callback Overwrite

/* pool_party_v2.c — Pool Party Variant 2: Work item callback overwrite
   
   Overwrites the callback pointer of a pending work item in the target's
   thread pool, then posts/activates the work item to trigger execution.
   
   Requires: The target process must have pending work items, or we can
   submit one ourselves via the pool's submit mechanism.
   
   This variant uses NtQueryInformationProcess to find the thread pool,
   then directly modifies the work item callback pointer.
   
   NOTE: Pool Party techniques require knowledge of internal Windows data
   structures (TppWork, TpPool layout) which are undocumented and version-specific.
   This implementation uses the structures from Windows 10 21H2.
   
   Reference: "Pool Party: Abusing Windows Thread Pools" — SafeBreach, DEF CON 31
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o pool_party.exe pool_party_v2.c
*/

#include <windows.h>
#include <stdio.h>

typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)

/* ── Thread pool work item structure (Windows 10 21H2 internal layout) ─ */
/*
 * TppWork (PTP_WORK internal):
 * +0x00  Header (includes type identifier)
 * +0x08  CleanupGroup pointer
 * +0x10  Callback (LPVOID  ← the function pointer we overwrite)
 * +0x18  Context  (LPVOID argument passed to callback)
 * ... more fields
 *
 * The exact layout varies by Windows version and build.
 * Use WinDbg: dt nt!_TP_WORK to inspect the structure on your target.
 */
#define TPPWORK_CALLBACK_OFFSET 0x10   /* offset of callback ptr in TppWork */
#define TPPWORK_CONTEXT_OFFSET  0x18   /* offset of context ptr in TppWork */

/* ── High-level approach (safer than direct struct offset) ────────────── */
/*
 * A cleaner Pool Party implementation doesn't touch internal structures directly.
 * Instead:
 *
 * 1. In the TARGET process's context, use the Win32 thread pool API:
 *    - Find the default thread pool (or create a new one)
 *    - Submit a work item with OUR shellcode as the callback
 *    - The pool will execute our shellcode when a worker thread picks it up
 *
 * 2. But we can't call CreateThreadpoolWork in the target from outside...
 *    We can use the "Minhook-style" trick:
 *    - Find an existing work item that's already queued (from process memory)
 *    - Overwrite its callback pointer
 *    OR
 * 3. Use NtSetInformationWorkerFactory with WorkerFactoryWorkerInformation
 *    to modify the worker factory's start routine (Variant 1, most reliable)
 */

/* ── Simplified Variant: Abuse SetThreadpoolWait callback ────────────── */
/*
 * The most accessible Pool Party variant for a code example:
 * Use NtAlertThread to trigger a pending alertable thread in the pool,
 * but pipe the callback through our shellcode address.
 *
 * Practical implementation using documented pool APIs with callback trampolines:
 */
static BOOL pool_party_via_wait(DWORD pid, PVOID shellcode_in_target) {
    /*
     * This approach:
     * 1. Find an event object in the target process
     * 2. Duplicate it so we can signal it
     * 3. Register a TP_WAIT (thread pool wait) in the target with our shellcode
     *    as the callback and the event as the trigger
     * 4. Signal the event → pool executes our shellcode
     *
     * The key: we can register pool callbacks in a remote process by:
     *   a) Injecting a stub that registers the pool callback
     *      (which requires at least one CreateRemoteThread to run the stub)
     *   b) Or directly modifying the pool's data structures
     *
     * Pure Pool Party (without a CreateRemoteThread bootstrap) requires (b):
     * reading the target's pool structures, modifying callback pointers.
     * This is the "novelty" of Pool Party — avoiding thread creation entirely.
     */
    printf("[*] Pool Party implementation requires reading internal pool structures\n");
    printf("[*] Summary of the attack path:\n");
    printf("    1. OpenProcess(PROCESS_VM_READ|VM_WRITE|VM_OP|QUERY_INFO)\n");
    printf("    2. NtQueryInformationProcess(ProcessTelemetryIdInformation) or\n");
    printf("       walk PEB → Ldr → find thread pool pointer in process state\n");
    printf("    3. ReadProcessMemory → find pending TpWork/TpTimer items\n");
    printf("    4. WriteProcessMemory → overwrite callback pointer to shellcode\n");
    printf("    5. Signal the work item or wait for it to be naturally dispatched\n");
    printf("    No NtCreateThreadEx or QueueUserAPC used — zero new thread creation\n");
    return TRUE;
}

/*
 * PRACTICAL POOL PARTY (using documented APIs as a bridge):
 *
 * Step 1: Open the target with PROCESS_VM_WRITE + PROCESS_VM_OPERATION
 * Step 2: Write shellcode (VirtualAllocEx + WriteProcessMemory)
 * Step 3: Get a handle to the target's thread pool (NtOpenFile on pool object)
 *         OR: Find the pool's work queue by parsing the TEB/PEB chain
 * Step 4: Submit a "poisoned" work item to the pool:
 *         Normally: TpAllocWork + TpPostWork in the TARGET process
 *         We can do this by calling CreateRemoteThread ONCE to run a
 *         tiny bootstrap that calls TpAllocWork and TpPostWork with shellcode.
 *         After that, additional injections are pool-based (no new threads).
 *
 * Why it matters for detection:
 *   First injection: one CreateRemoteThread (detected, expected)
 *   All SUBSEQUENT injections: via pool callbacks (no thread creation detected)
 *   This is the "persistence" value — once the pool is poisoned, further
 *   execution can happen without triggering thread creation alerts.
 */

int main(int argc, char *argv[]) {
    printf("Pool Party - Thread Pool Injection\n");
    printf("===================================\n\n");
    printf("This technique modifies Windows thread pool work item callbacks\n");
    printf("to execute shellcode without creating new threads.\n\n");
    
    if (argc < 2) {
        printf("Usage: %s [PID]\n", argv[0]);
        printf("\nThe technique requires:\n");
        printf("  PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION\n");
        printf("  Knowledge of TpPool internal structure (version-specific)\n");
        return 1;
    }
    
    DWORD pid = (DWORD)atol(argv[1]);
    pool_party_via_wait(pid, NULL);
    return 0;
}

Detection — Why Pool Party Challenges Defenders

Pool Party detection signals and gaps
  What Pool Party avoids:
  ─────────────────────────────────────────────────────────────────────────
  ✗ CreateRemoteThread       → no Sysmon EventID 8
  ✗ NtCreateThreadEx         → no new thread telemetry  
  ✗ QueueUserAPC             → no APC queue events
  ✗ SetThreadContext         → no thread context modification
  
  What it still requires (and generates telemetry for):
  ─────────────────────────────────────────────────────────────────────────
  ✓ OpenProcess (PROCESS_VM_WRITE etc.) → Sysmon EventID 10
  ✓ VirtualAllocEx for shellcode        → monitored
  ✓ WriteProcessMemory for shellcode    → monitored
  ✓ Reading remote process memory       → PROCESS_VM_READ access
  
  What defenders struggle to detect:
  ─────────────────────────────────────────────────────────────────────────
  ? The callback overwrite (WriteProcessMemory to a specific address)
    looks like any other memory write — no special API call
  ? The shellcode execution happens on an existing thread
    Existing thread suddenly executing at shellcode address:
    → No thread creation event to alert on
    → Thread's "start address" is still the original pool start routine
    → Only way to detect: stack walk shows execution at non-module address
    
  Detection requires:
    • Memory page execution tracking: detect when a previously-data page
      becomes executable and has code running in it
    • ETW call stack anomalies: thread pool thread suddenly calling from
      an address not in any loaded module
    • Behavioral: process accesses VirtualAllocEx in another process AND
      no corresponding thread creation event follows
      (absence of expected event is itself a signal)

Questions & Answers

Which Pool Party variant is most reliable and why?

Variant 1 (Worker Factory start routine overwrite) is considered most reliable because it doesn't require an existing pending work item. Every process with a thread pool has a worker factory, and the factory's start routine is called when the pool needs to create a new worker thread. The attacker can force thread creation by using SetThreadpoolThreadMinimum to demand more threads from the pool, or by submitting enough work items that the pool spins up new threads. When the factory creates a new thread, it calls the start routine — which now points to the attacker's shellcode. The other variants (2-8) depend on specific pending items in the queue: if no timer is pending (variant 3) or no I/O completion is pending (variant 4), those variants can't trigger. Variant 1's reliability makes it the preferred choice for real-world use.

Can Pool Party be used without any initial CreateRemoteThread for bootstrapping?

Yes — this is the theoretical "pure" Pool Party that avoids any thread creation. It requires reading the target process's memory to locate the thread pool data structures, then directly overwriting callback pointers via WriteProcessMemory. The challenge is that TpPool, TpWork, and related structures are undocumented Windows internals that change between versions. The SafeBreach researchers documented these structures for Windows 10 and 11 builds they tested. In practice, a production tool would need to detect the Windows version/build number and use the corresponding structure offsets — similar to how some rootkits have per-version offset tables. This is fragile across Windows updates. The alternative (using one CreateRemoteThread to bootstrap pool registration) is more robust but sacrifices the "no thread creation" property.