Chapter 43

Kernel Callbacks

PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, ObRegisterCallbacks, and CmRegisterCallback — the pillars of kernel-mode EDR

Scenario

An EDR's kernel driver gets 100% process visibility not from user-mode DLL injection, but from kernel callbacks registered when the driver loads. These callbacks fire before any user-mode code even runs in the new process, and cannot be bypassed with techniques that only operate in user mode. Understanding their internal structure is both essential for building detections and for understanding how rootkits suppress them.

Callback Architecture Overview

Kernel Callback Registration Model:

  Driver loads → DriverEntry() registers callbacks:
    PsSetCreateProcessNotifyRoutineEx(MyProcessCallback, FALSE)
    PsSetCreateThreadNotifyRoutine(MyThreadCallback)
    PsSetLoadImageNotifyRoutine(MyImageCallback)
    ObRegisterCallbacks(...)
    CmRegisterCallback(...)

  Callbacks stored in kernel arrays:
    PspCreateProcessNotifyRoutine[]  (64-entry limit)
    PspCreateThreadNotifyRoutine[]   (64-entry limit)
    PspLoadImageNotifyRoutine[]      (8-entry limit)
    ObpCallPreOperationCallbacks[] / ObpCallPostOperationCallbacks[]
    CmpCallCallBacks[] (registry callback list)

  On event:
    Process create  → walk PspCreateProcessNotifyRoutine[] → call each
    Thread create   → walk PspCreateThreadNotifyRoutine[]  → call each
    Image mapped    → walk PspLoadImageNotifyRoutine[]     → call each
    Object access   → call pre-operation → allow/block/modify
    Registry op     → call CmpCallCallBacks callbacks

  Rootkit suppression:
    DKOM: zero out array entries or overwrite with null/NOP
    Bypass limitation: only works on VTL0; HVCI protects kernel code pages

Process Notify Callbacks

PsSetCreateProcessNotifyRoutineEx (the "Ex" version, Windows Vista+) receives a PEPROCESS and PS_CREATE_NOTIFY_INFO struct. It fires on both creation and termination. The driver can block process creation by setting the CreationStatus field to a failure NTSTATUS:

// Registration
VOID MyProcessNotify(
    PEPROCESS           Process,
    HANDLE              ProcessId,
    PPS_CREATE_NOTIFY_INFO CreateInfo)   // NULL on exit
{
    if (CreateInfo == NULL) {
        // Process exiting
        DbgPrint("[EXIT] PID %llu\n", (ULONG64)ProcessId);
        return;
    }

    // Process being created
    if (CreateInfo->ImageFileName) {
        DbgPrint("[CREATE] %wZ PID=%llu ParentPID=%llu\n",
            CreateInfo->ImageFileName,
            (ULONG64)ProcessId,
            (ULONG64)CreateInfo->ParentProcessId);
    }

    // Block process creation: set CreationStatus to error
    // CreateInfo->CreationStatus = STATUS_ACCESS_DENIED;

    // Available fields in PS_CREATE_NOTIFY_INFO:
    //   ParentProcessId    — PID of parent
    //   CreatingThreadId   — TID of thread calling CreateProcess
    //   ImageFileName      — full kernel path (PUNICODE_STRING)
    //   CommandLine        — full command line (PUNICODE_STRING)
    //   FileOpenNameAvailable — set if ImageFileName is reliable
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DrvObj, PUNICODE_STRING RegPath) {
    // ...
    return PsSetCreateProcessNotifyRoutineEx(MyProcessNotify, FALSE);
    // Second param FALSE = register; TRUE = unregister
}

Thread Notify Callbacks

VOID MyThreadNotify(
    HANDLE ProcessId,
    HANDLE ThreadId,
    BOOLEAN Create)    // TRUE=create, FALSE=terminate
{
    if (Create) {
        // Cross-process thread creation: ProcessId != current PID
        // This is the CreateRemoteThread signal at kernel level
        HANDLE currentPid = PsGetCurrentProcessId();
        if (ProcessId != currentPid) {
            DbgPrint("[CROSS-PROC THREAD] Src=%llu Dst=%llu TID=%llu\n",
                (ULONG64)currentPid,
                (ULONG64)ProcessId,
                (ULONG64)ThreadId);
        }
    }
}

// Register:
PsSetCreateThreadNotifyRoutine(MyThreadNotify);
Detection value of thread callbacks

The thread notify callback fires for every new thread, including those created via CreateRemoteThread. By comparing the new thread's owning process PID with the current process context PID, the callback identifies cross-process thread injection at the kernel level — before any user-mode hooks can observe or interfere with it. This is why APC injection and thread hijacking techniques that work purely in Ring 3 cannot evade a properly deployed kernel thread callback.

