找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1628

积分

0

好友

212

主题
发表于 13 小时前 | 查看: 1| 回复: 0

技术概念插图

前置知识

导入表

当我们的可执行程序需要调用其他 DLL 文件中的函数时,就需要用到导入表。例如,程序需要用到 CreateProcess 函数,那么它就必须依赖 kernel32.dll 文件,并将其中 CreateProcess 函数的信息“导入”到自己的地盘里,之后才能顺利调用。

为了统一管理这些“外来”函数,系统引入了导入表这一结构。简单来说,程序里每调用一个导入函数,系统就会去导入表里查找该函数的“登记信息”,获取其地址,然后完成调用。

程序调用DLL函数流程示意图

因此,程序加载器在调用任何导入函数之前,首要任务就是找到导入表的位置。当可执行文件被映射到内存时,一切都是从 Dos Header 开始的。这个头部里有一个关键的 e_lfanew 字段,它指向 PE 文件头的偏移量。在 PE 文件头中,有一个可选头结构,里面存放着数据目录项,而导入表的信息就保存在这里。所以,在内存中找到导入表的完整路径是:Dos Header -> Nt Header -> Option Header -> Import Table

PE文件头结构寻址流程图

借助《加密与解密》一书中的经典图示,我们可以更直观地了解导入表的结构。

导入表(IMAGE_IMPORT_DESCRIPTOR)结构图

图中涉及的变量很多,我们先聚焦三个核心成员:OriginalFirstThunkFirstThunk 以及 Name

  • Name:指向所导入的DLL库的名称字符串。
  • OriginalFirstThunk:指向输入名称表(INT),里面存储着导入函数的信息(比如函数名或序号)。
  • FirstThunk:指向输入地址表(IAT)。初始化时,OriginalFirstThunkFirstThunk 指向的是同一块区域(都是函数信息)。

输入名称表(INT)的具体结构可以参考下图,我们主要关注 OrdinalAddressOfData 两个字段。

  • Ordinal:以序号的形式标识导入函数。
  • AddressOfData:以函数名的形式标识导入函数。

IMAGE_THUNK_DATA与IMAGE_IMPORT_BY_NAME结构图

那么,INT 和 IAT 的本质区别是什么呢?关键在于,程序加载器会从 INT 中读取函数名(或序号),然后在内存中定位该函数在对应DLL中的实际地址,并将这个真实地址填写到 IAT 中。所以,加载完成后,IAT 里存放的就是可以直接跳转的函数地址了。下图清晰地展示了这个过程:

IAT由PE装载器填写真实地址示意图

导入地址表钩取技术

所谓导入地址表钩取技术(IAT Hook),原理非常直接:通过修改输入地址表(IAT)中某个函数的地址值,当程序再次尝试调用该函数时,就会“误入歧途”,跳转到我们预设的自定义地址上。

钩取之前,程序的调用流程是正常的:

IAT Hook前程序调用流程

成功实施钩取之后,调用流程就被我们“劫持”了:

IAT Hook后程序调用流程

总结一下 IAT Hook 的核心步骤:

  1. 确定目标:明确需要钩取的导入函数。
  2. 定位战场:获取目标进程的输入地址表(IAT)的地址。
  3. 实施替换:在IAT中搜索目标函数的地址,并将其替换为我们自定义函数的地址。
  4. 完成调用:在我们的自定义函数中处理逻辑后,必须重新调用原始的被钩取函数,以确保程序功能完整。

确定需要钩取的导入函数

首先,我们需要侦察目标程序都导入了哪些函数。使用 PE 分析工具(如 PE-bear, CFF Explorer 等)可以清楚地看到,目标程序导入了 kernel32.dll,并且其中包含了 CreateProcessW 函数。

PE工具查看导入表示例图

那么,我们就以钩取 CreateProcessW 函数为例进行实战。

获取导入地址表的地址

按照之前讲解的路径 DOS Header -> Nt Header -> Option Header -> Import Table 进行解析,即可定位到 IAT。这本质上是对 Windows PE文件结构的一次遍历。

关键代码如下:

...
//获取当前进程的基地址
    hMod = GetModuleHandle(NULL);
    pBase = (PBYTE)hMod;
//进程的基地址是从DOS头开始的
    pImageDosHeader = (PIMAGE_DOS_HEADER)hMod;
//通过e_lfanew变量获取NT头的偏移,然后加上基地址及NT头的位置
    pImageNtHeaders = (PIMAGE_NT_HEADERS)(pBase + pImageDosHeader->e_lfanew);
//数据目录项下标为1的项是导入表
    pImageImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(pImageNtHeaders->OptionalHeader.DataDirectory[1].VirtualAddress + pBase)
...

获取导入函数地址并修改

找到导入表的地址后,我们需要遍历其中所有的导入描述符。首先提取 Name 字段,判断是否为我们要钩取的DLL(例如 kernel32.dll)。匹配成功后,继续遍历该描述符对应的 IAT 数组,寻找目标函数(如 CreateProcessW)的地址。找到后,将其替换为我们自定义函数的地址。

IAT中查找并修改目标函数地址示意图

