When discussing callbacks, most people often think of EDRs (Endpoint Detection and Response systems).
There are numerous methods available for identifying callbacks in research environments. One such method was addressed by Yarden Shafir in [this article].
In this post, we will explain how to initialize, identify and remove callbacks using simple Windbg commands and also take a general look at the Windows kernel source code.
Arrays and Structures Related to Callbacks
In Windows, there are specific arrays designed to manage callbacks, which follow the patterns below:
- PspCreateProcessNotifyRoutine
- PspCreateThreadNotifyRoutine
- PspLoadImageNotifyRoutine
The PspCreateProcessNotifyRoutine array is dedicated to managing process callbacks, while the PspCreateThreadNotifyRoutine array is used for thread callbacks.
Note: In ReactOS, these arrays are named PspProcessNotifyRoutine and PspThreadNotifyRoutine.
These arrays are defined in the Windows kernel source code as follows:
EX_CALLBACK PspCreateProcessNotifyRoutine[PSP_MAX_CREATE_PROCESS_NOTIFY];
EX_CALLBACK PspCreateThreadNotifyRoutine[PSP_MAX_CREATE_THREAD_NOTIFY];
As shown above, the arrays are of type EX_CALLBACK, which is an undocumented structure defined as:
typedef struct _EX_CALLBACK {
EX_FAST_REF RoutineBlock;
} EX_CALLBACK, *PEX_CALLBACK;
In the EX_CALLBACK structure, the RoutineBlock member is of type EX_FAST_REF, which can be observed in the nt symbols.
Note: The RoutineBlock member is of type EX_FAST_REF, not a pointer to it. Therefore, a pointer to an EX_CALLBACK structure effectively points to an EX_FAST_REF structure.
typedef struct _EX_FAST_REF {
union {
PVOID Object;
#if defined (_WIN64)
ULONG_PTR RefCnt : 4;
#else
ULONG_PTR RefCnt : 3;
#endif
ULONG_PTR Value;
};
} EX_FAST_REF, *PEX_FAST_REF;
EX_FAST_REF Structure
The EX_FAST_REF structure is an example of efficient memory usage in Windows kernel programming. As shown in the definition, it contains a union that shares the same memory space for its members (Object, RefCnt, and Value). This means that modifying one member will directly affect the others.
In 64-bit architectures, the RefCnt field is specifically designed as a 4-bit field. This is possible because, in x64 systems, pointers are always aligned to 64-bit (8 byte), which ensures that the lower 4 bits of the pointer are always zero. By utilizing these unused bits, the designers have allocated them to store the RefCnt value. This optimization significantly reduces memory usage.
This design demonstrates how unused portions of aligned pointers can be repurposed to store additional information without increasing the memory footprint. Such optimizations are critical in kernel structures where efficiency is paramount.
Preview of windbg Command
You can inspect the values inside the PspCreateProcessNotifyRoutine array using the following Windbg command:
0: kd> dq nt!PspCreateProcessNotifyRoutine
fffff804`302ec360 ffff800d`af6501bf ffff800d`af7fd0cf
fffff804`302ec370 ffff800d`aff663cf ffff800d`aff6642f
fffff804`302ec380 ffff800d`aff66b7f ffff800d`afedda3f
fffff804`302ec390 ffff800d`afeddb5f ffff800d`afede21f
fffff804`302ec3a0 ffff800d`b0a038af ffff800d`b0a0810f
fffff804`302ec3b0 00000000`00000000 00000000`00000000
fffff804`302ec3c0 00000000`00000000 00000000`00000000
fffff804`302ec3d0 00000000`00000000 00000000`00000000
Since these arrays are of type EX_CALLBACK, the addresses shown above represent the RoutineBlock values, which are of type EX_FAST_REF.
The reason all addresses in PspCreateProcessNotifyRoutine end with F is the reserved value for RefCnt. We will check the source code later.
Inspecting the EX_FAST_REF Structure in Windbg:
You can view the structure of EX_FAST_REF in the nt module using the following command:
0: kd> dt nt!_EX_FAST_REF
+0x000 Object : Ptr64 Void
+0x000 RefCnt : Pos 0, 4 Bits
+0x000 Value : Uint8B
The offsets indicate that this structure is a union.
Next Step
So far, we’ve explored how these arrays and their structures are laid out in memory. Now, let’s take a look at the Windows kernel source code to understand how these arrays are populated.
Diving Into Kernel Source Code
It’s important to note that all the functions explained here are simplified for the purpose of this article and should not be relied upon for in-depth research. For a deeper understanding, refer to the ReactOS source code or relevant repositories on GitHub.
It’s important to note that all the functions explained here are simplified for the purpose of this article and should not be relied upon for in-depth research. For a deeper understanding, refer to the ReactOS source code or relevant repositories on GitHub.
Callback Registration for Processes
The following functions are available for registering process callbacks:
- PsSetCreateProcessNotifyRoutine
- PsSetCreateProcessNotifyRoutineEx
- PsSetCreateProcessNotifyRoutineEx2
Since only PsSetCreateProcessNotifyRoutine is implemented in ReactOS, this article focuses on explaining this function.
Function call order:
You constantly need to refer to this photo.

