kernel-notification-callbacks.md (13122B)
1 --- 2 title: "Kernel Notification Callbacks" 3 description: "PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine and PsSetLoadImageNotifyRoutine: the four callbacks an EDR sensor registers, what each one sees, and what it cannot see." 4 category: dfir 5 subcategory: "Windows Internals" 6 tags: [windows, kernel, detection-engineering, edr, telemetry] 7 tools: [windbg, visual-studio, debugview, wdk] 8 difficulty: advanced 9 updated: 2026-10-04 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/subscribing-to-process-creation-thread-creation-and-image-load-notifications-from-a-kernel-driver" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "PsSetCreateProcessNotifyRoutineEx — Microsoft Learn" 17 url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreateprocessnotifyroutineex" 18 relation: inspired 19 note: "Callback signature, the PS_CREATE_NOTIFY_INFO fields, and the /integritycheck requirement." 20 - name: "PsSetLoadImageNotifyRoutine — Microsoft Learn" 21 url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetloadimagenotifyroutine" 22 relation: inspired 23 note: "Image-load callback signature and the IMAGE_INFO fields." 24 - name: "PsSetCreateThreadNotifyRoutine — Microsoft Learn" 25 url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreatethreadnotifyroutine" 26 relation: inspired 27 note: "Thread-creation callback signature and its Create flag semantics." 28 --- 29 30 ## What this is 31 32 When an endpoint product tells you "process `x` created process `y` and then created a thread 33 in process `z`", four kernel callbacks produced that sentence. They are documented, anyone 34 can register them from a signed driver, and knowing their exact semantics is how you reason 35 about what your sensor can and cannot have seen. 36 37 | Routine | Fires on | Can block? | 38 |---|---|---| 39 | `PsSetCreateProcessNotifyRoutine` | process create and process exit | no | 40 | `PsSetCreateProcessNotifyRoutineEx` | process create and process exit | yes — set `CreationStatus` | 41 | `PsSetCreateThreadNotifyRoutine` | thread create and thread exit, system-wide | no | 42 | `PsSetLoadImageNotifyRoutine` | every image mapped into any process | no | 43 44 These are notifications, not hooks. The kernel calls you at a defined point in its own 45 operation; you are not intercepting a call or patching a table. That is the whole reason they 46 are the sanctioned mechanism and SSDT patching is not — see 47 [SSDT and IDT](/sheets/dfir/ssdt-and-idt) for the historical alternative and why it died. 48 49 ## Prerequisites 50 51 - WDK plus the matching Visual Studio workload. A WDM driver is the smaller target for this; 52 KMDF works too. 53 - A debuggee you are willing to bugcheck. A callback that dereferences a bad pointer takes 54 the machine down, and an unload path that forgets to deregister takes it down later. 55 - `DbgPrint` output visible — see [the kernel debugging lab](/sheets/dfir/kernel-debugging-lab) 56 for the kdnet transport, `ed kd_default_mask 0xf`, and the DebugView registry filter. 57 - Test signing or a loader, since the driver is unsigned. `PsSetCreateProcessNotifyRoutineEx` 58 additionally requires the driver image to be linked with `/integritycheck`; without it the 59 registration fails with `STATUS_ACCESS_DENIED` and the usual mistake is to assume the 60 callback is simply not firing. 61 62 ## Walkthrough 63 64 ### Process creation and exit 65 66 ```c 67 VOID ProcessNotify(HANDLE parentId, HANDLE processId, BOOLEAN create) 68 { 69 if (create) 70 DbgPrint("process %llu created by %llu\n", (ULONG64)processId, (ULONG64)parentId); 71 else 72 DbgPrint("process %llu exited\n", (ULONG64)processId); 73 } 74 75 // in DriverEntry 76 PsSetCreateProcessNotifyRoutine(ProcessNotify, FALSE); 77 // in the unload routine — the TRUE removes it 78 PsSetCreateProcessNotifyRoutine(ProcessNotify, TRUE); 79 ``` 80 81 The second argument is `Remove`, so the same call registers and deregisters. Forgetting the 82 deregistration on unload leaves the kernel with a pointer into freed driver memory, and the 83 bugcheck arrives at the next process creation rather than at unload, which makes it a 84 confusing one to diagnose. 85 86 Note what this gives you: parent PID, child PID, and a create/exit flag. No image path and 87 no command line. 88 89 <figure class="shot"> 90 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetCreateProcessNotifyRoutine.gif" 91 alt="Debug output showing a process creation notification naming the new PID and its parent PowerShell PID, followed by the matching exit notification" 92 loading="lazy" referrerpolicy="no-referrer"> 93 <figcaption>Create and exit notifications for a child process, parent PID included. 94 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 95 </figcaption> 96 </figure> 97 98 ### Image loads 99 100 ```c 101 VOID ImageNotify(PUNICODE_STRING fullImageName, HANDLE processId, PIMAGE_INFO imageInfo) 102 { 103 DbgPrint("pid %llu mapped %wZ at %p (%s)\n", 104 (ULONG64)processId, fullImageName, imageInfo->ImageBase, 105 imageInfo->SystemModeImage ? "kernel" : "user"); 106 } 107 108 PsSetLoadImageNotifyRoutine(ImageNotify); 109 ``` 110 111 One callback for every image mapped anywhere, user mode and kernel mode both. `IMAGE_INFO` 112 carries `ImageBase`, `ImageSize`, `SystemModeImage` and the image signature level — so this 113 is where a sensor learns that an unsigned DLL just appeared inside a signed process. 114 115 <figure class="shot"> 116 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetLoadImageNotifyRoutine.gif" 117 alt="Debug output listing every module notepad.exe maps as it starts, each with its image path and base address" 118 loading="lazy" referrerpolicy="no-referrer"> 119 <figcaption>Every module one process maps during start-up, with path and base. 120 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 121 </figcaption> 122 </figure> 123 124 A caveat that matters for detection: the callback fires while the image is being mapped, and 125 the full image name is not guaranteed to be resolvable at that point for every image. Code 126 that assumes a usable path for all of them produces gaps. 127 128 ### Thread creation and exit 129 130 ```c 131 VOID ThreadNotify(HANDLE processId, HANDLE threadId, BOOLEAN create) 132 { 133 DbgPrint("thread %llu in process %llu %s\n", 134 (ULONG64)threadId, (ULONG64)processId, create ? "created" : "exited"); 135 } 136 137 PsSetCreateThreadNotifyRoutine(ThreadNotify); 138 ``` 139 140 System-wide and high volume. The callback gives you the owning PID and the thread ID; it does 141 not hand you the thread's start address, so a sensor that reports "thread started outside any 142 module" is resolving that itself after being notified. 143 144 <figure class="shot"> 145 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28529%29.png" 146 alt="Debug output stream of thread creation and thread exit notifications across multiple process IDs on the system" 147 loading="lazy" referrerpolicy="no-referrer"> 148 <figcaption>Thread create and exit across every process on the host. 149 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 150 </figcaption> 151 </figure> 152 153 ### The Ex variant, and blocking 154 155 `PsSetCreateProcessNotifyRoutineEx` receives a `PPS_CREATE_NOTIFY_INFO` instead of a bare 156 parent PID, and that structure is where the useful fields live: `ImageFileName`, 157 `CommandLine`, `FileObject`, the creating process and thread IDs, and `CreationStatus`. 158 Writing a failure code into `CreationStatus` makes the creation fail. 159 160 ```c 161 VOID ProcessNotifyEx(PEPROCESS process, HANDLE processId, PPS_CREATE_NOTIFY_INFO info) 162 { 163 UNREFERENCED_PARAMETER(process); 164 if (info == NULL) return; // NULL means exit, not create 165 166 DbgPrint("pid %llu image %wZ cmdline %wZ\n", 167 (ULONG64)processId, info->ImageFileName, info->CommandLine); 168 169 if (ShouldBlock(info->CommandLine)) 170 info->CreationStatus = STATUS_ACCESS_DENIED; 171 } 172 173 PsSetCreateProcessNotifyRoutineEx(ProcessNotifyEx, FALSE); 174 ``` 175 176 Two sharp edges. `info` is `NULL` on process exit, so the early return is mandatory, not 177 defensive. And the block happens before the process's first instruction executes, which is 178 what makes this the mechanism behind "the agent prevented that from launching" rather than 179 "the agent killed it afterwards". 180 181 <figure class="shot"> 182 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetCreateProcessNotifyRoutineEx.gif" 183 alt="A process launch attempt failing with an access denied error because the driver set CreationStatus in its create-process callback" 184 loading="lazy" referrerpolicy="no-referrer"> 185 <figcaption>Creation denied from the callback — the process never runs. 186 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 187 </figcaption> 188 </figure> 189 190 ## What it tells you 191 192 **This is where your telemetry comes from, and its shape explains your telemetry's shape.** 193 The fields in `PS_CREATE_NOTIFY_INFO` are, almost exactly, the fields in a Sysmon event ID 1. 194 That is not a coincidence: a sensor can only report what the callback gave it. When an event 195 in your SIEM lacks a field, the first question is whether the callback carries it at all. 196 197 **The parent PID is reported, and it is not the same as the launching process.** The 198 callback hands you the creating process at the moment of creation. A process created with an 199 explicitly reparented parent handle reports that parent, because from the kernel's view it 200 *is* the parent. So "unusual parent" detections built on this data are detecting what the 201 kernel was told, and parent spoofing shows up as a parent-child pair that is wrong in a 202 different way — a parent that never ran the child's binary, a parent that had already exited. 203 Correlate against the creating *thread* ID, which `PS_CREATE_NOTIFY_INFO` also carries and 204 which is harder to make consistent with a forged parent. 205 206 **Image load is your best cross-process code-execution signal and it has a blind spot.** The 207 callback sees everything the loader maps. It does not see code that was never mapped by the 208 loader — memory allocated and written directly. So an injection that calls `LoadLibrary` in 209 the target generates an image-load event with a path you can act on, while one that maps a 210 PE manually generates nothing here at all. The techniques under 211 [process hollowing](/sheets/exploitation/process-hollowing) and the manual-mapping variants 212 of [DLL injection](/sheets/exploitation/dll-injection) are invisible to this callback 213 specifically, which is why the thread and memory checks in 214 [injected-thread hunting](/sheets/dfir/injected-thread-hunting) exist. 215 216 **Thread creation covers cross-process thread starts, and nothing else about them.** You get 217 notified that a thread appeared in a process. Pairing that with the handle that was used to 218 create it requires an object-callback registration (`ObRegisterCallbacks`) for process-handle 219 opens, which is a separate mechanism — which is why "who opened a handle to lsass" and 220 "a thread started in lsass" are two different events from two different registrations. See 221 [handle enumeration](/sheets/dfir/handle-enumeration) for the user-mode way to get the same 222 relationship after the fact. 223 224 **A thread that is not created is not notified.** Hijacking an existing thread produces no 225 thread-create callback, so this mechanism never sees 226 [thread execution hijacking](/sheets/exploitation/thread-execution-hijacking). The visible 227 artefact there is the handle access mask and the memory layout, not the notification stream. 228 229 **Callback registration is itself a thing to inventory.** The set of drivers holding process, 230 thread and image callbacks on a host is small and known. A driver you do not recognise in 231 that set is either a product you forgot about or a problem; a *missing* callback where your 232 product should have one means your sensor has been unloaded or its registration removed, and 233 the events simply stop with no error anywhere. 234 235 ## References 236 237 - [PsSetCreateProcessNotifyRoutineEx — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreateprocessnotifyroutineex) 238 - [PsSetLoadImageNotifyRoutine — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetloadimagenotifyroutine) 239 - [PsSetCreateThreadNotifyRoutine — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreatethreadnotifyroutine) 240 - [PS_CREATE_NOTIFY_INFO](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_ps_create_notify_info) 241 - [ETW as a telemetry source](/sheets/dfir/etw-telemetry) — the same events without a driver