Image Load Notify Callbacks

PsSetLoadImageNotifyRoutine fires when any PE image (EXE or DLL) is memory-mapped into a process. It provides the full image path, process context, and the IMAGE_INFO structure with base address and size. Critical for detecting reflective DLL injection: a reflectively loaded DLL will typically NOT trigger this callback (it allocates memory and manually loads without going through the normal mapped image path):

VOID MyImageNotify(
    PUNICODE_STRING FullImageName,  // kernel path, may be NULL
    HANDLE          ProcessId,
    PIMAGE_INFO     ImageInfo)
{
    // IMAGE_INFO fields:
    //   ImageBase    — VA where image was mapped
    //   ImageSize    — size of mapping
    //   SystemModeImage — set if image loaded into kernel (driver)

    if (ImageInfo->SystemModeImage) {
        // Kernel-mode driver being loaded
        DbgPrint("[DRIVER LOAD] %wZ Base=%p Size=%llu\n",
            FullImageName,
            ImageInfo->ImageBase,
            (ULONG64)ImageInfo->ImageSize);
        return;
    }

    // User-mode DLL loaded into process
    DbgPrint("[DLL LOAD] PID=%llu Base=%p %wZ\n",
        (ULONG64)ProcessId,
        ImageInfo->ImageBase,
        FullImageName);
}

PsSetLoadImageNotifyRoutine(MyImageNotify);

Object Callbacks (ObRegisterCallbacks)

Object callbacks intercept attempts to open handles to processes, threads, or desktop objects. They fire in both pre-operation (before access check) and post-operation (after handle is created) contexts. EDRs use object callbacks to strip sensitive access rights from handles granted to suspicious processes, implementing "handle stripping" — even if malware tries OpenProcess(PROCESS_ALL_ACCESS, ..., lsass), the object callback can reduce the rights to something that makes credential dumping fail:

// Object callback for process handles — strip memory-read rights
OB_PREOP_CALLBACK_STATUS PreOperationCallback(
    PVOID                          RegistrationContext,
    POB_PRE_OPERATION_INFORMATION  OperationInformation)
{
    if (OperationInformation->ObjectType != *PsProcessType)
        return OB_PREOP_SUCCESS;

    // Get target process
    PEPROCESS target = (PEPROCESS)OperationInformation->Object;
    HANDLE targetPid = PsGetProcessId(target);

    // Check if target is a protected process (e.g., lsass)
    if (IsProtectedProcess(targetPid)) {
        // Strip dangerous access rights from the handle
        ACCESS_MASK deniedRights =
            PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
            PROCESS_DUP_HANDLE | PROCESS_SUSPEND_RESUME;

        if (OperationInformation->Operation == OB_OPERATION_HANDLE_CREATE) {
            OperationInformation->Parameters->CreateHandleInformation
                .DesiredAccess &= ~deniedRights;
        } else { // OB_OPERATION_HANDLE_DUPLICATE
            OperationInformation->Parameters->DuplicateHandleInformation
                .DesiredAccess &= ~deniedRights;
        }
    }
    return OB_PREOP_SUCCESS;
}

// Registration structure
OB_OPERATION_REGISTRATION opReg = {
    .ObjectType         = PsProcessType,
    .Operations         = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE,
    .PreOperation       = PreOperationCallback,
    .PostOperation      = NULL
};
OB_CALLBACK_REGISTRATION cbReg = {
    .Version            = OB_FLT_REGISTRATION_VERSION,
    .OperationRegistrationCount = 1,
    .Altitude           = RTL_CONSTANT_STRING(L"321000"),
    .RegistrationContext= NULL,
    .OperationRegistration = &opReg
};
ObRegisterCallbacks(&cbReg, &g_ObHandle);

Registry Callbacks (CmRegisterCallback)

CmRegisterCallback registers a kernel function that fires on every registry operation (open, create, delete, query, set value). Each operation has a pre- and post-notification version. Registry callbacks are used by security products to monitor and block registry modifications that indicate persistence:

NTSTATUS RegistryCallback(
    PVOID  CallbackContext,
    PVOID  Argument1,    // REG_NOTIFY_CLASS enum value
    PVOID  Argument2)    // operation-specific struct
{
    REG_NOTIFY_CLASS notifyClass = (REG_NOTIFY_CLASS)Argument1;

    switch (notifyClass) {
    case RegNtPreSetValueKey: {
        PREG_SET_VALUE_KEY_INFORMATION info =
            (PREG_SET_VALUE_KEY_INFORMATION)Argument2;
        DbgPrint("[REG SET] %wZ\n", info->ValueName);
        // Could inspect info->Data, info->DataSize, info->Type
        // Return STATUS_ACCESS_DENIED to block the write
        break;
    }
    case RegNtPreCreateKey: {
        PREG_CREATE_KEY_INFORMATION info =
            (PREG_CREATE_KEY_INFORMATION)Argument2;
        DbgPrint("[REG CREATE] %wZ\n", info->CompleteName);
        break;
    }
    default:
        break;
    }
    return STATUS_SUCCESS;  // SUCCESS = allow; error = block
}

