文章总结: 文章深入讲解了Windows系统中函数挂钩(InlineHook)的工作原理、实现方法和对抗技术。文章从Hook的基本概念入手,详细介绍了InlineHook的实现流程、MicrosoftDetours库的使用方法,以及EDR如何通过DLL注入来实施Hook。同时,文章还提供了检测Hook的方法和多种绕过Hook的技术,如DirectSyscalls、动态解析系统调用号SSN和重映射ntdll.dll。这些技术对于理解EDR工作原理和开发对抗技术具有重要参考价值。 综合评分: 89 文章分类: 二进制安全,红队,漏洞分析,渗透测试,安全工具

EDR对抗从入门到入狱: Hook原理与对抗
原创
黑晶
黑晶
2025年10月20日 21:36
EDR对抗从入门到入狱: Hook对抗
应对现代 EDR 的第一步,是理解函数挂钩(Function Hooking)在用户态的运行机制。本篇文章以“FUNCTION-HOOKING DLLS”目录为纲,从最基础的概念、内存布局讲起,逐节深入实现流程、检测思路以及常见对抗技巧,力求让第一次接触该主题的读者也能搭建实验、快速验证示例代码。
HOOK是如何工作的
Hook 的动机与分类
Windows 应用在执行到系统调用时,往往需要经过多个中间层:用户代码 → Win32 API → ntdll → 内核。EDR 或调试器只要在其中任一环节设置“跳板”(Trampoline),即可在不改动应用源码的情况下观察或修改行为。根据修改位置不同,可将常见 Hook 方式分为:
| 类型 | 介入位置 | 特点 | | — | — | — | | Inline Hook | 直接修改目标函数前几个字节 | 侵入性强但适用范围广;需保存原指令 | | Import Address Table (IAT) Hook | 篡改模块导入表中的函数指针 | 对调用方透明,适合监控单个模块 | | Export Address Table (EAT) Hook | 修改 DLL 导出表 | 影响依赖该 DLL 的所有模块 |
本文示例聚焦于最常见的 inline hook。理解其原理需要掌握三个基础问题:
- 1. 代码段也是内存:只要调整页面保护(
VirtualProtect),就能修改任何可执行模块的机器码。 - 2. 指令长度可变:x86/x64 的指令长度 1~15 字节不等,Hook 时必须至少覆盖一条完整指令,避免被截断后出现非法操作。
- 3. 需要“跳板”:我们会把原始指令保存到自建的缓冲区(Trampoline),在完成额外逻辑后再跳回原函数剩余部分。
Inline Hook 的基本流程
以 CreateFileW 为例:
- 1. 将目标函数所在内存页改为
PAGE_EXECUTE_READWRITE,以便写入跳转指令。 - 2. 复制目标开头若干字节,保存到
InlineHook::original,并在InlineHook::trampoline中附带一条jmp指令跳回原函数剩余位置。 - 3. 在目标函数开头写入一条无条件跳转(
JMP rel32或MOV RAX + JMP RAX)。 - 4. 后续调用将先进入我们自定义的处理函数,再通过跳板继续原流程。
下面的 C++ 片段展示了原始指令的保存和跳转网桥的构建逻辑,这也是很多 Hook 框架(包括 Detours)内部的基本步骤:
#include <windows.h>
struct InlineHook {
BYTE original[16];
BYTE trampoline[32];
};
bool InstallInlineHook(void* target, void* handler, InlineHook& hook) {
DWORD oldProtect;
if (!VirtualProtect(target, sizeof(hook.original), PAGE_EXECUTE_READWRITE, &oldProtect))
return false;
memcpy(hook.original, target, sizeof(hook.original));
BYTE* t = hook.trampoline;
memcpy(t, hook.original, sizeof(hook.original));
t += sizeof(hook.original);
*t++ = 0x48; // mov rax, handler
*t++ = 0xB8;
memcpy(t, &handler, sizeof(handler));
t += sizeof(handler);
*t++ = 0xFF; // jmp rax
*t++ = 0xE0;
DWORD rel = (DWORD)((BYTE*)handler - (BYTE*)target - 5);
BYTE patch[5] = {0xE9};
memcpy(patch + 1, &rel, sizeof(rel));
memcpy(target, patch, sizeof(patch));
DWORD tmp;
VirtualProtect(target, sizeof(hook.original), oldProtect, &tmp);
return true;
}
Microsoft Detours实现函数HOOK
自己写 Inline Hook 容易踩坑(异常恢复、指令长度、线程安全等问题),微软开源的 Detours 库将这些细节封装成一套事务式 API。使用 Detours 时,通常遵循以下流程:
- 1. 开始事务:
DetourTransactionBegin会冻结当前线程的执行流,避免在打补丁时发生竞态。 - 2. 指定受影响线程:
DetourUpdateThread告诉 Detours 需要暂停哪一个线程,通常传GetCurrentThread()即可。 - 3. 附加与拆除 Hook:
DetourAttach/DetourDetach分别用于安装与卸载跳板。 - 4. 提交事务:
DetourTransactionCommit会一次性写入补丁,成功后才能恢复线程执行。
与裸写机器码相比,Detours 还负责处理 WOW64、异常过滤器、线程恢复等边界情况。下面的示例拦截 CreateFileW,记录访问日志再继续执行:
#include <windows.h>
#include <detours.h>
#include <iostream>
static HANDLE(WINAPI* RealCreateFileW)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE) = CreateFileW;
HANDLE WINAPI HookedCreateFileW(LPCWSTR fileName,
DWORD desiredAccess,
DWORD shareMode,
LPSECURITY_ATTRIBUTES securityAttributes,
DWORD creationDisposition,
DWORD flagsAndAttributes,
HANDLE templateFile) {
std::wcout << L"[Hook] CreateFileW -> " << fileName << std::endl;
return RealCreateFileW(fileName, desiredAccess, shareMode, securityAttributes, creationDisposition, flagsAndAttributes, templateFile);
}
void InstallCreateFileHook() {
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)RealCreateFileW, HookedCreateFileW);
DetourTransactionCommit();
}
int main() {
InstallCreateFileHook();
HANDLE h = CreateFileW(L"C\\temp\\demo.txt", GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
if (h != INVALID_HANDLE_VALUE) CloseHandle(h);
}
EDR的防护手段之dll注入
Hook 代码往往与目标进程不同源,因此第一步是让我们的 DLL 驻留在目标的地址空间。早期(Windows 8 之前),许多商业安全软件使用 AppInit_DLLs 注册表项:只要某个交互式进程加载 user32.dll,系统就会自动把配置列表中的 DLL 注入进去。该方案实现简单、覆盖范围广,但也被恶意软件滥用进行持久化,并且会显著拖慢系统启动。自 Windows 8 起,Microsoft 在启用 Secure Boot 的系统上完全禁用了该机制,行业逐渐转向更可控的注入手段。
现代 EDR 常借助两类思路:
- 1. 用户态远程线程注入:
OpenProcess→VirtualAllocEx→WriteProcessMemory→CreateRemoteThread,适合在已有权限的情况下向任意进程加载 DLL。 - 2. 内核态 KAPC 注入:驱动程序订阅进程创建通知,分配目标进程内存并排队一个 Kernel Asynchronous Procedure Call(KAPC)。当目标线程下次恢复执行时,Windows 会先跑我们排队的 APC 例程,它再调用
LdrLoadDll/LoadLibraryW来加载 Hook DLL。由于逻辑运行在内核态,EDR 能够在用户启动任何程序的瞬间插入监控逻辑。
下图展示了用户态远程线程注入的关键步骤:
- 1. 拿到句柄:通过
OpenProcess获取目标进程的可读写句柄。 - 2. 写入 DLL 路径:在目标进程分配一段内存,把 DLL 的完整路径写进去。
- 3. 远程创建线程:令远程线程执行
LoadLibraryW,其参数即为步骤 2 中写入的路径。 - 4. 等待加载完成:线程结束后 DLL 已经映射到目标进程,可执行其中的
DllMain或自定义初始化逻辑。
如果目标开启了防注入策略(如 PROCESS_MITIGATION_DYNAMIC_CODE_POLICY),上述方式可能失败。这也是为什么内核态 KAPC 注入在商业产品中更常见的原因:驱动可以在进程初始化的最早阶段插入 APC,并通过更高权限规避用户态的阻拦。下面的示例实现了最基础的 CreateRemoteThread 注入,方便读者在实验环境中理解用户态链路:
#include <windows.h>
#include <string>
bool InjectDll(DWORD pid, const std::wstring& path) {
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!process) return false;
SIZE_T size = (path.size() + 1) * sizeof(wchar_t);
LPVOID remoteMem = VirtualAllocEx(process, nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remoteMem) {
CloseHandle(process);
return false;
}
WriteProcessMemory(process, remoteMem, path.c_str(), size, nullptr);
HANDLE thread = CreateRemoteThread(process, nullptr, 0,
(LPTHREAD_START_ROUTINE)LoadLibraryW, remoteMem, 0, nullptr);
bool success = thread != nullptr;
if (thread) {
WaitForSingleObject(thread, INFINITE);
CloseHandle(thread);
}
VirtualFreeEx(process, remoteMem, 0, MEM_RELEASE);
CloseHandle(process);
return success;
}
检测函数挂钩
如果你负责构建安全产品或者要确保样本在受控环境下运行,检测 Hook 既是防守方的必要步骤,也是进攻方反取证的常规动作。常见策略包括:
- 1. 字节比较:读取函数开头的几个字节,与磁盘上原始映像进行对比;若存在跳转指令、无意义填充,则高度可疑。
- 2. 完整性校验:将函数所在页面计算哈希或签名,检测是否被改写。
- 3. 导入表验证:遍历 IAT,确认函数指针是否仍指向预期 DLL;如果指向未知模块,很可能被 IAT Hook。
- 4. 内存映像对比:加载同一 DLL 的只读副本,与进程内已加载的 DLL 字节级比较,定位差异。
下面的示例演示了最基础的字节比较。它通过把磁盘状态的函数入口与内存状态对比,快速判断是否被改写:
#include <windows.h>
#include <wincrypt.h>
#include <vector>
bool IsFunctionPatched(const wchar_t* modulePath, const char* exportName, void* inMemory) {
HMODULE diskModule = LoadLibraryExW(modulePath, nullptr, DONT_RESOLVE_DLL_REFERENCES | LOAD_LIBRARY_AS_IMAGE_RESOURCE);
if (!diskModule) return false;
FARPROC diskProc = GetProcAddress(diskModule, exportName);
if (!diskProc) {
FreeLibrary(diskModule);
return false;
}
BYTE diskBytes[16];
memcpy(diskBytes, diskProc, sizeof(diskBytes));
BYTE memBytes[16];
memcpy(memBytes, inMemory, sizeof(memBytes));
bool patched = memcmp(diskBytes, memBytes, sizeof(memBytes)) != 0;
FreeLibrary(diskModule);
return patched;
}
Evading Function Hooks
如果 EDR 已经在用户态挂钩关键 API,攻击者需要想办法绕过这些拦截点。以下技术兼具学习价值与实战意义,但也要意识到它们容易触发安全产品的高危告警,需要在授权环境中实验。
Direct Syscalls
平时调用 WriteFile 等 Win32 API,实际上最终会落到 ntdll.dll 的 NtWriteFile,通过 syscall 指令触发内核服务。如果我们直接调用后者,就能绕开对 WriteFile 的用户态 Hook。关键点包括:
- • 解析系统调用号:在 x64 下,
ntdll中的 stub 会将 syscall 号写入EAX,再执行syscall。 - • 维持调用契约:参数、调用约定、异常处理需与官方实现一致,否则可能导致蓝屏或崩溃。
Syscall Stub 中的汇编
使用调试器查看 ntdll!NtWriteFile,可以看到类似如下的汇编:
mov r10, rcx ; Windows x64 调用约定要求 syscall 前把 RCX 备份
mov eax, 0x0055 ; 0x55 是 NtWriteFile 的系统调用号(不同版本可能变化)
syscall ; 触发内核态服务
ret
指令含义:
- 1.
mov r10, rcx:用户态调用使用微软 x64 调用约定,首个参数在RCX;而内核层需要RCX保存指向KTHREAD的指针,因此在进入内核之前,Windows 规定 stub 必须把RCX备份到R10,内核返回后会恢复。 - 2.
mov eax, imm32:EAX存放系统调用号,内核根据该值分发到相应服务入口。该编号与操作系统版本耦合,对应表可在ntdll.dll中查到。 - 3.
syscall:x86_64 ISA 提供的快速陷入指令,会切换到内核态执行,并将RCX、R11等寄存器保存到栈帧。 - 4.
ret:系统调用完成后跳回用户态,RAX中携带 NTSTATUS 返回值。
如果我们自己构造一次系统调用,只要模仿上述序列即可。为了避免调用被 Hook 的 stub,可以将磁盘 ntdll 中的原始字节复制到自定义缓冲区,再通过函数指针跳转过去。
以下示例调用 NtWriteFile 写入文件,同时绕过 WriteFile 的用户态 Hook:
#include <windows.h>
extern "C" NTSTATUS NtWriteFile(HANDLE, HANDLE, PIO_APC_ROUTINE, PVOID, PIO_STATUS_BLOCK, PVOID, ULONG, PLARGE_INTEGER, PULONG);
typedef NTSTATUS(NTAPI* NtWriteFile_t)(HANDLE, HANDLE, PIO_APC_ROUTINE, PVOID, PIO_STATUS_BLOCK, PVOID, ULONG, PLARGE_INTEGER, PULONG);
NtWriteFile_t ResolveNtWriteFile() {
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
return reinterpret_cast<NtWriteFile_t>(GetProcAddress(ntdll, "NtWriteFile"));
}
bool DirectSyscallWrite(HANDLE hFile, const void* buffer, ULONG size) {
auto syscall = ResolveNtWriteFile();
IO_STATUS_BLOCK ios = {};
NTSTATUS status = syscall(hFile, nullptr, nullptr, nullptr, &ios, const_cast<void*>(buffer), size, nullptr, nullptr);
return status >= 0;
}
动态解析系统调用号SSN
如果 ntdll.dll 本身被打了 Hook(常见于用户态 API 监控),直接 GetProcAddress 可能得到被篡改的 stub。为了拿到真正的 syscall 入口,可以:
- 1. 从磁盘加载一个只读副本,不执行其中代码。
- 2. 手动解析其 PE 结构,找到导出表中的函数 RVA。
- 3. 将该 RVA 映射到本进程新申请的可执行内存,得到干净的 syscall stub。
以下代码读取磁盘 ntdll 并返回导出地址指针,供后续复制或分析:
#include <windows.h>
#include <vector>
void* ResolveExportFromDisk(const wchar_t* path, const char* exportName) {
HANDLE hFile = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
if (hFile == INVALID_HANDLE_VALUE) return nullptr;
HANDLE hMap = CreateFileMappingW(hFile, nullptr, PAGE_READONLY, 0, 0, nullptr);
if (!hMap) {
CloseHandle(hFile);
return nullptr;
}
BYTE* base = (BYTE*)MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
if (!base) {
CloseHandle(hMap);
CloseHandle(hFile);
return nullptr;
}
auto dos = (IMAGE_DOS_HEADER*)base;
auto nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew);
auto exports = (IMAGE_EXPORT_DIRECTORY*)(base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* names = (DWORD*)(base + exports->AddressOfNames);
WORD* ordinals = (WORD*)(base + exports->AddressOfNameOrdinals);
DWORD* functions = (DWORD*)(base + exports->AddressOfFunctions);
void* result = nullptr;
for (DWORD i = 0; i < exports->NumberOfNames; ++i) {
const char* name = (char*)(base + names[i]);
if (strcmp(name, exportName) == 0) {
DWORD rva = functions[ordinals[i]];
result = base + rva;
break;
}
}
UnmapViewOfFile(base);
CloseHandle(hMap);
CloseHandle(hFile);
return result;
}
重映射ntdll.dll
如果对手在现有 ntdll 上布满 Hook,可以重新映射一份“干净”的 ntdll 并自行解析导出。这一技术对理解 PE 装载流程非常有帮助,核心步骤如下:
- 1. 建立镜像:把磁盘上的
ntdll.dll映射为SEC_IMAGE,自动处理节对齐。 - 2. 复制到可写区域:为执行准备,可将镜像拷贝到
PAGE_EXECUTE_READWRITE的内存中。 - 3. 重定位修复(简化版可以忽略):如果新基址与原始基址不同,需要按重定位表更新各个指针。
- 4. 重定向调用:获取目标函数在新镜像中的地址,并替换原函数指针。
下面的示例展示了镜像复制和导出解析的最小实现,为了易读性省略了重定位和依赖修复,适合作为实验骨架:
struct RemappedModule {
BYTE* base;
SIZE_T size;
};
RemappedModule RemapNtdll() {
wchar_t systemDir[MAX_PATH];
GetSystemDirectoryW(systemDir, MAX_PATH);
std::wstring path = std::wstring(systemDir) + L"\\ntdll.dll";
HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
HANDLE map = CreateFileMappingW(file, nullptr, PAGE_READONLY | SEC_IMAGE, 0, 0, nullptr);
BYTE* image = (BYTE*)MapViewOfFile(map, FILE_MAP_READ, 0, 0, 0);
auto dos = (IMAGE_DOS_HEADER*)image;
auto nt = (IMAGE_NT_HEADERS*)(image + dos->e_lfanew);
SIZE_T imageSize = nt->OptionalHeader.SizeOfImage;
BYTE* region = (BYTE*)VirtualAlloc(nullptr, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(region, image, imageSize);
RemappedModule mod{region, imageSize};
UnmapViewOfFile(image);
CloseHandle(map);
CloseHandle(file);
return mod;
}
template <typename T>
T ResolveFromRemap(const RemappedModule& mod, const char* name) {
auto dos = (IMAGE_DOS_HEADER*)mod.base;
auto nt = (IMAGE_NT_HEADERS*)(mod.base + dos->e_lfanew);
auto exportDir = (IMAGE_EXPORT_DIRECTORY*)(mod.base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* names = (DWORD*)(mod.base + exportDir->AddressOfNames);
WORD* ordinals = (WORD*)(mod.base + exportDir->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)(mod.base + exportDir->AddressOfFunctions);
for (DWORD i = 0; i < exportDir->NumberOfNames; ++i) {
if (strcmp(name, (char*)(mod.base + names[i])) == 0)
return reinterpret_cast<T>(mod.base + funcs[ordinals[i]]);
}
return nullptr;
}
Conclusion
本文围绕函数挂钩 DLL 的实现、注入、检测与规避展开,通过 C++ 示例展示了核心步骤,并尽量从零解释关键原理。对防守方而言,理解 Hook 的工作细节有助于构建更可靠的检测策略;对于研究 offensive 技术的读者,则可借助直接 syscall、动态解析及 remapping 等技巧提升对抗强度。未来的研究方向还包括内核态 Hook、基于硬件的控制流监控以及自动化反 Hook 分析工具。通过不断实验与迭代,开发者可以在攻防两端获得更深入的洞察。
好书推荐,EDR原理与对抗必读书目:《Evading EDR: The Definitive Guide to Defeating Endpoint Detection Systems》 👇👇👇获取:

这是一个纯粹,开放,前沿的技术交流社区,成员主要有互联网大厂安全部门任职的成员,乙方红队专家,以及正在学习入门的小白等,目前主题主要以红队研发为主(有经验的都知道是什么意思),以及其他涉及到红队进攻侵入性技术,如果你想学习技术,认识不同的人或者寻求一个机会之类的,可以来看看👇👇👇****


欢迎加入交流圈
扫码获取更多精彩












评论