DPCs: Deferred Procedure Calls and Interrupt Deferral

Objective: Understand how the Windows kernel uses Deferred Procedure Calls (DPCs) to move work out of high-IRQL interrupt service routines down to DISPATCH_LEVEL, covering the KDPC structure, IRQL mechanics, the full queue-to-callback lifecycle, threaded and timer DPCs, the DPC watchdog, and how defenders detect kernel-mode abuse of the DPC mechanism.


1. The Interrupt Deferral Problem

When a hardware device raises an interrupt, the kernel dispatches to an Interrupt Service Routine (ISR) running at DIRQL — a device IRQL higher than the scheduler itself. At that level the processor cannot wait, cannot touch pageable memory, and blocks all lower-priority interrupts on that CPU. An ISR that lingers degrades the entire system; the guidance is that ISRs should not run longer than 25 microseconds.

Windows therefore uses a two-phase interrupt model. The ISR does the minimum work needed to quiesce the device (acknowledge the interrupt, snapshot status), then schedules a Deferred Procedure Call to perform the heavier processing later, at a gentler IRQL. The DPC executes at DISPATCH_LEVEL, which is still too high for anything that touches pageable memory — but it is low enough to run the bulk of device servicing without starving other interrupts.

The essence of the DPC is deferring execution to gentler circumstances. It is the kernel’s primary tool for keeping ISRs short.


2. IRQL Levels: A Precise Map

The Interrupt Request Level (IRQL) is a per-processor priority that determines what code may run and what it may do. Any routine running at DISPATCH_LEVEL or above is not preemptable, runs to completion, and must reside in non-paged memory.

IRQL NameValueNotes
PASSIVE_LEVEL0Normal user/kernel thread execution; paging and waiting allowed
APC_LEVEL1Asynchronous Procedure Calls
DISPATCH_LEVEL2DPC execution, scheduler, spin locks; no paging, no waiting
DIRQL3–11 (device-dependent)Hardware ISRs run here

An ISR at DIRQL cannot call functions that require PASSIVE_LEVEL. It instead schedules a DPC, which the kernel later runs at DISPATCH_LEVEL. Because DISPATCH_LEVEL still forbids page faults and blocking waits, a DPC routine and all data it touches must be non-paged.


Hierarchy diagram showing Windows IRQL levels from DIRQL at the top down through DISPATCH_LEVEL where DPCs run, APC_LEVEL, and PASSIVE_LEVEL at the bottom, with arrows showing how ISRs queue DPCs that drain at DISPATCH_LEVEL
The IRQL ladder: ISRs fire at DIRQL and defer heavy work via DPCs, which the kernel drains at DISPATCH_LEVEL before returning to lower IRQLs.

3. The KDPC Structure Dissected

The KDPC is the structure in which the kernel keeps the state of a Deferred Procedure Call. It has always been explicitly undocumented — Microsoft labels it an opaque structure and warns drivers not to set members directly. The published layout from WDK/OSR headers is:

typedef struct _KDPC {
    UCHAR                 Type;            // DpcObject or ThreadedDpcObject
    UCHAR                 Importance;      // Low / Medium / High
    USHORT                Number;          // target processor (directed DPCs)
    LIST_ENTRY            DpcListEntry;    // links into per-processor DPC queue
    PKDEFERRED_ROUTINE    DeferredRoutine; // pointer to the callback function
    PVOID                 DeferredContext; // driver-supplied context value
    PVOID                 SystemArgument1; // extra arg passed to callback
    PVOID                 SystemArgument2; // extra arg passed to callback
    __volatile PVOID      DpcData;         // internal; pointer to KDPC_DATA
} KDPC, *PKDPC, *PRKDPC;
FieldPurpose
TypeDistinguishes a normal DpcObject from a ThreadedDpcObject
ImportanceControls queue insertion: MediumImportance = tail, HighImportance = head
NumberTarget logical processor, set via KeSetTargetProcessorDpc
DeferredRoutinePointer to the KDEFERRED_ROUTINE callback
DeferredContextOpaque context the driver receives back in the callback
SystemArgument1/2Caller-supplied arguments passed through to the callback
DpcDataVolatile internal pointer to the per-processor KDPC_DATA; non-NULL while queued

The DpcData field is the kernel’s bookkeeping anchor: before Windows 8.1 it pointed directly at a KDPC_DATA structure, and its non-NULL state indicates the DPC is currently queued. Because DeferredRoutine is a raw function pointer inside a writable structure, it is also a corruption target — covered in §10.


4. The DPC Lifecycle: From ISR to Callback

A DPC moves through four stages: allocate → initialize → queue → drain.