LARGE_INTEGER g_CmCookie;
CmRegisterCallback(RegistryCallback, NULL, &g_CmCookie);

Callback Suppression (Rootkit Technique)

PspCreateProcessNotifyRoutine[] array structure (simplified):

  [0] = 0xFFFFF80012340001  (low bit set = EX_CALLBACK_ROUTINE_BLOCK)
  [1] = 0xFFFFF80056780001  (another registered callback)
  [2] = 0x0000000000000000  (empty slot)
  ...
  [63]= 0x0000000000000000

  Rootkit DKOM suppression:
    1. Find PspCreateProcessNotifyRoutine array (via signature scan in ntoskrnl)
    2. Read each non-zero entry
    3. Clear the low bit → get pointer to EX_CALLBACK_ROUTINE_BLOCK
    4. From that block, get function pointer
    5. Compare to target EDR driver base range → zero out the entry

  Result: EDR's process creation callback silently removed.
  EDR sees no notification of any new process creation.

  PspCreateThreadNotifyRoutine[] — same structure, same attack
  PspLoadImageNotifyRoutine[]    — same structure, 8 entries max
Detecting callback tampering

Security tools monitoring from a higher-privilege context (another kernel driver, a VMM/VBS layer) can enumerate the PspCreateProcessNotifyRoutine array and compare it against a known-good baseline. Velociraptor's Windows.KapeFiles.Targets and Volatility's callbacks plugin both enumerate kernel callbacks. Sudden removal of an EDR's entry from the callback array (without a corresponding driver unload event) is a strong indicator of DKOM-based callback suppression. HVCI/VBS complicates suppression: if the kernel is HVCI-protected, writing to data structures that are in read-only VTL1-enforced pages is blocked at the hardware level.

Q & A

Can an EDR's process create callback fire before a process's first thread executes any code?

Yes — and this is precisely the advantage that kernel callbacks have over user-mode DLL injection. The PsSetCreateProcessNotifyRoutineEx callback fires during NtCreateUserProcess execution, specifically at a point after the process object and initial thread are created but before the initial thread is actually allowed to begin executing. The sequence inside NtCreateUserProcess is roughly: (1) kernel process object created, (2) kernel thread object created, (3) PspCreateProcessNotifyRoutine callbacks called → EDR's callback fires here, (4) initial thread is allowed to run (first instruction in ntdll's process startup path). This means the EDR kernel callback gets to inspect (and optionally block) the process before it has executed a single instruction. The EDR can read the image file from the notification, hash it, look it up against a blocklist, and return STATUS_ACCESS_DENIED in CreationStatus to prevent the process from ever starting. This is entirely impossible from user mode — user-mode DLL injection hooks fire only after the process is already running and the DLL loader has initialized, meaning there is a window where malicious code could run before the hook is in place. Kernel callbacks close that window completely.

What is an altitude and why does it matter for object callbacks and minifilters?

An altitude is a numeric string that defines the stack position of a filter driver or object callback relative to others. When multiple drivers register callbacks for the same event (e.g., multiple EDR products plus Windows Defender all register ObRegisterCallbacks for process handles), they form a call chain. The altitude determines the order: a driver with altitude "400000" fires before a driver with altitude "200000". For ObRegisterCallbacks, the altitude must be a unique numeric string registered in a Microsoft-maintained database — drivers must apply for an altitude to avoid conflicts. For minifilters (file system filter drivers, chapter 44), altitudes are strictly assigned: backup tools operate in the 300,000–329,999 range, AV scanners in 320,000–329,999, encryption filters in 140,000–149,999. Why it matters for security: A rootkit that registers an object callback at a very high altitude (fires first in the chain) can observe an access request and modify it before the EDR's callback sees it. More critically for the attack direction: the callback chain also runs in reverse for post-operations — the driver registered last (highest altitude) runs its post-operation first. This stack ordering means a well-designed rootkit that loads after the EDR could potentially modify results seen by lower-altitude callbacks. In practice, HVCI and driver signing requirements limit what drivers can register, but understanding altitude ordering is essential for reasoning about multi-vendor endpoint security product interactions.