The four callbacks an EDR kernel driver uses
EDR kernel drivers are built around four notification APIs. Each covers a different event surface. None covers all of them. Understanding the gaps is the same as understanding the evasion primitives.
This post goes through each callback: registration, signature, a minimal implementation, and the miss that matters.
1. PsSetCreateProcessNotifyRoutineEx
Registration:
NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(ProcessNotifyCallback, FALSE);Second argument is Remove. Pass FALSE to register, TRUE to deregister.
Callback signature:
VOID ProcessNotifyCallback(
PEPROCESS Process,
HANDLE ProcessId,
PPS_CREATE_NOTIFY_INFO CreateInfo
);CreateInfo is non-NULL on creation, NULL on exit. The interesting fields:
typedef struct _PS_CREATE_NOTIFY_INFO {
SIZE_T Size;
union {
ULONG Flags;
struct {
ULONG FileOpenNameAvailable : 1;
ULONG IsSubsystemProcess : 1;
ULONG Reserved : 30;
};
};
HANDLE ParentProcessId;
CLIENT_ID CreatingThreadId;
struct _FILE_OBJECT *FileObject;
PCUNICODE_STRING ImageFileName; // full NT path if FileOpenNameAvailable
PCUNICODE_STRING CommandLine; // NULL if not a Win32 process
NTSTATUS CreationStatus; // set this to deny creation
} PS_CREATE_NOTIFY_INFO, *PPS_CREATE_NOTIFY_INFO;The Ex variant is what you want. The non-Ex PsSetCreateProcessNotifyRoutine fires on both creation and exit via a BOOLEAN Create flag, but gives you only three parameters: parent PID, process ID, and that flag. No CommandLine, no PS_CREATE_NOTIFY_INFO, no ability to deny creation. Don’t use the non-Ex version.
Minimal implementation:
VOID
ProcessNotifyCallback(
PEPROCESS Process,
HANDLE ProcessId,
PPS_CREATE_NOTIFY_INFO CreateInfo
)
{
UNREFERENCED_PARAMETER(Process);
if (!CreateInfo) {
// Exit notification. ProcessId is the terminating process.
DbgPrint("[edr] process exit PID=%llu\n", (ULONG64)(ULONG_PTR)ProcessId);
return;
}
// Creation path.
DbgPrint("[edr] process create PID=%llu ParentPID=%llu\n",
(ULONG64)(ULONG_PTR)ProcessId,
(ULONG64)(ULONG_PTR)CreateInfo->ParentProcessId);
if (CreateInfo->CommandLine) {
DbgPrint("[edr] cmdline: %wZ\n", CreateInfo->CommandLine);
}
if (CreateInfo->ImageFileName) {
DbgPrint("[edr] image: %wZ\n", CreateInfo->ImageFileName);
}
// Block process creation -- return before this to allow it.
// CreateInfo->CreationStatus = STATUS_ACCESS_DENIED;
}The miss. The callback fires during initial thread insertion (PspInsertThread) — EPROCESS and ETHREAD both exist, but the thread has not been scheduled yet. You can deny creation by setting CreateInfo->CreationStatus. What you can’t do: see the initial loaded sections or inspect the mapped image. A process created with CREATE_SUSPENDED and then injected via APC or NtQueueApcThread before resuming bypasses your image-load checks entirely, because you made your allow/deny decision before any image notification fired.
The classic Early Bird pattern does exactly this. Process injection with Early Bird APC covers the user-mode side. From the kernel perspective, your process callback sees a notepad.exe spawn with a clean command line, allows it, and then the thread notification fires. By then you’re deciding on a thread whose start address may already point at shellcode queued via APC.
2. PsSetCreateThreadNotifyRoutine
Registration:
NTSTATUS status = PsSetCreateThreadNotifyRoutine(ThreadNotifyCallback);Deregistration:
PsRemoveCreateThreadNotifyRoutine(ThreadNotifyCallback);Callback signature:
VOID ThreadNotifyCallback(HANDLE ProcessId, HANDLE ThreadId, BOOLEAN Create);Create is TRUE on creation, FALSE on exit. The callback fires synchronously in the context of the thread that triggered the creation syscall, before the new thread is scheduled.
The API gives you nothing except three IDs and a direction flag. To get the start address, you have to open the thread yourself:
VOID
ThreadNotifyCallback(
HANDLE ProcessId,
HANDLE ThreadId,
BOOLEAN Create
)
{
if (!Create) return;
NTSTATUS status;
HANDLE hThread = NULL;
OBJECT_ATTRIBUTES oa;
CLIENT_ID cid;
InitializeObjectAttributes(&oa, NULL, OBJ_KERNEL_HANDLE, NULL, NULL);
cid.UniqueProcess = ProcessId;
cid.UniqueThread = ThreadId;
status = ZwOpenThread(&hThread, THREAD_QUERY_INFORMATION, &oa, &cid);
if (!NT_SUCCESS(status)) return;
// ThreadQuerySetWin32StartAddress (class 9): the Win32-layer start address.
// This is distinct from ThreadBasicInformation.StartAddress (class 0).
// On a spoofed thread, these two values diverge.
PVOID win32Start = NULL;
ULONG retLen = 0;
status = ZwQueryInformationThread(hThread,
(THREADINFOCLASS)9, // ThreadQuerySetWin32StartAddress
&win32Start,
sizeof(win32Start),
&retLen);
ZwClose(hThread);
if (!NT_SUCCESS(status)) return;
DbgPrint("[edr] thread create PID=%llu TID=%llu win32start=%p\n",
(ULONG64)(ULONG_PTR)ProcessId,
(ULONG64)(ULONG_PTR)ThreadId,
win32Start);
}The miss. ThreadQuerySetWin32StartAddress is the Win32 start address field stored in the ETHREAD. It is explicitly settable. An attacker using NtCreateThreadEx can pass any value as the start address in a way that puts a legitimate function address there while the real payload is reached through an ROP chain, APC, or hardware breakpoint trick. ThreadBasicInformation.StartAddress (class 0) gives you the kernel-level initial Rip, which can also be spoofed by setting context directly after creation.
The thread start address inspection post goes into the full inspection pipeline, including ZwGetContextThread at creation time to capture the actual Rip before the thread is scheduled, and the pattern for detecting divergence between the two start-address values.
Common bug: the ZwTerminateProcess type error. If you decide to terminate the offending process from this callback, the natural instinct is to call ZwTerminateProcess with the PEPROCESS. This is wrong. ZwTerminateProcess takes a HANDLE. Passing a PEPROCESS as a HANDLE produces undefined behavior: the value is interpreted as a handle index, which may refer to a completely different object in the kernel handle table, or crash.
The correct pattern:
NTSTATUS
TerminateProcess(HANDLE ProcessId)
{
PEPROCESS proc = NULL;
HANDLE hProc = NULL;
NTSTATUS status;
status = PsLookupProcessByProcessId(ProcessId, &proc);
if (!NT_SUCCESS(status)) return status;
// Convert PEPROCESS -> HANDLE.
// OBJ_KERNEL_HANDLE: valid at any IRQL without attaching to the target process.
status = ObOpenObjectByPointer(
proc,
OBJ_KERNEL_HANDLE,
NULL,
PROCESS_TERMINATE,
*PsProcessType,
KernelMode,
&hProc);
// ObOpenObjectByPointer takes its own reference.
// Release the PsLookupProcessByProcessId reference unconditionally.
ObDereferenceObject(proc);
if (!NT_SUCCESS(status)) return status;
status = ZwTerminateProcess(hProc, STATUS_SUCCESS);
ZwClose(hProc);
return status;
}The same bug applies to threads: ZwTerminateThread(PETHREAD, ...) is wrong. Use ObOpenObjectByPointer with *PsThreadType and THREAD_TERMINATE, then call ZwTerminateThread(hThread, ...).
3. PsSetLoadImageNotifyRoutine
Registration:
NTSTATUS status = PsSetLoadImageNotifyRoutine(LoadImageNotifyCallback);Deregistration:
PsRemoveLoadImageNotifyRoutine(LoadImageNotifyCallback);Callback signature:
VOID LoadImageNotifyCallback(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
);IMAGE_INFO:
typedef struct _IMAGE_INFO {
union {
ULONG Properties;
struct {
ULONG ImageAddressingMode : 8;
ULONG SystemModeImage : 1; // 1 = kernel-mode image
ULONG ImageMappedToAllPids : 1;
ULONG ExtendedInfoPresent : 1;
ULONG MachineTypeMismatch : 1;
ULONG ImageSignatureLevel : 4;
ULONG ImageSignatureType : 3;
ULONG ImagePartialMap : 1;
ULONG Reserved : 12;
};
};
PVOID ImageBase;
ULONG ImageSelector;
SIZE_T ImageSize;
ULONG ImageSectionNumber;
} IMAGE_INFO, *PIMAGE_INFO;The callback fires after the image is mapped and committed, but before any code in the image runs. SystemModeImage distinguishes kernel-mode from user-mode loads. ImageBase and ImageSize give you the VA range in the target process.
Minimal implementation:
VOID
LoadImageNotifyCallback(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
)
{
if (!FullImageName || !FullImageName->Buffer) return;
DbgPrint("[edr] image load PID=%llu base=%p size=%zu km=%d name=%wZ\n",
(ULONG64)(ULONG_PTR)ProcessId,
ImageInfo->ImageBase,
ImageInfo->ImageSize,
(int)ImageInfo->SystemModeImage,
FullImageName);
// Extended info is available if ExtendedInfoPresent is set.
// Cast to IMAGE_INFO_EX to get the FileObject.
if (ImageInfo->ExtendedInfoPresent) {
PIMAGE_INFO_EX ext = CONTAINING_RECORD(ImageInfo, IMAGE_INFO_EX, ImageInfo);
if (ext->FileObject) {
// ext->FileObject->FileName gives you another path representation.
}
}
}IMAGE_INFO_EX is available when ExtendedInfoPresent == 1. It wraps IMAGE_INFO with a FileObject pointer, which gives you a second path reference via FileObject->FileName. This is useful when FullImageName is NULL (it can be NULL for kernel images loaded very early, and for some driver loads).
The miss. The callback fires when the PE section object is created and mapped. It requires the PE to go through the normal section-object path: NtMapViewOfSection via NtCreateSection. Reflective DLL injection doesn’t use this path. It allocates memory with VirtualAlloc, copies a PE manually into that region, resolves imports, applies relocations, and calls the entry point. No section object is created. No PsSetLoadImageNotifyRoutine callback fires. The image exists only as anonymous private memory.
Manual mapping through NtMapViewOfSection with a section created from a file will fire the callback. Manual mapping directly from a memory buffer will not.
This is why the process callback and image-load callback need to work together. Process callback provides early signal and allow/deny. Image-load callback provides DLL visibility. Thread callback provides execution-point inspection. None of them alone closes the surface.
4. ObRegisterCallbacks
Registration is more involved than the others. You build two structures:
NTSTATUS
RegisterObjectCallbacks(PVOID *RegistrationHandle)
{
UNICODE_STRING altitude;
RtlInitUnicodeString(&altitude, L"321000");
// Register for both process and thread handle operations.
OB_OPERATION_REGISTRATION opRegs[2] = { 0 };
opRegs[0].ObjectType = PsProcessType;
opRegs[0].Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opRegs[0].PreOperation = ProcessHandlePreOp;
opRegs[0].PostOperation = ProcessHandlePostOp;
opRegs[1].ObjectType = PsThreadType;
opRegs[1].Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opRegs[1].PreOperation = ThreadHandlePreOp;
opRegs[1].PostOperation = ThreadHandlePostOp;
OB_CALLBACK_REGISTRATION reg = { 0 };
reg.Version = OB_FLT_REGISTRATION_VERSION;
reg.OperationRegistrationCount = 2;
reg.Altitude = altitude;
reg.RegistrationContext = NULL;
reg.OperationRegistration = opRegs;
return ObRegisterCallbacks(®, RegistrationHandle);
}Pre-operation callback for process handles:
OB_PREOP_CALLBACK_STATUS
ProcessHandlePreOp(
PVOID Context,
POB_PRE_OPERATION_INFORMATION Info
)
{
UNREFERENCED_PARAMETER(Context);
// Skip kernel-mode callers -- system components need unfiltered access.
if (Info->KernelHandle) return OB_PREOP_SUCCESS;
PEPROCESS target = (PEPROCESS)Info->Object;
HANDLE targetPid = PsGetProcessId(target);
// Example: protect a specific PID loaded from a global.
if (targetPid != g_ProtectedPid) return OB_PREOP_SUCCESS;
// Strip everything except query rights.
ACCESS_MASK strip = ~(PROCESS_QUERY_LIMITED_INFORMATION);
if (Info->Operation == OB_OPERATION_HANDLE_CREATE) {
Info->Parameters->CreateHandleInformation.DesiredAccess &= ~strip;
} else {
Info->Parameters->DuplicateHandleInformation.DesiredAccess &= ~strip;
}
return OB_PREOP_SUCCESS;
}Post-operation callback:
VOID
ProcessHandlePostOp(
PVOID Context,
POB_POST_OPERATION_INFORMATION Info
)
{
UNREFERENCED_PARAMETER(Context);
UNREFERENCED_PARAMETER(Info);
// Post-operation can observe GrantedAccess but cannot modify it for process objects.
// Logging only.
}Registering for both HANDLE_CREATE and HANDLE_DUPLICATE is not optional. Register for only HANDLE_CREATE and an attacker can call DuplicateHandle from a process that already holds an unrestricted handle, obtaining a copy with full access. OB_OPERATION_HANDLE_DUPLICATE covers the NtDuplicateObject user-mode path. Handle inheritance via bInheritHandles in CreateProcess goes through an internal kernel path (ObInheritHandles/ObDuplicateObject at KernelMode) that bypasses ObpCallPreOperationCallbacks, so the callback does not fire for inherited handles. Relying on HANDLE_DUPLICATE registration to block inheritance is not reliable.
The miss. ObRegisterCallbacks doesn’t fire when the kernel opens a handle to a process on behalf of user-mode code through certain internal paths. The KernelHandle flag in OB_PRE_OPERATION_INFORMATION indicates the caller is in kernel mode. You typically skip those to avoid breaking system components. Any kernel-mode path that opens a handle with KernelMode call semantics bypasses the user-mode protection entirely by design.
The post-operation callback cannot undo what pre-operation allowed. If you missed it in pre-op, it’s gone. Post-op is read-only for GrantedAccess on process and thread objects.
This is the callback the handle stripping post covers in full, including IOCTL-driven PID management, the altitude race between registered drivers, and what the attacker observes when the callback is active.
Signed driver requirement. ObRegisterCallbacks validates at registration time that the caller’s image carries a valid KMCS signature. The x64 WHQL/KMCS EKU OID is 1.3.6.1.4.1.311.10.3.6 (not 1.3.6.1.4.1.311.10.3.20, which is the ARM/ARM64 WHQL EKU). The actual enforcement is not a runtime OID lookup: MmVerifyCallbackFunction checks the LDR_DATA_TABLE_ENTRY flags for the calling module to confirm it was loaded with a valid signature. An unsigned driver gets STATUS_ACCESS_DENIED. This is separate from DSE enforcement: DSE controls whether the driver loads; this check controls whether a loaded driver can register object callbacks. The DSE bypass post gets you past the load gate but not this one. Test-signing mode (bcdedit /set testsigning on) is the clean path for development.
The BattleEye callback interception post covers what happens when two drivers both want to register on the same object type and one of them wants to undo what the other does. For an earlier interception point — before any callback is registered — the pool allocation fingerprint post covers intercepting MmGetSystemRoutineAddress at the point where BattleEye resolves ExAllocatePool, gating its allocations before it gets that far.
Putting them together
No single callback closes the surface. The combination matters:
- Process callback: early allow/deny on creation. Cmdline and parent PID for allowlisting. Sets the context for everything that follows.
- Thread callback: execution-point visibility. Catches injection that spawns threads in suspicious memory regions. Miss: spoofed start addresses.
- Image-load callback: DLL visibility before code runs. Catches disk-backed DLLs loaded by legitimate loader paths. Miss: anything reflective or manually mapped.
- Object callbacks: handle-level access control. The only one that actively prevents rather than just observes. Miss: kernel handles and the altitude race.
A process that injects shellcode without creating a remote thread, loads no images from disk, and never opens a handle to the target from user mode generates telemetry at exactly one callback: the process creation callback, which sees a clean image with a clean command line. That’s the threat model reflective injection optimizes for.
Build
Targets Windows 10 1903+ (build 18362) and later. WDK 10.0.22621.
msbuild EDRDriver.vcxproj /p:Configuration=Release /p:Platform=x64Load on a machine with test-signing enabled:
bcdedit /set testsigning on
signtool sign /v /s TestCertStoreName /n "TestCert" EDRDriver.sys
sc create EDRDriver type= kernel binPath= "C:\path\EDRDriver.sys"
sc start EDRDriverPsSetCreateProcessNotifyRoutineEx requires the calling driver’s image to pass a code integrity check: the PE must have the force-integrity flag set or carry a valid DSE signature. This is the same class of check as ObRegisterCallbacks. SE_LOAD_DRIVER_PRIVILEGE is what SCM requires to call NtLoadDriver; it is not what the callback API validates. The SCM-based load path handles the privilege automatically, but the image signature requirement is independent of how you load the driver.
On HVCI, the callback registration APIs themselves work without issue: they’re documented kernel APIs with no write-side requirements. What HVCI breaks is anything downstream that tries to patch kernel memory, such as the code cave approach in the BattleEye post. The four callbacks described here survive HVCI as long as your driver is properly signed.