API FunctionPurpose
KeInitializeDpcInitializes a KDPC, binding a DeferredRoutine and DeferredContext
KeInsertQueueDpcInserts the KDPC into the per-processor queue; returns FALSE if already queued
IoRequestDpcConvenience wrapper called from ISR context for the DpcForIsr pattern
KeRemoveQueueDpcRemoves a pending (not-yet-fired) DPC from the queue

Kernel code first allocates a KDPC in non-paged pool (or the device extension) so the object is resident when referenced from the ISR.

// C1 — allocate and initialize a DPC object
PKDPC pDpc = ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(KDPC), 'cpDD');
if (pDpc) {
    KeInitializeDpc(pDpc, MyCustomDpc, DeviceContext);  // routine + context
}

The callback must match the KDEFERRED_ROUTINE signature and runs at DISPATCH_LEVEL:

// C2 — DPC callback stub
VOID MyCustomDpc(
    _In_     PKDPC Dpc,
    _In_opt_ PVOID DeferredContext,
    _In_opt_ PVOID SystemArgument1,
    _In_opt_ PVOID SystemArgument2)
{
    UNREFERENCED_PARAMETER(Dpc);
    ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);   // invariant
    // Non-paged, bounded work only — no waits, no page faults.
}

The ISR queues the DPC. The return value of KeInsertQueueDpc enforces the single-instantiation guarantee: only one instance of a given KDPC can be queued at a time, so queuing it twice before it fires runs the routine once.

// C3 — queue from a mock ISR
BOOLEAN queued = KeInsertQueueDpc(pDpc, Arg1, Arg2);
if (!queued) {
    // Already pending on a queue — the earlier request still stands.
}

Device drivers commonly use the wrapper from inside their InterruptService routine:

// C4 — DpcForIsr pattern
BOOLEAN MyIsr(_In_ PKINTERRUPT Interrupt, _In_ PVOID Context) {
    PDEVICE_OBJECT devObj = (PDEVICE_OBJECT)Context;
    // ...acknowledge hardware quickly...
    IoRequestDpc(devObj, devObj->CurrentIrp, NULL);  // schedules DpcForIsr
    return TRUE;
}

When the processor returns from the interrupt, it checks its DPC queue; if entries are pending, the kernel raises IRQL to DISPATCH_LEVEL, drains the queue by invoking each DeferredRoutine, then lowers IRQL back down.


Flow diagram showing the four-stage DPC lifecycle: allocate KDPC in non-paged pool, initialize with KeInitializeDpc, ISR fires and calls KeInsertQueueDpc, then CPU drains the per-processor queue and executes the DeferredRoutine at DISPATCH_LEVEL
A DPC travels through four stages — allocate, initialize, queue, drain — with the single-instantiation guarantee ensuring each KDPC object fires at most once per queue cycle.

5. Per-Processor DPC Queues and KPRCB

Each logical processor owns a separate DPC queue, stored as a KDPC_DATA structure inside the processor’s KPRCB (Kernel Processor Control Block). This avoids cross-CPU locking on the common path.

KDPC_DATA carries the queue head, depth, count, and a spin lock:

typedef struct _KDPC_DATA {
    LIST_ENTRY DpcListHead;   // queued KDPC objects
    ULONG      DpcLock;       // spin lock protecting the list
    volatile ULONG DpcQueueDepth;  // pending DPCs
    ULONG      DpcCount;      // running total
} KDPC_DATA, *PKDPC_DATA;

Exact KDPC_DATA field names vary by kernel build — confirm against a live PDB with dt nt!_KDPC_DATA before relying on offsets.

Because each queue is per-processor, the target processor of a DPC determines which CPU drains it. By default a DPC runs on the CPU that queued it, but it can be pinned elsewhere (§6) — a property attackers exploit to manipulate specific cores.


Hierarchy diagram showing two CPU KPRCB blocks each owning an independent KDPC_DATA queue structure, with individual KDPC objects enqueued within each per-processor queue to avoid cross-CPU locking
Each logical processor maintains its own KDPC_DATA queue inside its KPRCB, eliminating cross-CPU lock contention on the common interrupt-deferral path.

6. Controlling DPC Behaviour

API FunctionPurpose
KeSetImportanceDpcSets Importance; HighImportance inserts at the queue head
KeSetTargetProcessorDpcPins the DPC to a specific logical processor (directed DPC)
KeRemoveQueueDpcDequeues a pending DPC; fails once the routine is already running

DPCs have three priority levels — low, medium, high. Importance influences KeInsertQueueDpc: high-importance DPCs go to the head of the queue and are serviced first.

A directed DPC is created by binding it to a CPU before queuing. The pattern below — iterating over KeNumberProcessors and targeting each core — is the same primitive a rootkit weaponizes for CPU lockdown, so treat it as an educational illustration only:

// C5 — directed DPC setup (educational pattern)
for (CCHAR cpu = 0; cpu < KeNumberProcessors; cpu++) {
    KeInitializeDpc(&pDpcArray[cpu], MyCustomDpc, NULL);
    KeSetTargetProcessorDpc(&pDpcArray[cpu], cpu);  // pin to logical CPU
    KeSetImportanceDpc(&pDpcArray[cpu], HighImportance);
}

Once a DPC begins executing it cannot be removed; KeRemoveQueueDpc only rescinds a still-pending entry.


7. Threaded DPCs

Since Windows Server 2003, a KDPC can represent either a normal DPC or a threaded DPC. In the threaded variant, the kernel — if it can arrange it — calls the routine back at PASSIVE_LEVEL from a highest-priority thread, allowing more flexible work. Support can be disabled, in which case the threaded DPC falls back to running at DISPATCH_LEVEL exactly like a normal DPC.

You initialize one with KeInitializeThreadedDpc and a CustomThreadedDpc routine. Because that routine can run at either PASSIVE_LEVEL or DISPATCH_LEVEL, it must synchronize correctly at both IRQLs:

// C7 — threaded DPC with dual-IRQL guard
KeInitializeThreadedDpc(&g_ThreadedDpc, MyThreadedDpc, NULL);

VOID MyThreadedDpc(_In_ PKDPC Dpc, _In_opt_ PVOID Ctx,
                   _In_opt_ PVOID A1, _In_opt_ PVOID A2) {
    ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);   // may be PASSIVE or DISPATCH
    // Use locks valid at both levels.
}

Threaded DPCs should be preferred over ordinary DPCs unless a particular DPC must never be preempted — not even by another DPC.


8. Timer DPCs and KTIMER

A DPC is also the callback mechanism for kernel timers. You associate a KDPC with a KTIMER and arm it; on expiry the kernel queues the DPC. KeSetTimerEx supports both one-shot and periodic timers.

// C6 — periodic timer DPC
KeInitializeTimerEx(&g_Timer, NotificationTimer);
KeInitializeDpc(&g_TimerDpc, MyCustomDpc, NULL);

LARGE_INTEGER due;
due.QuadPart = -10LL * 1000 * 1000;     // 1 second, relative
KeSetTimerEx(&g_Timer, due, 1000 /* ms period */, &g_TimerDpc);

Windows uses special timer DPCs internally for timer expiration and context switching. The same primitive — a recurring timer pointed at a non-paged callback — is the cleanest way a driver schedules background work, and the cleanest way a malicious driver re-enters its payload (§10).


9. The DPC Watchdog and Debugging

The kernel runs a DPC watchdog. Bug Check 0x00000133 (DPC_WATCHDOG_VIOLATION) fires when the watchdog detects either a single long-running DPC or a prolonged time spent at DISPATCH_LEVEL or above. The timing budgets are 100 microseconds for a DPC and 25 microseconds for an ISR. A malicious DPC spin-loop can therefore inadvertently trip the watchdog and crash the host.

Inspect live DPC state in the kernel debugger:

kd> !dpcs                 ; list pending DPCs per processor
kd> dt nt!_KDPC           ; KDPC layout for this build
kd> dt nt!_KDPC_DATA      ; per-processor queue structure
kd> !prcb                 ; processor control block (contains DpcData)
kd> !pcr                  ; processor control region

!dpcs reveals each queued DPC’s DeferredRoutine address — the single most useful artifact, since an unknown or non-image-backed routine address is a strong anomaly.


10. Common Attacker Techniques

DPCs give kernel-mode malware a high-IRQL execution surface. Because code at DISPATCH_LEVEL is non-preemptable and runs to completion, it is ideal cover for Direct Kernel Object Manipulation (DKOM).

TechniqueDescription
CPU lockdown / freeze-other-CPUsQueue a directed KDPC to every non-current CPU via KeSetTargetProcessorDpc and spin, raising all secondary cores to DISPATCH_LEVEL to block interruption during a DKOM patch
Timer DPC payloadArm a KTIMER whose DeferredRoutine points at attacker-controlled non-paged code, for recurring stealth execution
KDPC hijackingOverwrite DeferredRoutine in a legitimate queued KDPC to redirect execution to a payload
Driver-based persistenceLoad a malicious signed/BYOVD driver that registers a recurring timer DPC at load time

The CPU-lockdown pattern is especially relevant to defenders: by parking every other core at DISPATCH_LEVEL, the rootkit can unlink processes, patch EDR callbacks, or hide drivers while no scheduler or AV thread can run.


Graph diagram mapping three rootkit DPC abuse techniques — directed DPC CPU lockdown, timer DPC stealth re-entry, and DeferredRoutine pointer corruption — to their downstream impacts of DKOM manipulation and EDR callback patching
Kernel rootkits weaponize DPCs three ways: CPU lockdown via directed DPCs, persistent re-entry via timer DPCs, and code hijacking via DeferredRoutine pointer corruption.

