DPC Internals Deep Dive: From IRQL to BYOVD Exploitation — A Security Researcher’s Guide + Video

Listen to this Post

Featured Image

Introduction:

Deferred Procedure Calls (DPCs) are a fundamental Windows kernel mechanism that enables high-priority tasks (like Interrupt Service Routines) to defer lower-priority work for later execution. While essential for system performance, DPCs represent a powerful attack surface — hijacking a DPC’s function pointer allows an attacker to execute arbitrary kernel-mode code, often bypassing traditional EDR hooks that operate at lower IRQLs. This article provides a comprehensive technical deep dive into DPC internals, WinDbg analysis, vulnerable driver patterns, ROP-based exploitation techniques, and modern mitigations like HVCI, drawing from real-world offensive security research.

Learning Objectives:

  • Understand the hardware-level foundation of IRQLs and how they govern DPC execution
  • Analyze the KDPC structure in memory and inspect live DPC queues using WinDbg
  • Identify vulnerable driver patterns that expose DPC function pointers to user-mode control
  • Build a ROP-based token-stealing payload and understand BYOVD (Bring Your Own Vulnerable Driver) attack chains
  • Implement detection strategies for DPC abuse and navigate modern kernel mitigations
  1. IRQL — What It Actually Means at the Hardware Level

Most explanations treat IRQL (Interrupt Request Level) as a software concept, but it is fundamentally a hardware register — CR8 on x64, also known as the Task Priority Register (TPR). The CPU itself checks this register before dispatching an interrupt.

On x64 Windows, the key IRQL levels are:

  • IRQL 0 = PASSIVE_LEVEL: Normal thread execution, user mode
  • IRQL 1 = APC_LEVEL: Kernel APCs, page fault handling
  • IRQL 2 = DISPATCH_LEVEL: Scheduler, DPCs — no paging allowed
  • IRQL 3–11 = DEVICE IRQL: Hardware ISRs (varies per device/APIC config)
  • IRQL 13 = CLOCK_LEVEL: Timer interrupt
  • IRQL 14 = POWER_LEVEL: Power management
  • IRQL 15 = HIGH_LEVEL: NMI, machine check

When the CPU receives an interrupt with a vector priority lower than or equal to the current TPR value, it does not fire — it stays pending in the Local APIC until the TPR drops. This is the hardware basis for IRQL preemption.

Why DISPATCH_LEVEL Blocks Paging: Page faults are handled by the Memory Manager at APC_LEVEL. If you take a page fault at DISPATCH_LEVEL, the page fault handler cannot run (it’s at a lower IRQL), and the system deadlocks. The kernel enforces this strictly: any access to paged memory at DISPATCH_LEVEL or above results in a bug check IRQL_NOT_LESS_OR_EQUAL.

  1. The KDPC Structure in Memory — Live Analysis with WinDbg

Let’s inspect the `KDPC` structure directly using a kernel debugger. Attach WinDbg with a kernel debug session (or WinDbg Preview with a VM via KDNET) and run:

dt nt!_KDPC

On Windows 11 22H2 (x64), the output reveals:

nt!_KDPC
+0x000 TargetInfoAsUlong : Uint4B
+0x000 Type : UChar ← 0x13 = DpcObject
+0x001 Importance : UChar ← 0=Low, 1=Medium, 2=High
+0x002 volatile Number : Uint2B ← target CPU (0 = any)
+0x008 DpcListEntry : _LIST_ENTRY ← links into per-CPU DPC queue
+0x018 DeferredRoutine : Ptr64 ← THE FUNCTION POINTER
+0x020 DeferredContext : Ptr64 ← first arg to DeferredRoutine
+0x028 SystemArgument1 : Ptr64
+0x030 SystemArgument2 : Ptr64
+0x038 DpcData : Ptr64 ← internal linkage (set when queued)

Critical Offsets: `DeferredRoutine` sits at offset `+0x18` from the start of the KDPC structure on 64-bit Windows. This offset is stable across Windows 10/11 versions.

Listing Active DPCs: Use the `!dpcs` command:

!dpcs

Example output:

CPU Type KDPC Function
0: Normal fffff8034a1bc3c0 nt!KiTimerExpiration+0x0
0: Normal fffff8034a1bc420 ndis!NdisMiniportDpc+0x0
1: Normal fffff8034b9a1180 storport!RaidpAdapterDpcRoutine+0x0

The `Function` column shows where `DeferredRoutine` points. On a healthy system, every entry resolves to a named symbol inside a loaded driver. If you see a DPC with a Function address that doesn’t map to any module — that’s your indicator of compromise.

To dump a specific KDPC:

dt nt!_KDPC fffff8034a1bc3c0

To check where the function pointer resolves:

ln fffff8034a1bc3c0+18

3. The Per-CPU DPC Queue — KPRCB Internals

Each logical processor has a `KPRCB` (Kernel Processor Control Block). The DPC queue lives here:

dt nt!_KPRCB 0 DpcData

Or get the PRCB address for CPU 0:

!prcb 0

Relevant fields in `KPRCB`:

+0x2980 DpcData : [bash] _KDPC_DATA // two queues: Normal and High
+0x2a00 DpcStack : Ptr64 // dedicated stack for DPC execution
+0x2a10 MaximumDpcQueueDepth : Int4B
+0x2a14 DpcRequestRate : Uint4B
+0x2a18 DpcBypassCount : Uint8B
+0x2a20 DpcWatchdogCount : Int4B

The `_KDPC_DATA` structure:

typedef struct _KDPC_DATA {
KDPC_LIST DpcList; // doubly-linked list of queued KDPCs
ULONG_PTR DpcLock; // spinlock protecting the queue
volatile LONG DpcQueueDepth;
ULONG DpcCount;
} KDPC_DATA;

When `KeInsertQueueDpc` is called, it:

  • Acquires `DpcLock` (a spinlock — no sleeping at DISPATCH_LEVEL)
  • Inserts the KDPC into `DpcList` via `DpcListEntry`
    – Sets `DpcData` in the KDPC to mark it as queued
  • Requests a software interrupt at DISPATCH_LEVEL if one isn’t already pending
  1. Writing a Driver That Registers a DPC (Legitimate Code)

Before we exploit, understand what legitimate use looks like. Here’s a minimal kernel driver that registers and fires a DPC:

include <ntddk.h>

KDPC g_Dpc;
KTIMER g_Timer;

// The DPC routine — runs at DISPATCH_LEVEL
VOID MyDpcRoutine(
<em>In</em> PKDPC Dpc,
<em>In_opt</em> PVOID DeferredContext,
<em>In_opt</em> PVOID SystemArgument1,
<em>In_opt</em> PVOID SystemArgument2
) {
UNREFERENCED_PARAMETER(Dpc);
UNREFERENCED_PARAMETER(DeferredContext);
UNREFERENCED_PARAMETER(SystemArgument1);
UNREFERENCED_PARAMETER(SystemArgument2);

// We are at DISPATCH_LEVEL here
// Cannot: access paged memory, call blocking functions, acquire mutexes
// Can: read/write non-paged pool, access kernel structures
DbgPrint("[bash] DPC fired on CPU %d\n", KeGetCurrentProcessorNumber());
}

VOID DriverUnload(<em>In</em> PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
KeCancelTimer(&g_Timer);
DbgPrint("[bash] Unloaded\n");
}

NTSTATUS DriverEntry(
<em>In</em> PDRIVER_OBJECT DriverObject,
<em>In</em> PUNICODE_STRING RegistryPath
) {
UNREFERENCED_PARAMETER(RegistryPath);
LARGE_INTEGER dueTime;

DriverObject->DriverUnload = DriverUnload;

// Initialize the KDPC — sets Type, links DeferredRoutine
KeInitializeDpc(&g_Dpc, MyDpcRoutine, NULL);

// Initialize a timer and associate our DPC with it
KeInitializeTimer(&g_Timer);

// Fire in 1 second, then every 2 seconds
dueTime.QuadPart = -10000000LL; // 1 second in 100ns units
KeSetTimerEx(&g_Timer, dueTime, 2000, &g_Dpc);

DbgPrint("[bash] Loaded, DPC scheduled\n");
return STATUS_SUCCESS;
}

