Sep 18, 2026  ·  Kernel  ·  12 min read

Callback-free process birth via self-re-execution

Scott Busby  ·  offense

I was following the control flow in EDRSandblast's main function when I hit a branch I did not expect. The tool checks whether its own parent process has the same image path. If it does, the process knows it was spawned by a copy of itself, and it skips half the setup. If it does not, it removes every EDR kernel callback it can find, then calls CreateProcess on its own executable.

The child is born into a kernel that has nothing to say about it. No process creation callback fires. No thread creation callback fires. No image load callback fires. The EDR never finds out the child exists.

The technique is self-re-execution. The parent does the noisy work, the child does the payload, and the kernel's own notification system is the thing being turned off. I spent a few evenings walking through how it actually works.

The kernel's notification system

When an EDR driver loads, one of the first things it does is register callbacks with the kernel. The kernel maintains three arrays of function pointers for this purpose, all inside ntoskrnl:

Drivers register into these arrays by calling PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, and PsSetLoadImageNotifyRoutine. After that, every time a process is created, a thread starts, or an image gets mapped, the kernel walks the corresponding array and calls every registered function. That is how the EDR knows about your process before your first instruction runs.

Each entry in the array is a pointer to a callback registration structure. The actual function pointer sits at offset +8 inside that structure, and the low 4 bits of the array entry are flags that get masked off with & ~0b1111. This matters later when we look at how removal works.

On top of these three, there are also object callbacks (for process and thread handle operations) and minifilter callbacks (for file I/O). EDR drivers typically register all of them. A fully instrumented system has the kernel reporting process creation, thread creation, image loads, handle operations, and file activity.

Why the parent is already burned

Here is the problem. By the time your process is running and capable of removing callbacks, the callbacks have already fired on you.

Your process creation was reported. Your initial thread creation was reported. Every DLL that loaded into your address space was reported. The EDR knows your PID, your image path, your parent PID, and possibly your command line. You are in the EDR's internal process list. Removing the callbacks now does not erase you from that list. It just stops future events from being reported.

So if the goal is to run a payload in a process the EDR has never heard of, removing callbacks from inside your own process is not enough. You need a second process, one that is born after the callbacks are gone.

The three-step flow

The self-re-execution technique splits the work between a parent and a child. The parent is sacrificial. It accepts that it has already been reported and does all the dangerous kernel-level work. The child is the one that matters.

Step one: the parent removes the kernel callbacks. It zeros out the EDR's entries in all three notify routine arrays, disables object callbacks, and (if the write primitive supports it) removes minifilter callbacks. After this, the kernel has nothing to call when a new process appears.

Step two: the parent spawns itself.

self_reexec.c
STARTUPINFO si;
PROCESS_INFORMATION pi;
memset(&si, 0, sizeof(si));
si.cb = sizeof(si);
memset(&pi, 0, sizeof(pi));
TCHAR* currentCommandLine = GetCommandLine();
CloseDriverHandle();
if (CreateProcess(argv[0], currentCommandLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) {
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
}

The parent calls CloseDriverHandle() before spawning the child. The vulnerable BYOVD driver is still loaded as a kernel service, but the parent releases its usermode handle so the child can open its own. The child needs that driver handle for kernel operations like setting itself as a protected process.

Then the parent waits. WaitForSingleObject with INFINITE. It sits there doing nothing until the child finishes.

Step three: after the child exits, the parent restores everything. Every callback that was zeroed gets its original value written back. Object callbacks get re-enabled. Minifilter callbacks get restored (same atomicity caveat). The monitoring gap is bounded to the child's lifetime.

restore.c
// 3/3 : Restoring state after execution.
if (restoreCallbacks == TRUE && foundNotifyRoutineCallbacks) {
    RestoreEDRNotifyRoutineCallbacks(foundEDRDrivers);
}
if (restoreCallbacks == TRUE && foundObjectCallbacks) {
    EnableEDRProcessAndThreadObjectsCallbacks(foundEDRDrivers);
}
#if WriteMemoryPrimitiveIsAtomic
if (restoreCallbacks == TRUE && foundMinifilterCallbacks) {
    RestoreEDRMinifilterCallbacks(foundEDRDrivers);
}
#endif
if (ETWTIState) {
    EnableETWThreatIntelProvider(verbose);
}

From the EDR's perspective, there was a brief period where events stopped arriving, and then they started again. If no one was watching the callback arrays during that window, the gap is invisible.

WasRestarted()

The child needs to know it is the child. No command-line flag, no environment variable, no magic file on disk. The detection mechanism is an image path comparison.

WasRestarted.c
BOOL WasRestarted() {
    PROCESS_BASIC_INFORMATION pbi = { 0 };
    ULONG written = 0;
    PE* n = PE_create(getModuleEntryFromNameW(L"ntdll.dll")->DllBase, TRUE);
    NtQueryInformationProcess_f NtQueryInformationProcess =
        (NtQueryInformationProcess_f)PE_functionAddr(n, "NtQueryInformationProcess");
    NtQueryInformationProcess(
        GetCurrentProcess(), ProcessBasicInformation,
        &pbi, sizeof(pbi), &written);
    DWORD parentPid = (DWORD)pbi.InheritedFromUniqueProcessId;
    HANDLE hParent = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, parentPid);
    CHAR parentImage[MAX_PATH] = { 0 };
    CHAR myImage[MAX_PATH] = { 0 };
    GetProcessImageFileNameA(hParent, parentImage, sizeof(parentImage));
    GetProcessImageFileNameA(GetCurrentProcess(), myImage, sizeof(myImage));
    PE_destroy(n);
    return strcmp(parentImage, myImage) == 0;
}

