DFIR^:DFIR

Kernel Notification Callbacks

PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine and PsSetLoadImageNotifyRoutine: the four callbacks an EDR sensor registers, what each one sees, and what it cannot see.

advancedupdated 2026-10-04windbg · visual-studio · debugview · wdk

What this is

When an endpoint product tells you “process x created process y and then created a thread in process z”, four kernel callbacks produced that sentence. They are documented, anyone can register them from a signed driver, and knowing their exact semantics is how you reason about what your sensor can and cannot have seen.

RoutineFires onCan block?
PsSetCreateProcessNotifyRoutineprocess create and process exitno
PsSetCreateProcessNotifyRoutineExprocess create and process exityes — set CreationStatus
PsSetCreateThreadNotifyRoutinethread create and thread exit, system-wideno
PsSetLoadImageNotifyRoutineevery image mapped into any processno

These are notifications, not hooks. The kernel calls you at a defined point in its own operation; you are not intercepting a call or patching a table. That is the whole reason they are the sanctioned mechanism and SSDT patching is not — see SSDT and IDT for the historical alternative and why it died.

Prerequisites

  • WDK plus the matching Visual Studio workload. A WDM driver is the smaller target for this; KMDF works too.
  • A debuggee you are willing to bugcheck. A callback that dereferences a bad pointer takes the machine down, and an unload path that forgets to deregister takes it down later.
  • DbgPrint output visible — see the kernel debugging lab for the kdnet transport, ed kd_default_mask 0xf, and the DebugView registry filter.
  • Test signing or a loader, since the driver is unsigned. PsSetCreateProcessNotifyRoutineEx additionally requires the driver image to be linked with /integritycheck; without it the registration fails with STATUS_ACCESS_DENIED and the usual mistake is to assume the callback is simply not firing.

Walkthrough

Process creation and exit

VOID ProcessNotify(HANDLE parentId, HANDLE processId, BOOLEAN create)
{
    if (create)
        DbgPrint("process %llu created by %llu\n", (ULONG64)processId, (ULONG64)parentId);
    else
        DbgPrint("process %llu exited\n", (ULONG64)processId);
}

// in DriverEntry
PsSetCreateProcessNotifyRoutine(ProcessNotify, FALSE);
// in the unload routine — the TRUE removes it
PsSetCreateProcessNotifyRoutine(ProcessNotify, TRUE);

The second argument is Remove, so the same call registers and deregisters. Forgetting the deregistration on unload leaves the kernel with a pointer into freed driver memory, and the bugcheck arrives at the next process creation rather than at unload, which makes it a confusing one to diagnose.

Note what this gives you: parent PID, child PID, and a create/exit flag. No image path and no command line.

Debug output showing a process creation notification naming the new PID and its parent PowerShell PID, followed by the matching exit notification
Create and exit notifications for a child process, parent PID included. ired.team · Mantvydas Baranauskas

Image loads

VOID ImageNotify(PUNICODE_STRING fullImageName, HANDLE processId, PIMAGE_INFO imageInfo)
{
    DbgPrint("pid %llu mapped %wZ at %p (%s)\n",
             (ULONG64)processId, fullImageName, imageInfo->ImageBase,
             imageInfo->SystemModeImage ? "kernel" : "user");
}

PsSetLoadImageNotifyRoutine(ImageNotify);

One callback for every image mapped anywhere, user mode and kernel mode both. IMAGE_INFO carries ImageBase, ImageSize, SystemModeImage and the image signature level — so this is where a sensor learns that an unsigned DLL just appeared inside a signed process.

Debug output listing every module notepad.exe maps as it starts, each with its image path and base address
Every module one process maps during start-up, with path and base. ired.team · Mantvydas Baranauskas

A caveat that matters for detection: the callback fires while the image is being mapped, and the full image name is not guaranteed to be resolvable at that point for every image. Code that assumes a usable path for all of them produces gaps.

Thread creation and exit

VOID ThreadNotify(HANDLE processId, HANDLE threadId, BOOLEAN create)
{
    DbgPrint("thread %llu in process %llu %s\n",
             (ULONG64)threadId, (ULONG64)processId, create ? "created" : "exited");
}