After KeInitializeDpc, the KDPC is initialized with DeferredRoutine = MyDpcRoutine. That function pointer now lives in non-paged pool (or wherever the driver allocated its globals). This is the target.

  1. The Vulnerable Version — What an Attacker Looks For

Now imagine this driver has a bug: it exposes the address of `g_Dpc` to user mode, or allows writing to `g_Dpc.DeferredRoutine` via an IOCTL without validation.

// VULNERABLE IOCTL handler
case IOCTL_SET_DPC_CALLBACK: {
// User passes a 64-bit address, driver writes it directly
// NO VALIDATION — no check if address is in a signed module
PVOID userAddress = (PVOID)inputBuffer;
g_Dpc.DeferredRoutine = (PKDEFERRED_ROUTINE)userAddress;
break;
}

This is the write-what-where primitive. The attacker controls the value written to DeferredRoutine.

6. Exploitation — ROP-Based DPC Hijack

On modern Windows with SMEP (Supervisor Mode Execution Prevention) enabled, you cannot point `DeferredRoutine` at user-space shellcode. The CPU will fault when it tries to execute a user-mode page from kernel mode.

The answer: ROP (Return-Oriented Programming) inside kernel modules.

Step 1: Leak a Kernel Address

The driver needs to leak the address of `g_Dpc` (or the attacker finds it via another primitive). Common leaks:
– `NtQuerySystemInformation(SystemModuleInformation)` gives base addresses of all loaded kernel modules (still works from medium-integrity on older Windows)
– Vulnerable IOCTL that returns a pointer
– Pool spray + info leak from an adjacent allocation

Step 2: Build a ROP Chain in Non-Paged Pool

Allocate a buffer in non-paged pool (via `NtAllocateVirtualMemory` targeting kernel — no longer works directly, but BYOVD driver can do this). Or use an existing kernel address range that contains useful gadgets.

A minimal token-stealing ROP chain targeting Windows 10 x64:

; At DISPATCH_LEVEL, we want to:
; 1. Find SYSTEM process EPROCESS
; 2. Copy its token to our process's EPROCESS
; 3. Return cleanly without crashing

; Gadget 1: Set up registers
pop rax ; ret ← load PsInitialSystemProcess address into rax

; Gadget 2:
mov rax, [bash] ; ret ← dereference to get SYSTEM EPROCESS

; Gadget 3:
mov rbx, [rax+0x4b8] ; ret ← get SYSTEM Token (offset varies by build)

; Gadget 4: find current process EPROCESS via KTHREAD
mov rcx, gs:[bash] ; ret ← KPCR.CurrentThread
; ... (continue chain to write token and ret to a safe address)

In practice, exploit developers use automated gadget finders like ROPgadget or rp++ on ntoskrnl.exe:

rp++ -f ntoskrnl.exe -r 5 --va 0xfffff80340000000 > gadgets.txt
grep "pop rax" gadgets.txt

Step 3: Trigger the DPC

Send the IOCTL that causes the timer to fire, or directly call `KeInsertQueueDpc` if accessible. The kernel processes the DPC queue, calls our ROP chain at DISPATCH_LEVEL, and we’ve elevated privileges.

7. BYOVD — The Modern Real-World Path

Bring Your Own Vulnerable Driver (BYOVD) is how real-world attackers use DPC exploitation today. The idea:
– Find a legitimate, signed driver with the `IOCTL_SET_DPC_CALLBACK` style vulnerability
– Drop it onto the target system (it’s signed, so DSE/HVCI-off systems will load it)
– Use it as your exploitation primitive

Real examples of BYOVD drivers used in-the-wild:

| Driver | CVE | Primitive |

|–|–|–|