It calls NtQueryInformationProcess with ProcessBasicInformation to get the parent PID from PROCESS_BASIC_INFORMATION.InheritedFromUniqueProcessId. Opens the parent, gets both image paths with GetProcessImageFileNameA, and compares them. If they match, this process was spawned by a copy of itself.

The main function checks this early and branches on it:

EDRSandblast.c
if (WasRestarted()) {
    removeVulnDriver = FALSE;
}
else {
    PrintBanner();
}

Two things happen here. The child skips the banner (no need to print it twice). And it sets removeVulnDriver = FALSE so it does not try to uninstall the vulnerable driver when it finishes. The parent handles driver cleanup after the child exits.

How callbacks get removed

The actual removal is a write-to-zero. Each entry in the notify routine array is a pointer to a callback registration structure. To remove a callback, you write 0 to that array slot.

KernelCallbacks.c
void RemoveOrRestoreSpecificEDRNotifyRoutineCallbacks(
    enum NtoskrnlOffsetType notifyRoutineType,
    struct FOUND_EDR_CALLBACKS* edrCallbacks,
    BOOL remove)
{
    for (DWORD i = 0; i < edrCallbacks->size; ++i) {
        struct KRNL_CALLBACK* cb = &edrCallbacks->EDR_CALLBACKS[i];
        if (cb->type == NOTIFY_ROUTINE_CB &&
            cb->addresses.notify_routine.type == notifyRoutineType &&
            cb->removed == !remove) {
            DWORD64 value_to_write = remove
                ? 0
                : cb->addresses.notify_routine.callback_struct;
            WriteMemoryDWORD64(
                cb->addresses.notify_routine.callback_struct_addr,
                value_to_write);
            cb->removed = !cb->removed;
        }
    }
}

The same function handles both removal and restoration. When removing, it writes 0. When restoring, it writes back the original callback_struct value that was saved during enumeration. The removed flag on each callback entry tracks the current state so you do not accidentally double-remove or double-restore.

Before any of this happens, the tool has to find the callbacks. Enumeration walks each array, reads every slot, follows the pointer to the registration structure (masking off the low 4 flag bits), reads the actual function pointer at offset +8, and resolves which driver owns that address. Only EDR-owned callbacks get removed. Callbacks registered by the OS or other legitimate drivers are left alone.

KernelCallbacks.c
for (int i = 0; i < PSP_MAX_CALLBACKS; ++i) {
    DWORD64 callback_struct = ReadMemoryDWORD64(
        NotifyRoutineAddress + (i * sizeof(DWORD64)));
    if (callback_struct != 0) {
        DWORD64 callback = (callback_struct & ~0b1111) + 8;
        DWORD64 cbFunction = ReadMemoryDWORD64(callback);
        DWORD64 driverOffset;
        TCHAR* driver = FindDriverName(cbFunction, &driverOffset);
        if (driver && isDriverNameMatchingEDR(driver)) {
            // save for later removal/restoration
        }
    }
}

PSP_MAX_CALLBACKS is 0x40 (64) for the process and thread arrays, 8 for image loading. The tool reads all slots, skips empty ones, and builds a list of EDR callbacks to target.

The child's world