11. Defensive Strategies & Detection

DPC objects live entirely in kernel memory and are not directly observable from user mode, so detection focuses on the driver that installs them and on kernel ETW timing telemetry.

Sysmon and Windows event telemetry:

Event IDSourceRelevance
6Sysmon — Driver LoadedFires on every driver load; primary signal for kernel modules that register DPC routines
7Sysmon — Image LoadedCatches unsigned/anomalous modules entering kernel space
7045Service Control ManagerNew kernel-mode driver, especially from a non-standard path
7040Service Control ManagerService start-type change — driver persistence

ETW providers: The NT Kernel Logger session with EVENT_TRACE_FLAG_DPC and EVENT_TRACE_FLAG_INTERRUPT records per-DPC timing and the routine address, exposing abnormally long-running or unknown-address DPC routines. Microsoft-Windows-Kernel-Processor-Power surfaces IRQL/watchdog events. Verify the exact flag constants against the current WDK evntrace.h.

Sigma anchor — unsigned/expired driver load:

title: Suspicious Kernel Driver Load (Unsigned or Expired)
logsource:
  product: windows
  service: sysmon
detection:
  selection_unsigned:
    EventID: 6
    Signed: 'false'
  selection_expired:
    EventID: 6
    SignatureStatus: 'Expired'
  selection_path:
    EventID: 6
    ImageLoaded|contains: '\Temp\'
  condition: selection_unsigned or selection_expired or selection_path
level: high

Hunt additionally for EventID 6 where ImageLoaded resolves outside \SystemRoot\System32\drivers\.

Hardening:

MitigationDescription
Driver Signature Enforcement (DSE)Default on 64-bit Windows; blocks unsigned drivers that would install DPC routines
HVCIProtects kernel code pages, raising the bar for DPC shellcode and DeferredRoutine overwrite
Kernel CETHardware shadow stack mitigates ROP-based DPC hijacking
DPC WatchdogBuilt-in; Bug Check 0x133 catches long-running DPC loops, including malicious spin-locks
Vulnerable Driver BlocklistHKLM\SYSTEM\CurrentControlSet\Control\CI\Config\VulnerableDriverBlocklistEnable blocks known BYOVD primitives
WDAC / Memory IntegrityRestrict which drivers may load, shrinking the DPC-abuse attack surface

12. Tools for DPC Analysis

ToolDescriptionLink
WinDbg!dpcs, dt nt!_KDPC, !prcb, !pcr live queue inspectionmicrosoft.com
Process HackerDriver/service enumeration and kernel module listingprocesshacker.sourceforge.io
Windows Performance Recorder / xperfCaptures DPC/ISR ETW timing and routine addressesmicrosoft.com
SysmonDriver-load (EID 6) and image-load (EID 7) telemetrysysinternals.com
VolatilityMemory-forensic enumeration of drivers and kernel callbacksvolatilityfoundation.org
GhidraStatic analysis of suspect drivers for KeInsertQueueDpc usageghidra-sre.org

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
RootkitT1014ETW DPC routine-address anomalies; !dpcs unknown routines
Boot/Logon Autostart: Kernel ModulesT1547.006Sysmon EID 6 / Event 7045 driver loads
Exploitation for Privilege EscalationT1068HVCI/CET violations; KDPC.DeferredRoutine corruption
Impair Defenses: Disable/Modify ToolsT1562.001CPU-freeze DPC pattern halting EDR threads; watchdog 0x133
Native APIT1106Driver use of KeInitializeDpc / KeInsertQueueDpc

No dedicated ATT&CK sub-technique exists for DPC abuse as of ATT&CK v15; the techniques above are the parents. Verify current IDs at attack.mitre.org before publishing.


Summary

  • DPCs are the kernel’s mechanism for deferring interrupt work from high-IRQL ISRs down to DISPATCH_LEVEL, keeping ISRs under their 25 µs budget.
  • The opaque KDPC structure carries the DeferredRoutine, context, arguments, and a DpcData pointer that marks whether it is queued on a per-processor KDPC_DATA list in the KPRCB.
  • The lifecycle runs allocate → KeInitializeDpcKeInsertQueueDpc/IoRequestDpc → per-CPU drain at DISPATCH_LEVEL, with a single-instantiation guarantee per object.
  • Rootkits abuse directed DPCs for CPU lockdown, timer DPCs for stealth re-entry, and DeferredRoutine corruption for hijacking — mapping to T1014, T1547.006, and T1562.001.
  • Detect via Sysmon Event ID 6 driver loads, NT Kernel Logger DPC timing telemetry, and the DPC watchdog (0x133); harden with DSE, HVCI, Kernel CET, and the vulnerable driver blocklist.

Related Tutorials

References