| `dbutil_2_3.sys` (Dell) | CVE-2021-21551 | Arbitrary kernel R/W |
| `gdrv.sys` (Gigabyte) | CVE-2018-19320 | Kernel R/W + code exec |
| `AsrDrv104.sys` (ASRock) | — | Kernel R/W |
| `mhyprot2.sys` (MiHoYo) | — | Arbitrary kernel R/W |

None of these need DPC specifically — but if the vulnerable driver exposes a KDPC write primitive, DPC hijacking becomes the execution method. The signed driver is the delivery; DPC corruption is the payload mechanism.

Finding BYOVD candidates:

  • LOLDrivers.io — community database of known vulnerable drivers
    – `ioctl_fuzzer` — fuzz driver IOCTLs to find write primitives
  • Reverse engineer drivers: look for IOCTLs that write user-supplied values to kernel pointers

8. DPC Watchdog — The Anti-Abuse Mechanism

Windows has a built-in DPC abuse detector: the DPC Watchdog. If a single DPC runs for more than ~100 microseconds, or if DPCs collectively monopolize a CPU for more than ~10 seconds, Windows issues a `DPC_WATCHDOG_VIOLATION` bug check (0x133).

This is relevant for exploitation: your DPC routine (or ROP chain) must complete fast. A token steal that takes too long will trigger a watchdog crash before it completes.

Exploit developers handle this by keeping the DPC code minimal and deferring any heavy work to a worker thread via `IoQueueWorkItem` (which runs at PASSIVE_LEVEL):

// From within your DPC routine:
PIO_WORKITEM workItem = IoAllocateWorkItem(g_DeviceObject);
IoQueueWorkItem(workItem, MyWorkerRoutine, DelayedWorkQueue, workItem);
// Return immediately — keep DPC short

9. HVCI — Why This All Changes

Hypervisor-Protected Code Integrity (HVCI) enforces W^X (write XOR execute) at the hypervisor level. The hypervisor maintains its own page table (SLAT — Second Level Address Translation) that is separate from the OS page tables.

Implications:

  • No RWX memory. You cannot have a page that is both writable and executable, even in the kernel.
  • All executable pages must be backed by a signed image. The hypervisor checks this via its own records, not the OS.
  • Even if you corrupt DeferredRoutine, pointing it at dynamically-allocated code will result in a PF or VM exit when the CPU tries to execute — the hypervisor’s EPT marks that page as non-executable.

The only viable paths on HVCI-enabled systems:

  • Data-only attacks: Corrupt `EPROCESS.Token` directly without executing shellcode. This still works if you have a kernel R/W primitive — no code execution needed.
  • Hypervisor vulnerabilities: Escape HVCI entirely by exploiting the hypervisor itself — a much harder class of bug.
  • Time-of-check/time-of-use on signed code: Extremely rare, but some signed drivers have gadgets that can be abused without new code allocation.

Check if HVCI is enabled:

Get-CimInstance -ClassName Win32_DeviceGuard -1amespace root\Microsoft\Windows\DeviceGuard | Select-Object -ExpandProperty VirtualizationBasedSecurityStatus
 2 = running

Or in WinDbg:

rdmsr 0xC0000082 // check LSTAR — if HVCI is on, this points to a hypervisor stub
!pcr // look for HvlpVsmEnabled flags

10. Detection Engineering — Catching DPC Abuse

For defenders and EDR developers, here’s what DPC abuse looks like and how to catch it.

What EDRs Miss Most: EDRs hook at PASSIVE_LEVEL via kernel callbacks:
– `PsSetCreateProcessNotifyRoutine`
– `PsSetCreateThreadNotifyRoutine`
– `CmRegisterCallback`

DPC execution happens at DISPATCH_LEVEL and is invisible to these callbacks. A compromised DPC that does a token steal and returns will leave no process creation event, no registry event, no file event.

What You Can Detect:

1. KDPC with out-of-module DeferredRoutine

Periodically scan the per-CPU DPC queues and validate each `DeferredRoutine` pointer resolves to a known loaded module:

// Pseudo-detection logic
KDPC_DATA dpcData = &KeGetPcr()->Prcb->DpcData[bash];
LIST_ENTRY entry = dpcData->DpcList.ListHead.Flink;