While analyzing the PsSetCreateProcessNotifyRoutine() function, we encounter a variable of type PEX_CALLBACK_ROUTINE_BLOCK, Finally, we realize that the main structure that holds the callbacks is this structure. which is defined as follows:
typedef struct _EX_CALLBACK_ROUTINE_BLOCK {
EX_RUNDOWN_REF RundownProtect;
PEX_CALLBACK_FUNCTION Function;
PVOID Context;
} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;
Callback Initialization
PsSetCreateProcessNotifyRoutine():
In the PsSetCreateProcessNotifyRoutine() function, the EX_CALLBACK_ROUTINE_BLOCK structure is populated using the ExAllocateCallBack() function and passed as input to the ExCompareExchangeCallBack() function.
//
// The functions for this article have been shortened for better understanding and less clutter.
// Please refer to the original source.
//
NTSTATUS PsSetCreateProcessNotifyRoutine(
IN PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine,
IN BOOLEAN Remove
) {
PEX_CALLBACK_ROUTINE_BLOCK CallBack;
PAGED_CODE();
if (Remove)
{...
}
else
{
/* Allocate a callback */
CallBack = ExAllocateCallBack((PVOID)NotifyRoutine, NULL);
if (!CallBack) return STATUS_INSUFFICIENT_RESOURCES;
/* Loop all callbacks */
for (ULONG i = 0; i < PSP_MAX_CREATE_PROCESS_NOTIFY; i++) {
/* Add this routine if it's an empty slot */
if (ExCompareExchangeCallBack(&PspCreateProcessNotifyRoutine[i], CallBack, NULL)) {
/* Found and inserted into an empty slot, return */
InterlockedIncrement((PLONG)&PspCreateProcessNotifyRoutineCount);
return STATUS_SUCCESS;
}
}
ExFreeCallBack(CallBack);
return STATUS_INVALID_PARAMETER;
}
}
ExAllocateCallBack():
This function allocates and initializes a EX_CALLBACK_ROUTINE_BLOCK .
PEX_CALLBACK_ROUTINE_BLOCK ExAllocateCallBack(
IN PEX_CALLBACK_FUNCTION Function,
IN PVOID Context
) {
PEX_CALLBACK_ROUTINE_BLOCK CallbackBlock;
/* Allocate a callback */
CallbackBlock = ExAllocatePoolWithTag(PagedPool, sizeof(EX_CALLBACK_ROUTINE_BLOCK), TAG_CALLBACK_ROUTINE_BLOCK);
if (CallbackBlock) {
/* Initialize it */
CallbackBlock->Function = Function;
CallbackBlock->Context = Context;
ExInitializeRundownProtection(&CallbackBlock->RundownProtect);
}
return CallbackBlock;
}
ExCompareExchangeCallBack():
The ExCompareExchangeCallBack function populates the internal value of the EX_CALLBACK structure using the ExCompareSwapFastReference function. The output of the ExCompareSwapFastReference function is an EX_FAST_REF structure, and the Object parameter passed to it is of type PEX_CALLBACK_ROUTINE_BLOCK. As a result, the Object field in the EX_FAST_REF structure acts as a pointer to an EX_CALLBACK_ROUTINE_BLOCK.
//
// ALL CODE HAS CHANGED AND YOU SHOULD NOT RELY ON THIS CODE FOR DETAILS.
// THIS CODE IS SHOW ONLY FOR BETTER UNDERSTANDING ABOUT INITIALIZATION VALUE IN EX_FAST_REF.
//
BOOLEAN ExCompareExchangeCallBack(IN OUT PEX_CALLBACK CallBack,
IN PEX_CALLBACK_ROUTINE_BLOCK NewBlock,
IN PEX_CALLBACK_ROUTINE_BLOCK OldBlock)
{
EX_FAST_REF OldValue;
PEX_CALLBACK_ROUTINE_BLOCK CallbackBlock;
ULONG Count;
/* Check that we have a new block */
if (NewBlock)
{...
}
/* Do the swap */
OldValue = ExCompareSwapFastReference(&CallBack->RoutineBlock, NewBlock, OldBlock);
/* Get the routine block */
CallbackBlock = ExGetObjectFastReference(OldValue);
Count = ExGetCountFastReference(OldValue);
/* Make sure the swap worked */
if (CallbackBlock == OldBlock)
{...
}
else
{...
}
ExCompareSwapFastReference():
FORCEINLINE EX_FAST_REF ExCompareSwapFastReference(IN PEX_FAST_REF FastRef,
IN PVOID Object,
IN PVOID OldObject)
{
EX_FAST_REF OldValue, NewValue;
/* Sanity check and start swap loop */
ASSERT(!(((ULONG_PTR)Object) & MAX_FAST_REFS));
for (;;) {
/* Get the current value */
OldValue = *FastRef;
/* Make sure there's enough references to swap */
if (!((OldValue.Value ^ (ULONG_PTR)OldObject) <= MAX_FAST_REFS)) break;
/* Check if we have an object to swap */
if (Object) {
/* Set up the value with maximum fast references */ // <---------------------------------------------
NewValue.Value = (ULONG_PTR)Object | MAX_FAST_REFS; // #define MAX_FAST_REFS 0xF
else
{
/* Write the object address itself (which is empty) */
NewValue.Value = (ULONG_PTR)Object;
}
/* Do the actual compare exchange */
NewValue.Object = ExpChangePushlock(&FastRef->Object, NewValue.Object, OldValue.Object);
if (NewValue.Object != OldValue.Object) continue;
break;
}
return OldValue;
}
In ReactOS the value of MAX_FAST_REFS is 7, but in 64-bit systems this value is 0xF.

Here we found out why all the addresses in PspCreateProcessNotifyRoutine end with F.
Due to the FORCEINLINE nature of the ExCompareSwapFastReference function, there is no explicit symbol for it in WinDbg or IDA Pro. The compiler defines this function as inline, meaning there are no call instructions for it. As a result, different references may assign various names to this function in documentation or reverse-engineering contexts.
Since these structures are undocumented, they need to be added manually to WinDbg. While the dx command can be used for this purpose, I prefer to use the basic commands of WinDbg for better control and understanding. To do this, I wrote a simple driver where these three structures were defined and initialized. The driver was then started on a debugging-enabled Windows system, allowing WinDbg to utilize the PDB file of the driver for symbol resolution.
#include <ntifs.h>
typedef struct _EX_FAST_REF {
union {
PVOID Object;
#if defined (_WIN64)
ULONG_PTR RefCnt : 4;
#else
ULONG_PTR RefCnt : 3;
#endif
ULONG_PTR Value;
};
} EX_FAST_REF, * PEX_FAST_REF;
typedef struct _EX_CALLBACK_ROUTINE_BLOCK {
EX_RUNDOWN_REF RundownProtect;
PEX_CALLBACK_FUNCTION Function;
PVOID Context;
} EX_CALLBACK_ROUTINE_BLOCK, * PEX_CALLBACK_ROUTINE_BLOCK;
typedef struct _EX_CALLBACK
{
EX_FAST_REF RoutineBlock;
} EX_CALLBACK, * PEX_CALLBACK;
NTSTATUS IrpCompleteRequest(PIRP Irp, ULONG_PTR info, NTSTATUS status) {
Irp->IoStatus.Status = status;
Irp->IoStatus.Information = info;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}
void StructWindbgUnload(PDRIVER_OBJECT DriverObject) {
IoDeleteDevice(DriverObject->DeviceObject);
UNICODE_STRING symbolName = RTL_CONSTANT_STRING(L"\\??\\StructWindbg");
IoDeleteSymbolicLink(&symbolName);
DbgPrint("[-] StructWindbg Driver Unload Successfuly\n");
return;
}
NTSTATUS StructWindbgCreateClose(PIRP Irp) {
return IrpCompleteRequest(Irp, 0, STATUS_SUCCESS);
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrint("[+] StructWindbg Driver Load Successfuly\n");
NTSTATUS status = STATUS_SUCCESS;
DriverObject->DriverUnload = StructWindbgUnload;
DriverObject->MajorFunction[IRP_MJ_CREATE] = DriverObject->MajorFunction[IRP_MJ_CLOSE] = StructWindbgCreateClose;
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\StructWindbg");
UNICODE_STRING symName = RTL_CONSTANT_STRING(L"\\??\\StructWindbg");
PDEVICE_OBJECT DeviceObject = NULL;
do {
status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
if (!NT_SUCCESS(status))
break;
DbgPrint("[+] StructWindbg Driver IoCreateDevice Successfuly\n");
status = IoCreateSymbolicLink(&symName, &devName);
if (!NT_SUCCESS(status)) {
IoDeleteDevice(&DeviceObject);
break;
}
DbgPrint("[+] StructWindbg Driver IoCreateSymbolicLink Successfuly\n");
} while (FALSE);
EX_CALLBACK _callbackEX = { 0 };
EX_CALLBACK_ROUTINE_BLOCK _callbackEX1 = { 0 };
EX_FAST_REF _callbackEX2 = { 0 };
return status;
}
Let’s go back to Windbg:
0: kd> lm m struct*
Browse full module list
start end module name
fffff804`377f0000 fffff804`377f7000 StructForWindbg (private pdb symbols) C:\Users\mehrshad\source\Kernel-mode\StructForWindbg\x64\Debug\StructForWindbg.pdb
0: kd> dt StructForWindbg!_EX_CALLBACK_ROUTINE_BLOCK
+0x000 RundownProtect : _EX_RUNDOWN_REF
+0x008 Function : Ptr64 long
+0x010 Context : Ptr64 Void
0: kd> dt StructForWindbg!_EX_CALLBACK
+0x000 RoutineBlock : _EX_FAST_REF
0: kd> dq nt!PspCreateProcessNotifyRoutine
fffff804`6d6ec360 ffffa984`1105012f ffffa984`111fd39f
fffff804`6d6ec370 ffffa984`114fc5df ffffa984`114fc30f
fffff804`6d6ec380 ffffa984`114fce1f ffffa984`11b0891f
fffff804`6d6ec390 ffffa984`11b087cf ffffa984`11b0930f
fffff804`6d6ec3a0 ffffa984`1245aa6f ffffa984`1245f1df
fffff804`6d6ec3b0 ffffa984`17874acf 00000000`00000000
fffff804`6d6ec3c0 00000000`00000000 00000000`00000000
fffff804`6d6ec3d0 00000000`00000000 00000000`00000000
As mentioned earlier, these addresses are actually the RoutineBlock values, which are of type EX_FAST_REF. To obtain the actual address of the EX_CALLBACK_ROUTINE_BLOCK structure, we need to remove the bits related to RefCnt from these addresses. This is done using a bitwise AND operation with the value FFFFFFFFFFFFFFFF0.
FFFF A984 1105 012F
FFFF FFFF FFFF FFFF0
AND --------------------
FFFF A984 1105 0120
We can inspect the contents of this structure in Windbg:
0: kd> dt StructForWindbg!_EX_CALLBACK fffff804`6d6ec360
+0x000 RoutineBlock : _EX_FAST_REF
0: kd> dt nt!_EX_FAST_REF fffff804`6d6ec360
+0x000 Object : 0xffffa984`1105012f Void
+0x000 RefCnt : 0y1111
+0x000 Value : 0xffffa984`1105012f
0: kd> dt StructForWindbg!_EX_CALLBACK_ROUTINE_BLOCK (ffffa984`1105012f & ffffffff`fffffff0)
+0x000 RundownProtect : _EX_RUNDOWN_REF
+0x008 Function : 0xfffff804`6cd5e400 long nt!ViCreateProcessCallback+0
+0x010 Context : (null)
0: kd> u 0xfffff804`6cd5e400
nt!ViCreateProcessCallback:
fffff804`6cd5e400 4883ec28 sub rsp,28h
fffff804`6cd5e404 833d5dee8b0000 cmp dword ptr [nt!ViVerifierEnabled (fffff804`6d61d268)],0
fffff804`6cd5e40b 488bc2 mov rax,rdx
fffff804`6cd5e40e 0f8532f11200 jne nt!ViCreateProcessCallback+0x12f146 (fffff804`6ce8d546)
fffff804`6cd5e414 4883c428 add rsp,28h
fffff804`6cd5e418 c3 ret
fffff804`6cd5e419 cc int 3
fffff804`6cd5e41a cc int 3
We can inspect the contents of this structure in Windbg:
Using the WinObjEx64.exe tool in the Extras -> System Callbacks , we can see the system callbacks.

Remove Callbacks
To delete a callback, you can use the eq command in WinDbg to modify the value at a specific memory address. For example, the following command changes the first index of the PspCreateProcessNotifyRoutine array to 0:
0: kd> eq fffff804`6d6ec360 00000000`00000000
0: kd> dq nt!PspCreateProcessNotifyRoutine
fffff804`6d6ec360 00000000`00000000 ffffa984`111fd39f
fffff804`6d6ec370 ffffa984`114fc5df ffffa984`114fc30f
fffff804`6d6ec380 ffffa984`114fce1f ffffa984`11b0891f
fffff804`6d6ec390 ffffa984`11b087cf ffffa984`11b0930f
fffff804`6d6ec3a0 ffffa984`1245aa6f ffffa984`1245f1df
fffff804`6d6ec3b0 ffffa984`17874acf 00000000`00000000
fffff804`6d6ec3c0 00000000`00000000 00000000`00000000
fffff804`6d6ec3d0 00000000`00000000 00000000`00000000
In the image below you can see that 0xfffff8046cd5e400 was deleted:

References:
reactos/reactos: A free Windows-compatible Operating System
Yarden ShafirWinDbg — the Fun Way: Part 2
Thanks to:
Twitter Yarden Shafir (@yarden_shafir) on X
TwitterAlex Ionescu (@aionescu) on X
.
.