daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

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