PsSetCreateThreadNotifyRoutine(ThreadNotify);

System-wide and high volume. The callback gives you the owning PID and the thread ID; it does not hand you the thread’s start address, so a sensor that reports “thread started outside any module” is resolving that itself after being notified.

Debug output stream of thread creation and thread exit notifications across multiple process IDs on the system
Thread create and exit across every process on the host. ired.team · Mantvydas Baranauskas

The Ex variant, and blocking

PsSetCreateProcessNotifyRoutineEx receives a PPS_CREATE_NOTIFY_INFO instead of a bare parent PID, and that structure is where the useful fields live: ImageFileName, CommandLine, FileObject, the creating process and thread IDs, and CreationStatus. Writing a failure code into CreationStatus makes the creation fail.

VOID ProcessNotifyEx(PEPROCESS process, HANDLE processId, PPS_CREATE_NOTIFY_INFO info)
{
    UNREFERENCED_PARAMETER(process);
    if (info == NULL) return;              // NULL means exit, not create

    DbgPrint("pid %llu image %wZ cmdline %wZ\n",
             (ULONG64)processId, info->ImageFileName, info->CommandLine);

    if (ShouldBlock(info->CommandLine))
        info->CreationStatus = STATUS_ACCESS_DENIED;
}

PsSetCreateProcessNotifyRoutineEx(ProcessNotifyEx, FALSE);

Two sharp edges. info is NULL on process exit, so the early return is mandatory, not defensive. And the block happens before the process’s first instruction executes, which is what makes this the mechanism behind “the agent prevented that from launching” rather than “the agent killed it afterwards”.

A process launch attempt failing with an access denied error because the driver set CreationStatus in its create-process callback
Creation denied from the callback — the process never runs. ired.team · Mantvydas Baranauskas

What it tells you

This is where your telemetry comes from, and its shape explains your telemetry’s shape. The fields in PS_CREATE_NOTIFY_INFO are, almost exactly, the fields in a Sysmon event ID 1. That is not a coincidence: a sensor can only report what the callback gave it. When an event in your SIEM lacks a field, the first question is whether the callback carries it at all.

The parent PID is reported, and it is not the same as the launching process. The callback hands you the creating process at the moment of creation. A process created with an explicitly reparented parent handle reports that parent, because from the kernel’s view it is the parent. So “unusual parent” detections built on this data are detecting what the kernel was told, and parent spoofing shows up as a parent-child pair that is wrong in a different way — a parent that never ran the child’s binary, a parent that had already exited. Correlate against the creating thread ID, which PS_CREATE_NOTIFY_INFO also carries and which is harder to make consistent with a forged parent.

Image load is your best cross-process code-execution signal and it has a blind spot. The callback sees everything the loader maps. It does not see code that was never mapped by the loader — memory allocated and written directly. So an injection that calls LoadLibrary in the target generates an image-load event with a path you can act on, while one that maps a PE manually generates nothing here at all. The techniques under process hollowing and the manual-mapping variants of DLL injection are invisible to this callback specifically, which is why the thread and memory checks in injected-thread hunting exist.

Thread creation covers cross-process thread starts, and nothing else about them. You get notified that a thread appeared in a process. Pairing that with the handle that was used to create it requires an object-callback registration (ObRegisterCallbacks) for process-handle opens, which is a separate mechanism — which is why “who opened a handle to lsass” and “a thread started in lsass” are two different events from two different registrations. See handle enumeration for the user-mode way to get the same relationship after the fact.

A thread that is not created is not notified. Hijacking an existing thread produces no thread-create callback, so this mechanism never sees thread execution hijacking. The visible artefact there is the handle access mask and the memory layout, not the notification stream.

Callback registration is itself a thing to inventory. The set of drivers holding process, thread and image callbacks on a host is small and known. A driver you do not recognise in that set is either a product you forgot about or a problem; a missing callback where your product should have one means your sensor has been unloaded or its registration removed, and the events simply stop with no error anywhere.

References

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.