When CreateProcess fires in the parent, the kernel would normally walk PspCreateProcessNotifyRoutine and call every registered function. But the EDR's entries are zeroed. The kernel iterates the array, finds nothing (or only non-EDR callbacks), and moves on. The child's process creation is never reported to the EDR.

Same for the child's initial thread. Same for every DLL the child loads. The child's ntdll.dll load, its kernel32.dll load, every dependency, none of them trigger an EDR image load callback because those callbacks are gone too.

The child runs its own enumeration, finds no EDR callbacks in the kernel arrays, and determines that the environment is safe:

EDRSandblast.c
if (isSafeToExecutePayloadKernelland &&
    (isSafeToExecutePayloadUserland || directSyscalls)) {
    _putts_or_not(TEXT("[+] Process is \"safe\" to launch our payload\n"));
    // execute payload directly: dump, cmd, credguard, etc.
}

The child hits the "safe" path and executes the payload directly. No self-re-execution. No additional callback removal. It just does the work.

The recursion guard

What if something goes wrong? What if the child runs its checks and finds that the environment is still not safe, maybe because a callback restoration happened too early or a driver reloaded its callbacks?

Without a guard, the child would try to self-re-execute, spawning a grandchild, which would check again, and if still not safe, spawn another. Infinite recursion of processes.

The guard is at the top of the "not safe" branch:

EDRSandblast.c
if (WasRestarted()) {
    _tprintf_or_not(TEXT(
        "Something failed, cannot perform safely execute payload. Aborting...\n"));
    exit(1);
}

If WasRestarted() returns true and the environment is still not safe, the child aborts. One level of recursion only. The child never tries to spawn itself.

The minifilter gap

If you read the previous post on write primitive atomicity, you will recognize the #if WriteMemoryPrimitiveIsAtomic guard showing up again in the self-re-execution flow.

EDRSandblast.c
#if WriteMemoryPrimitiveIsAtomic
if (foundMinifilterCallbacks) {
    RemoveEDRMinifilterCallbacks(foundEDRDrivers);
}
#endif

If you compiled with a driver whose write primitive is not atomic (RTCore64, with its 4-byte IOCTL value field), minifilter removal is gone from the build entirely. The self-re-execution still removes process, thread, and image load callbacks, and it still disables object callbacks. But the minifilter callback list uses doubly-linked LIST_ENTRY nodes, and unlinking those requires pointer-width writes. With a non-atomic driver, unlinking a minifilter node races against every file I/O operation on the system.

So the child is born without process/thread/image callbacks, but file I/O monitoring might still be active. The EDR will not know the child exists, but it might see the child's file operations. Whether that matters depends on the payload. An LSASS memory dump through MiniDumpWriteDump writes to a file, and if minifilter callbacks are still live, the EDR sees that file write. A command shell that just spawns other processes might fly under the radar entirely.

The choice of vulnerable driver constrains which callbacks the parent can safely remove, and that determines how invisible the child actually is.

ETW-TI

There is one more thing the parent disables before spawning the child. ETW Threat Intelligence is a separate telemetry channel that feeds events to EDR drivers through Event Tracing for Windows. It runs outside the callback array mechanism, so zeroing the notify routine arrays does not affect it.

The parent disables it separately, before the self-re-execution, and restores it after the child exits. The ordering matters: ETW-TI goes down first, then callbacks, then spawn, then restore callbacks, then ETW-TI back up. The child is born with both systems offline.

What the EDR sees

If everything works, here is the EDR's view of the timeline:

The parent process was born and reported normally. The EDR knows about it. At some point, kernel callbacks stop arriving. The EDR is not notified about this, since the mechanism for notification is the thing being turned off. During the gap, a child process is created, does its work, and exits. Then callbacks resume.

The EDR never received a process creation event for the child. It never received thread events, image load events, or (with an atomic driver) file I/O events from the child. If the EDR does not have an independent mechanism for discovering processes, like periodically enumerating the kernel's process list, the child is invisible.

The parent is still visible and still in the EDR's records. That is fine. The parent loaded a vulnerable driver, which might itself be flagged. The parent is the noisy cover. The child is the one doing the actual work, and from the kernel's notification system, it never existed.

The full source for all of this is in EDRSandblast. The self-re-execution logic lives in EDRSandblast.c, and the callback enumeration and removal code is in KernelCallbacks.c. If you want to see how the three callback arrays are located at runtime, NtoskrnlOffsets.csv has the offsets per build number.

It was a fun few evenings of reading. Hopefully it saves someone else some time.

SB
Scott Busby

Vulnerability research, teardowns and the occasional incident postmortem.