现代 EDR 对抗基础:用户态函数挂钩(Function Hooking)原理与实战指南

70次阅读
没有评论

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

现代 EDR 对抗基础:用户态函数挂钩(Function Hooking)原理与实战指南

一、函数挂钩(Hook)是什么?为什么需要它?

Windows 应用程序执行系统调用时,并非直接与内核交互,而是要经过“用户代码→Win32 API→ntdll.dll→内核”多个中间层。EDR 或调试工具只要在任意一层设置“跳板(Trampoline)”,就能在不修改应用源码的情况下,监控甚至篡改进程行为 —— 这就是函数挂钩的核心价值。

根据修改位置的不同,常见的 Hook 技术可分为三类,各自适用场景与特点差异明显:

  • Inline Hook:直接修改目标函数开头的字节指令,侵入性强但适用范围广,不过需要保存原始指令以防程序崩溃。
  • IAT Hook(导入地址表 Hook):篡改模块导入表中的函数指针,对调用方完全透明,适合监控单个模块的 API 调用。
  • EAT Hook(导出地址表 Hook):修改 DLL 的导出表,会影响所有依赖该 DLL 的进程模块,覆盖范围最广但风险较高。

本文重点讲解最常用的Inline Hook,理解它只需掌握三个基础认知:

  1. 代码段本质是可修改的内存:通过 VirtualProtect 函数调整内存页保护属性,就能修改任何可执行模块的机器码。
  2. x86/x64 指令长度不固定:指令长度在 1-15 字节之间,Hook 时必须覆盖完整指令,否则会出现非法操作导致程序崩溃。
  3. 必须依赖“跳板”实现流程跳转:把目标函数的原始指令保存到自定义缓冲区(Trampoline),执行完自定义逻辑后,再通过跳板跳回原函数剩余部分。

二、Inline Hook 实现流程:以 CreateFileW 为例

以监控文件创建的 CreateFileW 函数为例,Inline Hook 的实现分为 4 个关键步骤,每一步都需严格遵循内存操作与指令逻辑:

  1. 修改内存页保护属性 调用 VirtualProtectCreateFileW所在内存页的属性改为PAGE_EXECUTE_READWRITE,确保后续能写入跳转指令。
  2. 保存原始指令并构建跳板 复制 CreateFileW 开头的若干字节(通常 16 字节,覆盖足够多完整指令),保存到 InlineHook::original 结构体中;同时在 InlineHook::trampoline 中添加一条 jmp 指令,用于跳回原函数未被修改的部分。
  3. 写入跳转指令到目标函数 CreateFileW开头写入无条件跳转指令(x64 下常用MOV RAX + JMP RAX,x86 下常用JMP rel32),让函数调用直接进入自定义处理逻辑。
  4. 完成 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 记录文件访问日志为例:

  1. 开启 Detours 事务 调用 DetourTransactionBegin 冻结当前线程执行流,避免打补丁时出现线程竞态问题。
  2. 指定受影响线程 通过 DetourUpdateThread 告诉 Detours 需要暂停的线程,通常传入GetCurrentThread()(当前线程)即可。
  3. 附加或拆除 Hook使用 DetourAttach 安装 Hook(将目标函数与自定义函数绑定),DetourDetach用于卸载 Hook。
  4. 提交事务 调用 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,适合对已有进程注入,步骤清晰:

  1. 获取目标进程句柄 :调用OpenProcess 获取目标进程的 PROCESS_ALL_ACCESS 权限句柄。
  2. 写入 DLL 路径到目标进程 :用VirtualAllocEx 在目标进程分配内存,再通过 WriteProcessMemory 写入 DLL 完整路径。
  3. 创建远程线程触发加载:让远程线程执行LoadLibraryW,参数为第二步写入的 DLL 路径,此时 DLL 会被加载到目标进程。
  4. 等待加载完成 :调用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 例程,例程中调用 LdrLoadDllLoadLibraryW加载 Hook DLL。

由于逻辑运行在内核态,KAPC 注入能绕过用户态防护,在进程初始化的最早阶段插入监控,隐蔽性与成功率更高。

五、防守方视角:如何检测函数挂钩(Hook)?

无论是构建 EDR 还是保障程序安全,检测函数是否被 Hook 都是核心需求。常见的检测策略基于“内存对比”与“完整性校验”,以下是 4 种实用方法:

1. 字节对比检测(最基础)

读取内存中目标函数的开头字节,与磁盘上该函数所在 DLL 的原始字节对比。如果存在跳转指令(如0xE90xFFE0)或无意义填充,说明函数可能被 Hook。

2. 函数完整性校验

对目标函数所在的内存页计算 MD5、SHA256 哈希,与预先存储的“干净”哈希值对比。若哈希不一致,表明内存被篡改,存在 Hook 风险。

3. 导入表(IAT)验证

遍历进程的导入地址表,检查每个函数指针是否指向预期的 DLL 模块(如kernel32.dllntdll.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 应用调用的 WriteFileCreateFileW 等 Win32 API,最终都会通过 ntdll.dll 中的 NtWriteFileNtCreateFile 等函数,通过 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 的模块。

核心步骤如下:

  1. 从磁盘加载 ntdll.dll 只读副本 :通过CreateFileW 打开 C:\Windows\System32\ntdll.dll,再用CreateFileMappingWMapViewOfFile映射为只读内存,不执行任何代码。
  2. 手动解析 PE 结构找导出表:遍历 PE 文件的导出表,找到目标函数(如NtWriteFile)的相对虚拟地址(RVA)。
  3. 提取系统调用号 :根据函数 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 的模块。

核心步骤如下:

  1. 加载磁盘 ntdll.dll 为镜像 :通过CreateFileMappingWSEC_IMAGE参数,让系统自动处理 PE 文件的节对齐,生成可执行的镜像。
  2. 复制到可执行内存 :将镜像复制到PAGE_EXECUTE_READWRITE 属性的内存区域,确保代码可执行。
  3. 修复重定位(可选):如果新内存地址与 DLL 原始基址不同,需根据重定位表修正内存中的指针(简化场景可忽略,复杂场景必须处理)。
  4. 解析导出函数:从新映射的“干净”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 解析、模块重映射等底层技术,在授权场景下探索对抗边界。

未来的研究方向可聚焦于三个领域:

  1. 内核态 Hook 技术 :深入ntoskrnl.exe 的内核函数 Hook,如 SSDT(系统服务描述符表)Hook、IDT(中断描述符表)Hook,实现更底层的监控与对抗。
  2. 硬件级监控:基于 Intel SGX、AMD SEV 等可信执行环境(TEE),或利用处理器的性能监控单元(PMU),实现无法被软件绕过的控制流检测。
  3. 自动化分析工具:开发 Hook 检测与反 Hook 的自动化工具,通过静态分析(PE 文件解析)与动态插桩(内存快照对比),快速识别 Hook 行为并生成对抗方案。

函数挂钩技术是 Windows 安全攻防的“基石”,只有深入理解其原理与边界,才能在攻防对抗中占据主动,无论是构建更安全的防护系统,还是开展合规的漏洞研究,都离不开对这一技术的掌握。

正文完
 0
Fr2ed0m
版权声明:本站原创文章,由 Fr2ed0m 于2025-10-25发表,共计15572字。
转载说明:Unless otherwise specified, all articles are published by cc-4.0 protocol. Please indicate the source of reprint.
评论(没有评论)