代码如下,这里涉及了关键的指针操作和内存权限修改:

    ...
//遍历导入表项
    for (; pImageImportDescriptor->Name; pImageImportDescriptor++)
    {
//获取导入库的名称
        szLibName = (LPCSTR)(pImageImportDescriptor->Name + pBase);
//比较导入库的名称,判断是否为kernel32.dll
        if (!_stricmp(szLibName, szDllName))
        {
//获取IAT
            PIMAGE_THUNK_DATA pImageThunkData = (PIMAGE_THUNK_DATA)(pImageImportDescriptor->FirstThunk + pBase);
//获取导入函数地址
            for (; pImageThunkData->u1.Function; pImageThunkData++)
            {
//判断函数地址是否是需要钩取的函数地址,这里需要注意的是64位与32位地址的区别
                if (pImageThunkData->u1.Function == (ULONGLONG)pfnOrg)
                {
//修改IAT的权限为可写
                    VirtualProtect(&pImageThunkData->u1.Function, 4, PAGE_EXECUTE_READWRITE, &dwOldProtect);
//将原始的地址修改为自定义函数地址
                    pImageThunkData->u1.Function = (ULONGLONG)pfnNew;
//将权限恢复
                    VirtualProtect(&pImageThunkData->u1.Function, 4, dwOldProtect, &dwOldProtect);
                    return TRUE;
                }
            }
        }
        ...

在自定义函数中重新调用被钩取的函数

这一步至关重要。我们需要定义一个与原函数返回值类型、调用约定和参数列表完全一致的自定义函数。这样,我们就能接收到原始调用传来的所有参数,可以任意检查、修改它们,然后再将(可能被篡改的)参数传递给真正的原始函数。

自定义函数篡改参数后调用原函数示意图

示例代码如下,这里我们篡改了 CreateProcessW 的第一个参数(应用程序名),强制其启动计算器:

...
    LPCWSTR applicationName = L"C:\\Windows\\System32\\calc.exe";

    return ((LPFN_CreateProcessW)g_pOrgFunc)(applicationName,
        lpCommandLine,
        lpProcessAttributes,
        lpThreadAttributes,
        bInheritHandles,
        dwCreationFlags,
        lpEnvironment,
        lpCurrentDirectory,
        lpStartupInfo,
        lpProcessInformation);
...

完整代码示例可以在 GitHub 查看:https://github.com/h0pe-ay/HookTechnology/blob/main/Hook-IAT/iat.cpp

调试技巧

在开发这类底层钩子时,调试是关键。我最初使用 x64dbg,但后来发现 WinDbg 配合源码调试更加强大。这里记录几个核心的调试步骤和命令。

符号表与源码加载

在 WinDbg 的设置中,可以指定默认的源码目录和符号文件(.pdb)路径。符号文件是由 Visual Studio 编译生成的,包含了调试信息。

注意:默认的符号路径 srv*c:\Symbols*https://msdl.microsoft.com/download/symbols 会从微软服务器下载系统符号,如果只想调试自己的代码,可以删掉它以避免不必要的下载等待。

WinDbg调试路径设置界面

源码文件也可以在侧边栏通过 Open source file 选项直接打开。

DLL加载调试

IAT Hook 通常需要先将我们的 Hook DLL 注入到目标进程。为了调试注入和钩取过程,我们需要在 DLL 被加载时中断。

  • 使用 sxe ld:xxx.dll 命令,可以在加载 xxx.dll 时触发断点。
  • 使用 sxe ud:xxx.dll 命令,则在卸载 xxx.dll 时触发断点。

WinDbg中使用sxe命令设置模块加载断点

关闭编译器优化

为了方便单步跟踪和查看变量值,建议在编译用于调试的 DLL 时关闭编译器优化。否则,自定义函数中的一些变量可能会被优化掉,导致调试困难。

在 Visual Studio 的项目属性中,找到“C/C++” -> “优化”,将“优化”选项设置为“已禁用(/Od)”即可。

Visual Studio中关闭编译器优化选项

总结与思考

IAT Hook 是一种经典的Windows安全与逆向技术,其原理清晰,实现相对直接。它通过篡改程序内存中“函数地址簿”(IAT)的方式,实现了对特定API调用的监控和拦截。掌握这项技术,不仅能深化对Windows PE文件结构和程序加载机制的理解,也是迈向更高级Hook技术(如Inline Hook、SSDT Hook等)的重要一步。

当然,现代的安全软件(如反病毒、EDR)对这类内存篡改行为非常敏感。在实际应用中,还需要考虑如何绕过内存保护、如何隐蔽自身等技术细节,这又是另一个广阔的领域了。

参考链接

  • 《加密与解密》
  • 《逆向工程核心原理》

对底层安全和Hook技术感兴趣的开发者,欢迎在云栈社区交流讨论,共同探索操作系统的奥秘。




上一篇:Team Agent从构想走向落地:深度剖析飞书成为国内团队首选平台的结构性优势
下一篇:盘点我亲测的8款数据治理工具,聊聊新手老手怎么选
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-4 20:51 , Processed in 0.479422 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表