while (entry != &dpcData->DpcList.ListHead) {
KDPC dpc = CONTAINING_RECORD(entry, KDPC, DpcListEntry);
if (!IsAddressInLoadedModule(dpc->DeferredRoutine)) {
// Suspicious — log or alert
}
entry = entry->Flink;
}

2. Driver loading events for known-vulnerable drivers

Use `PsSetLoadImageNotifyRoutine` to catch driver loads and compare against a blocklist of vulnerable driver hashes (see LOLDrivers.io for hashes).

3. WinDbg — post-incident analysis

!dpcs // List all pending DPCs
!for_each_module "ln ${@Base}" // Validate each function pointer
!dpcs -v // Check for non-module addresses in DPC queue

4. DPC Watchdog violations in crash dumps

A `DPC_WATCHDOG_VIOLATION` minidump often indicates either a buggy driver or an exploitation attempt that ran too long. Parse `%SystemRoot%\Minidump\.dmp` files as part of incident response.

What Undercode Say:

  • Key Takeaway 1: DPCs operate at DISPATCH_LEVEL, completely bypassing EDR hooks that rely on PASSIVE_LEVEL callbacks. This makes DPC hijacking a stealthy and powerful kernel exploitation primitive.
  • Key Takeaway 2: The KDPC structure’s `DeferredRoutine` pointer at offset +0x18 is a stable, well-documented target. Vulnerable drivers that allow arbitrary writes to this pointer provide a reliable write-what-where primitive for privilege escalation.

Analysis: The article bridges the gap between theoretical kernel internals and practical exploitation. It systematically progresses from hardware-level IRQL mechanics to live WinDbg analysis, then to vulnerable driver patterns and ROP chain construction. The inclusion of BYOVD as the modern attack vector is particularly relevant, as it mirrors real-world adversary behavior (e.g., ransomware groups using signed drivers to disable EDR). The HVCI section appropriately highlights the shifting landscape — while DPC hijacking remains viable, modern mitigations force attackers toward data-only attacks or hypervisor escapes, raising the bar significantly. For defenders, the detection strategies provided offer actionable intelligence: scanning DPC queues for out-of-module pointers and monitoring for known-vulnerable driver loads are practical, low-cost measures that can catch sophisticated attacks.

Prediction:

  • +1 DPC exploitation will remain a relevant technique for red teams and APT groups, particularly in environments where HVCI is not enforced, as the attack surface (third-party drivers) is vast and historically vulnerable.
  • -1 The increasing adoption of HVCI and hardware-backed security features (like AMD SEV and Intel SGX) will progressively narrow the window for traditional DPC hijacking, forcing attackers to invest in more complex data-only or hypervisor-level exploits.
  • -1 Microsoft’s continued investment in driver signing enforcement and the expansion of the vulnerable driver blocklist will make BYOVD attacks harder to execute, though the sheer volume of legacy drivers in enterprise environments ensures this vector will persist for years.
  • +1 Detection engineering around DPC queues will become a standard capability in next-generation EDR/XDR platforms, as security vendors recognize the visibility gap at DISPATCH_LEVEL and develop kernel-level sensors to monitor DPC integrity.
  • -1 The complexity of DPC-based exploits and the need for precise ROP chains mean this technique will likely remain in the domain of sophisticated actors, rather than becoming a commodity attack tool.

▶️ Related Video (80% Match):

🎯Let’s Practice For Free:

🎓 Live Courses & Certifications:

Join Undercode Academy for Verified Certifications

🚀 Request a Custom Project:

Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands

IT/Security Reporter URL:

Reported By: Muaaztalaat Deferred – Hackers Feeds
Extra Hub: Undercode MoN
Basic Verification: Pass ✅

🔐JOIN OUR CYBER WORLD [ CVE News • HackMonitor • UndercodeNews ]

💬 Whatsapp | 💬 Telegram

📢 Follow UndercodeTesting & Stay Tuned:

𝕏 formerly Twitter 🐦 | @ Threads | 🔗 Linkedin | 🦋BlueSky