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.
| Routine | Fires on | Can block? |
|---|---|---|
PsSetCreateProcessNotifyRoutine | process create and process exit | no |
PsSetCreateProcessNotifyRoutineEx | process create and process exit | yes — set CreationStatus |
PsSetCreateThreadNotifyRoutine | thread create and thread exit, system-wide | no |
PsSetLoadImageNotifyRoutine | every image mapped into any process | no |
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.
DbgPrintoutput 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.
PsSetCreateProcessNotifyRoutineExadditionally requires the driver image to be linked with/integritycheck; without it the registration fails withSTATUS_ACCESS_DENIEDand 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.
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.
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.
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”.
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.