在 Windows 安全攻防领域,函数挂钩(Function Hooking)是 EDR 监控进程行为、攻击者绕过防护的核心技术。想要应对现代 EDR 的拦截,首先要掌握用户态下函数挂钩的运行机制。本文以“FUNCTION-HOOKING DLLS”为核心框架,从基础概念、内存布局讲起,逐步深入实现流程、检测思路与对抗技巧,即使是初次接触的读者,也能跟着完成实验环境搭建与代码验证。

一、函数挂钩(Hook)是什么?为什么需要它?
Windows 应用程序执行系统调用时,并非直接与内核交互,而是要经过“用户代码→Win32 API→ntdll.dll→内核”多个中间层。EDR 或调试工具只要在任意一层设置“跳板(Trampoline)”,就能在不修改应用源码的情况下,监控甚至篡改进程行为 —— 这就是函数挂钩的核心价值。
根据修改位置的不同,常见的 Hook 技术可分为三类,各自适用场景与特点差异明显:
- Inline Hook:直接修改目标函数开头的字节指令,侵入性强但适用范围广,不过需要保存原始指令以防程序崩溃。
- IAT Hook(导入地址表 Hook):篡改模块导入表中的函数指针,对调用方完全透明,适合监控单个模块的 API 调用。
- EAT Hook(导出地址表 Hook):修改 DLL 的导出表,会影响所有依赖该 DLL 的进程模块,覆盖范围最广但风险较高。
本文重点讲解最常用的Inline Hook,理解它只需掌握三个基础认知:
- 代码段本质是可修改的内存:通过
VirtualProtect函数调整内存页保护属性,就能修改任何可执行模块的机器码。 - x86/x64 指令长度不固定:指令长度在 1-15 字节之间,Hook 时必须覆盖完整指令,否则会出现非法操作导致程序崩溃。
- 必须依赖“跳板”实现流程跳转:把目标函数的原始指令保存到自定义缓冲区(Trampoline),执行完自定义逻辑后,再通过跳板跳回原函数剩余部分。
二、Inline Hook 实现流程:以 CreateFileW 为例
以监控文件创建的 CreateFileW 函数为例,Inline Hook 的实现分为 4 个关键步骤,每一步都需严格遵循内存操作与指令逻辑:
- 修改内存页保护属性 调用
VirtualProtect将CreateFileW所在内存页的属性改为PAGE_EXECUTE_READWRITE,确保后续能写入跳转指令。 - 保存原始指令并构建跳板 复制
CreateFileW开头的若干字节(通常 16 字节,覆盖足够多完整指令),保存到InlineHook::original结构体中;同时在InlineHook::trampoline中添加一条jmp指令,用于跳回原函数未被修改的部分。 - 写入跳转指令到目标函数 在
CreateFileW开头写入无条件跳转指令(x64 下常用MOV RAX + JMP RAX,x86 下常用JMP rel32),让函数调用直接进入自定义处理逻辑。 - 完成 Hook 逻辑闭环 后续所有对
CreateFileW的调用,都会先执行自定义处理函数(如记录文件路径、判断是否危险操作),再通过跳板跳回原函数,保证原有功能正常运行。
以下是 Inline Hook 核心逻辑的 C++ 实现代码,也是 Detours 等 Hook 框架的底层基础:
#include <windows.h>
// 存储原始指令与跳板的结构体
struct InlineHook {BYTE original[16]; // 保存目标函数原始开头指令
BYTE trampoline[32];// 跳板:原始指令 + 跳转回原函数的逻辑
};
// 安装 Inline Hook:target= 目标函数地址,handler= 自定义处理函数,hook= 存储原始指令的结构体
bool InstallInlineHook(void* target, void* handler, InlineHook& hook) {
DWORD oldProtect;
// 第一步:修改内存页保护,允许写入
if (!VirtualProtect(target, sizeof(hook.original), PAGE_EXECUTE_READWRITE, &oldProtect))
return false;
// 第二步:复制原始指令到 original,同时构建跳板
memcpy(hook.original, target, sizeof(hook.original));
BYTE* t = hook.trampoline;
memcpy(t, hook.original, sizeof(hook.original)); // 复制原始指令到跳板
t += sizeof(hook.original);
// 跳板中添加:MOV RAX, handler(将自定义函数地址存入 RAX)*t++ = 0x48;
*t++ = 0xB8;
memcpy(t, &handler, sizeof(handler));
t += sizeof(handler);
// 跳板中添加:JMP RAX(跳转到自定义函数)*t++ = 0xFF;
*t++ = 0xE0;
// 第三步:在目标函数开头写入跳转指令(JMP rel32)DWORD rel = (DWORD)((BYTE*)handler - (BYTE*)target - 5); // 计算相对偏移
BYTE patch[5] = {0xE9}; // 0xE9 是 JMP rel32 指令的操作码
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,能大幅降低开发难度,还支持 WOW64 兼容、异常过滤、线程恢复等边界场景。
使用 Detours 实现 Hook 的流程固定为 4 步,以拦截 CreateFileW 记录文件访问日志为例:
- 开启 Detours 事务 调用
DetourTransactionBegin冻结当前线程执行流,避免打补丁时出现线程竞态问题。 - 指定受影响线程 通过
DetourUpdateThread告诉 Detours 需要暂停的线程,通常传入GetCurrentThread()(当前线程)即可。 - 附加或拆除 Hook使用
DetourAttach安装 Hook(将目标函数与自定义函数绑定),DetourDetach用于卸载 Hook。 - 提交事务 调用
DetourTransactionCommit一次性写入所有补丁,成功后恢复线程执行,Hook 正式生效。
以下是 Detours 实现 CreateFileW 拦截的完整代码,可直接编译运行:
#include <windows.h>
#include <detours.h>
#include <iostream>
// 1. 定义原始函数指针,指向系统的 CreateFileW
static HANDLE(WINAPI* RealCreateFileW)(
LPCWSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile
) = CreateFileW;
// 2. 实现自定义 Hook 函数:先记录日志,再调用原始函数
HANDLE WINAPI HookedCreateFileW(
LPCWSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile
) {
// 自定义逻辑:打印被访问的文件路径
std::wcout << L"[Hook 监控] CreateFileW 访问文件:" << lpFileName << std::endl;
// 调用原始函数,保证原有功能正常
return RealCreateFileW(
lpFileName, dwDesiredAccess, dwShareMode,
lpSecurityAttributes, dwCreationDisposition,
dwFlagsAndAttributes, hTemplateFile
);
}
// 3. 安装 Hook 的封装函数
void InstallCreateFileHook() {DetourTransactionBegin(); // 开启事务
DetourUpdateThread(GetCurrentThread()); // 指定当前线程
DetourAttach(&(PVOID&)RealCreateFileW, HookedCreateFileW); // 绑定原始函数与 Hook 函数
DetourTransactionCommit(); // 提交事务,生效 Hook}
// 测试:安装 Hook 后调用 CreateFileW,验证日志是否输出
int main() {InstallCreateFileHook(); // 安装 Hook
// 调用 CreateFileW,此时会先触发 HookedCreateFileW
HANDLE hFile = CreateFileW(
L"C:\\temp\\demo.txt", // 测试文件路径
GENERIC_READ,
FILE_SHARE_READ,
nullptr,
OPEN_EXISTING,
0,
nullptr
);
if (hFile != INVALID_HANDLE_VALUE) {CloseHandle(hFile);
std::wcout << L"[ 测试] 文件打开成功并关闭" << std::endl;
}
return 0;
}
四、EDR 的关键步骤:如何将 Hook DLL 注入目标进程?
Hook 代码通常封装在 DLL 中,要让 DLL 生效,必须先将其注入到目标进程的地址空间。EDR 常用的注入方式分为用户态与内核态两类,各自适用场景不同。
1. 传统注入方式:AppInit_DLLs(已逐步淘汰)
Windows 8 之前,许多安全软件使用 AppInit_DLLs 注册表项(路径:HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows),只要进程加载user32.dll,系统就会自动注入该注册表中的 DLL。
这种方式实现简单、覆盖范围广,但容易被恶意软件滥用,且会拖慢系统启动。自 Windows 8 起,启用 Secure Boot 的系统已完全禁用该机制,目前仅用于兼容性场景。
2. 现代 EDR 常用:用户态远程线程注入
通过 CreateRemoteThread 创建远程线程,让目标进程加载指定 DLL,适合对已有进程注入,步骤清晰:
- 获取目标进程句柄 :调用
OpenProcess获取目标进程的PROCESS_ALL_ACCESS权限句柄。 - 写入 DLL 路径到目标进程 :用
VirtualAllocEx在目标进程分配内存,再通过WriteProcessMemory写入 DLL 完整路径。 - 创建远程线程触发加载:让远程线程执行
LoadLibraryW,参数为第二步写入的 DLL 路径,此时 DLL 会被加载到目标进程。 - 等待加载完成 :调用
WaitForSingleObject等待远程线程结束,确保 DLL 加载成功,最后释放资源。
以下是远程线程注入的 C++ 实现代码,适合实验环境测试:
#include <windows.h>
#include <string>
// 注入 DLL 到指定 PID 的进程:pid= 目标进程 ID,path=DLL 完整路径
bool InjectDll(DWORD dwProcessId, const std::wstring& strDllPath) {
// 第一步:获取目标进程句柄
HANDLE hProcess = OpenProcess(
PROCESS_ALL_ACCESS, // 需要所有权限,确保能分配内存、创建线程
FALSE,
dwProcessId
);
if (!hProcess) return false;
// 第二步:计算 DLL 路径长度,在目标进程分配内存
SIZE_T dwPathSize = (strDllPath.size() + 1) * sizeof(wchar_t); // 包含终止符
LPVOID lpRemoteMem = VirtualAllocEx(
hProcess,
nullptr,
dwPathSize,
MEM_COMMIT | MEM_RESERVE, // 提交并保留内存
PAGE_READWRITE // 允许读写(写入 DLL 路径));
if (!lpRemoteMem) {CloseHandle(hProcess);
return false;
}
// 第三步:将 DLL 路径写入目标进程内存
if (!WriteProcessMemory(
hProcess,
lpRemoteMem,
strDllPath.c_str(),
dwPathSize,
nullptr
)) {VirtualFreeEx(hProcess, lpRemoteMem, 0, MEM_RELEASE);
CloseHandle(hProcess);
return false;
}
// 第四步:创建远程线程,执行 LoadLibraryW 加载 DLL
HANDLE hRemoteThread = CreateRemoteThread(
hProcess,
nullptr,
0,
(LPTHREAD_START_ROUTINE)LoadLibraryW, // 线程入口:LoadLibraryW
lpRemoteMem, // 线程参数:DLL 路径
0,
nullptr
);
bool bSuccess = (hRemoteThread != nullptr);
// 第五步:等待线程结束,释放资源
if (hRemoteThread) {WaitForSingleObject(hRemoteThread, INFINITE); // 等待加载完成
CloseHandle(hRemoteThread);
}
VirtualFreeEx(hProcess, lpRemoteMem, 0, MEM_RELEASE); // 释放目标进程内存
CloseHandle(hProcess);
return bSuccess;
}
3. 更隐蔽的方式:内核态 KAPC 注入
用户态注入可能被目标进程的防注入策略(如PROCESS_MITIGATION_DYNAMIC_CODE_POLICY)拦截,因此现代 EDR 更多使用内核态 KAPC 注入。
其核心逻辑是:EDR 驱动程序订阅“进程创建通知”,当新进程启动时,驱动在目标进程内存中分配空间,排队一个 内核异步过程调用(KAPC) 。当目标进程的线程下次恢复执行时,Windows 会优先执行 KAPC 例程,例程中调用 LdrLoadDll 或LoadLibraryW加载 Hook DLL。
由于逻辑运行在内核态,KAPC 注入能绕过用户态防护,在进程初始化的最早阶段插入监控,隐蔽性与成功率更高。
五、防守方视角:如何检测函数挂钩(Hook)?
无论是构建 EDR 还是保障程序安全,检测函数是否被 Hook 都是核心需求。常见的检测策略基于“内存对比”与“完整性校验”,以下是 4 种实用方法:
1. 字节对比检测(最基础)
读取内存中目标函数的开头字节,与磁盘上该函数所在 DLL 的原始字节对比。如果存在跳转指令(如0xE9、0xFFE0)或无意义填充,说明函数可能被 Hook。
2. 函数完整性校验
对目标函数所在的内存页计算 MD5、SHA256 哈希,与预先存储的“干净”哈希值对比。若哈希不一致,表明内存被篡改,存在 Hook 风险。
3. 导入表(IAT)验证
遍历进程的导入地址表,检查每个函数指针是否指向预期的 DLL 模块(如kernel32.dll、ntdll.dll)。如果指针指向未知模块或自定义内存区域,大概率是 IAT Hook。
4. 内存映像对比
从磁盘加载一份目标 DLL 的只读副本(不执行代码),与进程中已加载的 DLL 进行字节级对比,定位所有内存差异点,判断是否存在 Hook。
以下是“字节对比检测”的 C++ 实现代码,可快速判断 CreateFileW 是否被篡改:
#include <windows.h>
#include <vector>
// 检测函数是否被 Hook:modulePath=DLL 路径,exportName= 函数名,inMemory= 内存中函数地址
bool IsFunctionPatched(const wchar_t* lpModulePath, const char* lpExportName, void* pInMemory) {
// 第一步:从磁盘加载 DLL 的只读副本(不解析依赖,避免执行代码)HMODULE hDiskModule = LoadLibraryExW(
lpModulePath,
nullptr,
DONT_RESOLVE_DLL_REFERENCES | LOAD_LIBRARY_AS_IMAGE_RESOURCE
);
if (!hDiskModule) return false;
// 第二步:获取磁盘 DLL 中目标函数的地址
FARPROC pDiskProc = GetProcAddress(hDiskModule, lpExportName);
if (!pDiskProc) {FreeLibrary(hDiskModule);
return false;
}
// 第三步:对比内存中与磁盘上函数的前 16 字节
BYTE byDiskBytes[16] = {0};
memcpy(byDiskBytes, pDiskProc, sizeof(byDiskBytes));
BYTE byMemBytes[16] = {0};
memcpy(byMemBytes, pInMemory, sizeof(byMemBytes));
// 第四步:判断字节是否一致,不一致则说明被篡改
bool bPatched = (memcmp(byDiskBytes, byMemBytes, sizeof(byDiskBytes)) != 0);
FreeLibrary(hDiskModule);
return bPatched;
}
// 测试:检测 kernel32.dll 中的 CreateFileW 是否被 Hook
void TestHookDetection() {void* pMemCreateFile = GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "CreateFileW");
if (IsFunctionPatched(L"kernel32.dll", "CreateFileW", pMemCreateFile)) {printf("警告:CreateFileW 可能被 Hook!\n");
} else {printf("正常:CreateFileW 未被篡改 \n");
}
}
六、攻击者视角:如何绕过 EDR 的函数挂钩(Evading Hook)?
当 EDR 通过 Hook 监控关键 API 时,攻击者需要绕过这些拦截点。以下三种技术兼具学习价值与实战意义,但需注意:所有操作必须在授权实验环境中进行,禁止用于未授权场景。
1. 直接调用系统调用(Direct Syscalls):绕开 Win32 API Hook
Windows 应用调用的 WriteFile、CreateFileW 等 Win32 API,最终都会通过 ntdll.dll 中的 NtWriteFile、NtCreateFile 等函数,通过 syscall 指令触发内核服务。如果直接调用 ntdll.dll 中的这些“内核接口函数”,就能绕开 EDR 对 Win32 API 的 Hook。
实现 Direct Syscalls 需注意两个关键点:
- 解析系统调用号(SSN):x64 系统中,
ntdll.dll的函数会先将系统调用号写入EAX寄存器,再执行syscall。不同 Windows 版本的系统调用号可能变化,需从ntdll.dll中动态解析。 - 遵循调用契约:函数参数、调用约定(如 x64 的
__fastcall)、异常处理必须与官方实现一致,否则会导致程序崩溃或蓝屏。
以 NtWriteFile 为例,其汇编逻辑(通过调试器查看ntdll!NtWriteFile)如下:
mov r10, rcx ; 备份 RCX(x64 调用约定:syscall 前需将首个参数 RCX 存到 R10)mov eax, 0x0055 ; 将 NtWriteFile 的系统调用号 0x55 存入 EAX(版本不同可能变化)syscall ; 触发内核态服务,切换到内核执行
ret ; 返回用户态,RAX 存放 NTSTATUS 结果
以下是直接调用 NtWriteFile 的 C++ 实现,可绕开 WriteFile 的 Hook:
cpp
运行
#include <windows.h>
// 声明 NtWriteFile 函数原型(ntdll.dll 导出,需 extern "C" 避免名称修饰)extern "C" NTSTATUS NtWriteFile(
HANDLE hFile,
HANDLE hEvent,
PIO_APC_ROUTINE pApcRoutine,
PVOID pApcContext,
PIO_STATUS_BLOCK pIoStatusBlock,
PVOID pBuffer,
ULONG nLength,
PLARGE_INTEGER pByteOffset,
PULONG pKey
);
// 定义函数指针类型,方便后续动态获取 NtWriteFile 地址
typedef NTSTATUS(NTAPI* NtWriteFile_t)(
HANDLE, HANDLE, PIO_APC_ROUTINE, PVOID,
PIO_STATUS_BLOCK, PVOID, ULONG,
PLARGE_INTEGER, PULONG
);
// 从 ntdll.dll 中获取 NtWriteFile 的地址
NtWriteFile_t ResolveNtWriteFile() {HMODULE hNtdll = GetModuleHandleW(L"ntdll.dll");
return reinterpret_cast<NtWriteFile_t>(GetProcAddress(hNtdll, "NtWriteFile")
);
}
// 直接调用 NtWriteFile 写入文件,绕开 WriteFile 的 Hook
bool DirectSyscallWrite(HANDLE hFile, const void* pBuffer, ULONG nSize) {auto pNtWriteFile = ResolveNtWriteFile();
if (!pNtWriteFile) return false;
IO_STATUS_BLOCK ioStatus = {0}; // 存储 I / O 操作结果
// 调用 NtWriteFile,参数需严格匹配原型
NTSTATUS status = pNtWriteFile(
hFile, // 目标文件句柄
nullptr, // 无需事件通知,传空
nullptr, // 无需 APC 回调,传空
nullptr, // APC 上下文,传空
&ioStatus, // I/ O 状态块
const_cast<void*>(pBuffer), // 要写入的数据
nSize, // 数据长度
nullptr, // 不指定偏移(使用当前文件指针)nullptr // 无需文件密钥,传空
);
// NTSTATUS >= 0 表示操作成功
return status >= 0;
}
2. 动态解析系统调用号(SSN):应对 ntdll.dll 被 Hook
如果 EDR 不仅 Hook 了 Win32 API,还 Hook 了 ntdll.dll 中的函数(如 NtWriteFile),直接GetProcAddress 获取的可能是被篡改的函数地址。此时需要从 磁盘上的 ntdll.dll 副本 中解析原始系统调用号,避免使用内存中被 Hook 的模块。
核心步骤如下:
- 从磁盘加载 ntdll.dll 只读副本 :通过
CreateFileW打开C:\Windows\System32\ntdll.dll,再用CreateFileMappingW和MapViewOfFile映射为只读内存,不执行任何代码。 - 手动解析 PE 结构找导出表:遍历 PE 文件的导出表,找到目标函数(如
NtWriteFile)的相对虚拟地址(RVA)。 - 提取系统调用号 :根据函数 RVA 找到汇编代码,从
mov eax, 0xXX指令中提取系统调用号(0xXX)。
以下代码实现了从磁盘 ntdll.dll 中解析导出函数地址的逻辑,为后续提取 SSN 做准备:
#include <windows.h>
#include <vector>
#include <cstring>
// 从磁盘 DLL 中解析导出函数地址:path=DLL 路径,exportName= 函数名
void* ResolveExportFromDisk(const wchar_t* lpPath, const char* lpExportName) {
// 第一步:打开磁盘上的 DLL 文件
HANDLE hFile = CreateFileW(
lpPath,
GENERIC_READ, // 仅读权限
FILE_SHARE_READ, // 允许其他进程读
nullptr,
OPEN_EXISTING, // 打开已存在文件
FILE_ATTRIBUTE_NORMAL,
nullptr
);
if (hFile == INVALID_HANDLE_VALUE) return nullptr;
// 第二步:创建文件映射,将 DLL 映射到内存(只读)HANDLE hMap = CreateFileMappingW(
hFile,
nullptr,
PAGE_READONLY, // 只读属性,避免执行
0, 0, // 映射整个文件
nullptr
);
if (!hMap) {CloseHandle(hFile);
return nullptr;
}
// 第三步:将映射视图加载到当前进程内存
BYTE* pBase = (BYTE*)MapViewOfFile(
hMap,
FILE_MAP_READ, // 仅读访问
0, 0, 0 // 映射整个视图
);
if (!pBase) {CloseHandle(hMap);
CloseHandle(hFile);
return nullptr;
}
// 第四步:解析 PE 结构,找到导出表
auto pDosHeader = (IMAGE_DOS_HEADER*)pBase;
auto pNtHeader = (IMAGE_NT_HEADERS*)(pBase + pDosHeader->e_lfanew);
// 导出表在数据目录中的索引为 IMAGE_DIRECTORY_ENTRY_EXPORT
auto pExportDir = (IMAGE_EXPORT_DIRECTORY*)(pBase + pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
);
// 第五步:遍历导出表,找到目标函数
DWORD* pNames = (DWORD*)(pBase + pExportDir->AddressOfNames); // 函数名列表
WORD* pOrdinals = (WORD*)(pBase + pExportDir->AddressOfNameOrdinals); // 函数序号列表
DWORD* pFuncs = (DWORD*)(pBase + pExportDir->AddressOfFunctions); // 函数 RVA 列表
void* pResult = nullptr;
for (DWORD i = 0; i < pExportDir->NumberOfNames; ++i) {const char* pName = (char*)(pBase + pNames[i]);
if (strcmp(pName, lpExportName) == 0) {
// 根据序号找到函数 RVA,再转换为内存地址
DWORD dwFuncRva = pFuncs[pOrdinals[i]];
pResult = pBase + dwFuncRva;
break;
}
}
// 第六步:释放资源
UnmapViewOfFile(pBase);
CloseHandle(hMap);
CloseHandle(hFile);
return pResult;
}
// 测试:从磁盘 ntdll.dll 中获取 NtWriteFile 的地址
void TestResolveFromDisk() {
void* pNtWriteFile = ResolveExportFromDisk(
L"C:\\Windows\\System32\\ntdll.dll",
"NtWriteFile"
);
if (pNtWriteFile) {printf("从磁盘 ntdll.dll 找到 NtWriteFile,地址:%p\n", pNtWriteFile);
} else {printf("解析 NtWriteFile 失败 \n");
}
}
3. 重映射 ntdll.dll(Remapping ntdll):使用“干净”的模块副本
如果 ntdll.dll 被大量 Hook,上述方法可能失效。此时可直接从磁盘加载一份“干净”的ntdll.dll,重映射到当前进程内存,完全避开内存中被 Hook 的模块。
核心步骤如下:
- 加载磁盘 ntdll.dll 为镜像 :通过
CreateFileMappingW的SEC_IMAGE参数,让系统自动处理 PE 文件的节对齐,生成可执行的镜像。 - 复制到可执行内存 :将镜像复制到
PAGE_EXECUTE_READWRITE属性的内存区域,确保代码可执行。 - 修复重定位(可选):如果新内存地址与 DLL 原始基址不同,需根据重定位表修正内存中的指针(简化场景可忽略,复杂场景必须处理)。
- 解析导出函数:从新映射的“干净”ntdll.dll 中获取目标函数地址,直接调用。
以下是重映射 ntdll.dll 的实现代码,可用于获取无 Hook 的NtWriteFile:
#include <windows.h>
#include <vector>
#include <string>
// 存储重映射模块的基地址与大小
struct RemappedModule {
BYTE* pBase; // 模块基地址
SIZE_T nSize; // 模块总大小
};
// 重映射磁盘上的 ntdll.dll 到当前进程内存
RemappedModule RemapNtdll() {RemappedModule mod = {nullptr, 0};
wchar_t szSystemDir[MAX_PATH] = {0};
// 获取系统目录(如 C:\Windows\System32)GetSystemDirectoryW(szSystemDir, MAX_PATH);
std::wstring strNtdllPath = szSystemDir + L"\\ntdll.dll";
// 第一步:打开磁盘 ntdll.dll
HANDLE hFile = CreateFileW(strNtdllPath.c_str(),
GENERIC_READ,
FILE_SHARE_READ,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
);
if (hFile == INVALID_HANDLE_VALUE) return mod;
// 第二步:创建文件映射,指定 SEC_IMAGE 让系统处理 PE 结构
HANDLE hMap = CreateFileMappingW(
hFile,
nullptr,
PAGE_READONLY | SEC_IMAGE, // SEC_IMAGE:按 PE 镜像格式映射
0, 0,
nullptr
);
if (!hMap) {CloseHandle(hFile);
return mod;
}
// 第三步:映射镜像视图,获取 PE 镜像基地址
BYTE* pImageBase = (BYTE*)MapViewOfFile(
hMap,
FILE_MAP_READ,
0, 0, 0
);
if (!pImageBase) {CloseHandle(hMap);
CloseHandle(hFile);
return mod;
}
// 第四步:获取镜像大小,申请可执行内存
auto pDosHeader = (IMAGE_DOS_HEADER*)pImageBase;
auto pNtHeader = (IMAGE_NT_HEADERS*)(pImageBase + pDosHeader->e_lfanew);
SIZE_T nImageSize = pNtHeader->OptionalHeader.SizeOfImage;
BYTE* pNewBase = (BYTE*)VirtualAlloc(
nullptr,
nImageSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // 可执行 + 读写,用于复制镜像
);
if (!pNewBase) {UnmapViewOfFile(pImageBase);
CloseHandle(hMap);
CloseHandle(hFile);
return mod;
}
// 第五步:复制镜像到新内存,完成重映射
memcpy(pNewBase, pImageBase, nImageSize);
mod.pBase = pNewBase;
mod.nSize = nImageSize;
// 第六步:释放临时资源
UnmapViewOfFile(pImageBase);
CloseHandle(hMap);
CloseHandle(hFile);
return mod;
}
// 从已重映射的模块中解析导出函数(模板函数,支持任意函数指针类型)template <typename T>
T ResolveFromRemap(const RemappedModule& mod, const char* lpExportName) {if (!mod.pBase) return nullptr;
// 解析 PE 导出表,逻辑与 ResolveExportFromDisk 一致
auto pDosHeader = (IMAGE_DOS_HEADER*)mod.pBase;
auto pNtHeader = (IMAGE_NT_HEADERS*)(mod.pBase + pDosHeader->e_lfanew);
auto pExportDir = (IMAGE_EXPORT_DIRECTORY*)(mod.pBase + pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
);
DWORD* pNames = (DWORD*)(mod.pBase + pExportDir->AddressOfNames);
WORD* pOrdinals = (WORD*)(mod.pBase + pExportDir->AddressOfNameOrdinals);
DWORD* pFuncs = (DWORD*)(mod.pBase + pExportDir->AddressOfFunctions);
for (DWORD i = 0; i < pExportDir->NumberOfNames; ++i) {const char* pName = (char*)(mod.pBase + pNames[i]);
if (strcmp(pName, lpExportName) == 0) {DWORD dwFuncRva = pFuncs[pOrdinals[i]];
return reinterpret_cast<T>(mod.pBase + dwFuncRva);
}
}
return nullptr;
}
// 测试:重映射 ntdll.dll 并获取 NtWriteFile
void TestRemapNtdll() {RemappedModule mod = RemapNtdll();
if (!mod.pBase) {printf("ntdll.dll 重映射失败 \n");
return;
}
// 从重映射的模块中获取 NtWriteFile
auto pNtWriteFile = ResolveFromRemap<NtWriteFile_t>(mod, "NtWriteFile");
if (pNtWriteFile) {printf("从重映射 ntdll.dll 找到 NtWriteFile,地址:%p\n", pNtWriteFile);
} else {printf("从重映射模块解析 NtWriteFile 失败 \n");
}
// 不再使用时释放内存
VirtualFree(mod.pBase, 0, MEM_RELEASE);
}
七、总结
本文围绕 用户态函数挂钩(Function Hooking) 展开,从基础原理到实战实现,覆盖了 Hook 的分类、Inline Hook 手动实现、Detours 库使用、EDR 的 DLL 注入手段,以及攻防双方的检测与规避技术。通过具体 C++ 代码示例,读者可搭建实验环境验证每一步逻辑,加深对 Windows 底层机制的理解。
对于防守方(如 EDR 开发),需重点关注 Hook 的隐蔽性与检测的全面性,例如结合内核态监控、硬件辅助虚拟化(如 Intel VT-x/AMD-V)实现更可靠的控制流保护;对于进攻方(如安全研究),则需掌握系统调用、PE 解析、模块重映射等底层技术,在授权场景下探索对抗边界。
未来的研究方向可聚焦于三个领域:
- 内核态 Hook 技术 :深入
ntoskrnl.exe的内核函数 Hook,如 SSDT(系统服务描述符表)Hook、IDT(中断描述符表)Hook,实现更底层的监控与对抗。 - 硬件级监控:基于 Intel SGX、AMD SEV 等可信执行环境(TEE),或利用处理器的性能监控单元(PMU),实现无法被软件绕过的控制流检测。
- 自动化分析工具:开发 Hook 检测与反 Hook 的自动化工具,通过静态分析(PE 文件解析)与动态插桩(内存快照对比),快速识别 Hook 行为并生成对抗方案。
函数挂钩技术是 Windows 安全攻防的“基石”,只有深入理解其原理与边界,才能在攻防对抗中占据主动,无论是构建更安全的防护系统,还是开展合规的漏洞研究,都离不开对这一技术的掌握。