
Humans are creatures that hate to wait. Even if the hardware response is slow by 100 milliseconds, human blood pressure may rise linearly. As a technology that appeared with the CPU in ancient times, the interrupt carries the response actions of various hardware in the system. Therefore, the response speed of the interrupt request has become a matter of “life and death” for the operating system - the interrupt handler should be as fast as possible, preferably not to let people feel waiting. Imagine the feeling of pressing a key and only seeing feedback on the screen after 1s, it definitely won’t be a good experience.
DPC Timer
Although it is envisaged that the interrupt request handler (ISR, Interrupt Service Routines) should be as fast as possible, it is inevitable to perform some time-consuming operations in the actual practice process. At this time, the conflict occurs. Fortunately, time-consuming events can generally be “delayed” for processing. At this time, the DPC (Deferred Procedure Calls) timer was born. The ISR can add a callback function to the DPC queue when needed. When the system switches from DIRQL to PASSIVE_LEVEL, the system will execute the callback function inserted into the DPC queue by the ISR, thereby completing some time-consuming operations (reference 1).
This article only explains a few key data structures of the DPC timer, and this article only contains a few key codes. If you want to traverse all DPC timers, there is some extra workload, and the code is not given in this article.
APC Queue
By the way, corresponding to DPC, there is also an APC (Asynchronous Procedure Calls) queue. This queue is thread-related, and there are two types of kernel-mode APC and user-mode APC. A thread must be in an Alertable State to execute the APC queue. The most famous “thread” is the Idle thread. Although its name is the idle thread (the operating system does nothing when it does a loop in this thread, so it often sees this thread occupying 99% of the CPU), it actually does some non-idle things. One of them is to execute the functions in the APC queue. This mechanism is often targeted by viruses to do some bad things. We will elaborate on this when we encounter suitable samples later.
KPRCB Structure
The header of the DPC queue is located in the TimerTable. The TimerTable is a member of the KPRCB structure. Its position under different operating systems is as follows:
x86 | x64 | |
Windows 7 | 0x1960 | 0x2200 |
Windows 8 | 0x2260 | 0x2e00 |
Windows 8.1 | 0x2260 | 0x2e00 |
Windows 10 | 0x2260 | 0x3600 |
Windows 11 | - | 0x3C00 |
For the convenience of the following description, it is assumed that the following data structure is defined, and the offset needs to be corrected according to the system version when used:
- typedef struct _KPRCB
- {
- #ifdef _WIN64
- UCHAR Reserved1[x64_offset_in_above_table];
- #else
- UCHAR Reserved1[x86_offset_in_above_table];
- #endif
- KTIMER_TABLE TimerTable;
- } KPRCB, *PKPRCB;
And KTIMER_TABLE is the data structure we are concerned about and related to DPC.
KTIMER_TABLE Structure
Microsoft is always doing weird things…
The structure of Windows 11 is different from that of Windows 7 to Windows 10. The main differences are:
- The TimerEntries member has changed from a one-dimensional array to a two-dimensional array.
- Windows 11 has added a KTIMER_TABLE_STATE type member TableState
Therefore, it is defined as follows:
- #define TIMER_TABLE_MAX 256
- typedef struct _KTIMER_TABLE_STATE
- {
- UINT8 LastTimerExpiration[2];
- UINT8 LastTimerHand;
- } KTIMER_TABLE_STATE, *PKTIMER_TABLE_STATE;
- typedef struct _KTIMER_TABLE_ENTRY
- {
- ULONG_PTR Lock;
- LIST_ENTRY Entry;
- ULARGE_INTEGER Time;
- } KTIMER_TABLE_ENTRY, *PKTIMER_TABLE_ENTRY;
- typedef struct _KTIMER_TABLE_WINDOWS7
- {
- #ifdef _WIN64
- KTIMER *TimerExpiry[64];
- #else
- KTIMER *TimerExpiry[16];
- #endif
- KTIMER_TABLE_ENTRY TimerEntries[TIMER_TABLE_MAX];
- } KTIMER_TABLE_WINDOWS7, *PKTIMER_TABLE_WINDOWS7;
- typedef struct _KTIMER_TABLE_WINDOWS11
- {
- KTIMER *TimerExpiry[64];
- KTIMER_TABLE_ENTRY TimerEntries[2][TIMER_TABLE_MAX];
- KTIMER_TABLE_STATE TableState;
- } KTIMER_TABLE_WINDOWS11, *PKTIMER_TABLE_WINDOWS11;
Fortunately, the KTIMER_TABLE_ENTRY structure has not changed, otherwise another layer of adaptation will be needed…
KTIMER Structure
After being able to traverse TimerEntries, there is one last step, to establish a connection between KTIMER_TABLE_ENTRY and KTIMER, which is actually the following relationship:

The corresponding C language traversal is as follows:
- ULONG i = 0, j = 0;
- ULONG NumberOfProcessor = KeQueryMaximumProcessorCount();
- for (i = 0; i < NumberOfProcessor; ++i)
- {
- PKPRCB Prcb = _GetPRCBEachProcessor(i);
- PKTIMER_TABLE TimerTable = Prcb->TimerTable;
- for (j = 0; j < TIMER_TABLE_MAX; ++j)
- {
- PKTIMER_TABLE_ENTRY Node = (PUCHAR)(TimerTable->TimerEntries) + j * sizeof(KTIMER_TABLE_ENTRY);
- PLIST_ENTRY Work = Node->Entry.Blink;
- for (Work = Node->Entry.Blink; Work != &Node->Entry.Blink; Work = Work->Blink)
- {
- PKTIMER Timer = CONTAINING_RECORD(Work, KTIMER, TimerListEntry);
- }
- }
- }
Note: The above code is only for Windows 7 to Windows 10, not suitable for Windows 11. Because the TimerEntries of Windows 11 is a two-dimensional array (see the structure definition in the above text), the traversal code of Windows 11 needs to consider this issue.
Encryption Problem of DPC
Structure Microsoft is always doing weird things…
For some reason, starting from x64, Microsoft encrypted the DeferredRoutine function address pointed to by the Dpc member of the KTIMER structure. The following code is taken from Windows 10 x64:
- __int64 __fastcall KiInsertQueueDpc(_KDPC *Dpc, __int64 a2, __int64 a3, volatile signed __int32 *a4, char a5)
- {
- ……
- v8 = 0;
- v10 = (DWORD1(PerfGlobalGroupMask) & 0x40000) != 0;
- ……
- if ( _InterlockedCompareExchange64((volatile signed __int64 *)&Dpc->DpcData, IsrDpcStats, 0i64) )
- {
- DpcQueueDepth = 0;
- }
- else
- {
- ……
- v8 = 1;
- ……
- }
- ……
- if ( v8 )
- {
- if ( v10 )
- {
- EtwTraceDpcEnqueueEvent(
- 0xF3DD7277
- * (KiWaitNever ^ __ROR8__(
- (unsigned __int64)Dpc->DeferredRoutine ^ _byteswap_uint64((unsigned __int64)Dpc ^ KiWaitAlways),
- KiWaitNever)),
- Dpc->DeferredRoutine,
- DpcQueueDepth,
- DpcCount,
- v15,
- Dpc->Importance);
- v15 = v31;
- }
- ……
- }
- ……
- }
It can be seen that when log tracking is turned on, EtwTraceDpcEnqueueEvent will decrypt Dpc->DeferredRoutine, and its decryption algorithm is equivalent to the following C code:
- PKDPC Dpc = NULL;
- ULONG_PTR ULongDpc = 0;
- ULongDpc = (ULONG_PTR)Timer->Dpc;
- ULongDpc ^= *(PULONG_PTR)KiWaitNever;
- ULongDpc = _rotl64(ULongDpc, *(PUCHAR)KiWaitNever);
- ULongDpc ^= (ULONG_PTR)Timer;
- ULongDpc = _byteswap_uint64(ULongDpc);
- ULongDpc ^= *(PULONG_PTR)KiWaitAlways;
- if (MmIsAddressValid(ULongDpc))
- {
- Dpc = ULongDpc;
- }
The KiWaitNever and KiWaitAlways in the above text are global variables in the kernel, which can be obtained by observing the KeSetTimerEx function. This article will not elaborate.
Results
To prove that the above results are correct, verify it:

End of work~