代码的隐形战场:Windows进程注入与API Hook完全指南
在数字时代的攻防战场上,Windows 注入与 Hook 技术如影随形,它们始终是开发者与安全研究员的“终极兵器库”。无论是逆向工程师通过动态调试破解加密算法,还是恶意软件通过进程注入窃取敏感信息,这项技术始终游走在合法与非法的灰色边缘。它既是系统底层机制的“潘多拉魔盒”,也是守护软件安全的“达摩克利斯之剑”——掌握它,你既能修复高危漏洞,也可能成为攻击者的帮凶。
注册表方式注入
这是一种最简单的注入方式,我们只需要开发正常的 DLL,然后在注册表AppInit_DLLs项中指定该 DLL 的名称,待系统User32.dll被加载到新进程时,该 DLL 也会被加载到目标进程。这是因为 User32.dll 会在 DLL_PROCESS_ATTACH 通知处理过程中读取该注册表值,并依次加载该项中的每个 DLL。
AppInit_DLLs 注册表项的路径如下:
1 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs |
如果是在 x64 系统上,将 32 位 DLL 注入到 32 位进程,需要使用Wow6432Node路径(下面的 LoadAppInit_DLLs 也是一样):
1 | HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs |
AppInit_DLLs 中的文件名通过逗号或空格来分割,因此在文件名中要避免使用空格。另外 AppInit_DLLs 中只有第一个文件可以包含路径,后面的文件的路径则将被忽略,出于这个原因,我们最好将 DLL 文件拷贝到 Windows 的系统目录中。
一切准备妥当后,还需将 LoadAppInit_DLLs 注册表项的值修改为 1,路径如下:
1 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\LoadAppInit_DLLs |
这种注入方式虽然简单,但存在很大的弊端,因为 DLL 是通过 User32.dll 加载到目标进程中去的,这也就要求被注入的目标进程必须使用了 User32.dll,虽然基于 GUI 的程序都会使用这个 DLL 文件,但命令行程序一般不会加载 User32.dll,所以可能无法通过这种方式被注入。而且这种方式会导致系统上所有使用了 User32.dll 的程序都会被注入 DLL,很多时候这也并不是我们所期望的。
DLL 欺骗注入
我们在启动一个可执行程序时,操作系统会先为进程创建虚拟地址空间,然后把可执行模块映射到进程的地址空间中,之后会遍历导入段(即 PE 的导入表),定位所依赖的 DLL 并映射到该进程的地址空间中。由于导入段中只包含了 DLL 的名称,不包含 DLL 的路径,因此加载程序会按照如下顺序在磁盘上查找 DLL(优先级从高到低):
- 包含可执行文件的目录。
- Windows 系统目录,通常为 C:\Windows\System32,可以使用 GetSystemDirectory 函数获取。
- Windows 目录,通常为 C:\Windows,可以使用 GetWindowsDirectory 函数获取。
- 进程的当前工作目录。
- PATH 环境变量中所列出的目录。
上面是程序启动时,操作系统查找程序依赖 DLL 的标准流程。在使用 LoadLibrary 加载 DLL 时,如果没有指定全路径,而是仅仅指定了文件名或相对路径,LoadLibrary 也会使用上面顺序进行搜索。但如果使用 SetDllDirectroy 函数设置了 DLL 搜索目录,或者使用 LoadLibraryEx 函数并设置了 LOAD_WITH_ALTERED_SEARCH_PATH 标志,此时程序搜索 DLL 的方式会与上面的标准搜索顺序所有不同,详见《Windows 核心编程》。
DLL 欺骗注入也叫 DLL 劫持,就是在上面 DLL 的搜索路径中放入我们自己 DLL,来欺骗系统或应用进行加载。
使用 Depends 工具查看程序依赖的 DLL,选择一个 DLL 来伪造。
需要注意,HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs注册表中指定的 DLL 不能伪造。
伪造 DLL 需要与原 DLL 的导出函数完全一致,并提供相同的功能,否则程序会加载失败或功能异常,因此我们在选择 DLL 时,尽量选择功能简单的 DLL,如 version.dll、hid.dll 等。
编写伪造 DLL 的流程通常如下:
- 在 DllMain 函数中加载原 DLL。
- 通常在 DllMain 函数中还会执行相关逻辑,如 Hook 某个函数等(非必须)。
- 使用函数转发器技术将导出函数的功能转发到跳板函数,并在跳板函数中调用原 DLL 中对应函数的功能,确保伪造 DLL 能够提供与原 DLL 一致的功能。
上述操作是一件繁琐的、费时费力的事情,好在已有前辈开发了工具来根据原 DLL 自动生成相应的 VS 工程,下面是我修改后的版本,支持 x86 和 x64 架构:
https://github.com/winsoft666/AheadLib
消息钩子注入
Windows 提供了 SetWindowsHookEx 函数来为指定线程安装一个消息钩子。当指定线程中的特定消息被钩住时,系统就会将我们的 DLL(前提是钩子的处理过程是位于 DLL 中的)加载到该线程所属进程的地址空间中,并且在该地址空间中调用我们的 DLL 中的钩子处理过程函数,从而实现 DLL 注入功能。
1 | HHOOK SetWindowsHookExA( |
操作系统同时允许开发人员为“同一个线程”的“同一个消息类型”指定多个钩子,这样就形成了一个“钩子链”(Hook Chain),当我们的钩子处理函数(由 lpfn 参数指定)将消息处理完之后,可以选择将消息丢弃,不让钩子链后面的钩子进行处理,也可以在钩子处理函数的最后调用 CallNextHookEx 函数,让消息继续传递下去,从而让其他钩子有处理的机会。
SetWindowsHookEx 函数返回了HHOOK类型的钩子句柄,而且 CallNextHookEx 和 UnhookWindowsHookEx 函数都需要使用这个句柄作为参数。因此,如果我们将“注入逻辑”放在独立的 exe 中,将“钩子处理过程”放到独立的 dll 中,那么我们需要通过其他途径将该句柄从 exe 传递到 dll 中,确保在“钩子处理过程”中能够使用该句柄调用 CallNextHookEx。
为了避免传递钩子句柄的麻烦,我们通常将注入、取消注入、钩子处理过程都写在一个 DLL 中。在 DLL 中导出开始和停止函数,外部程序调用这两个函数就可以实现注入和取消注入的功能了。
远程线程注入
注入原理
远程线程注入方式使用的关键系统 API 为 CreateRemoteThread,原型如下:
1 | HANDLE WINAPI CreateRemoteThread( |
CreateRemoteThread 相比 CreateThread 只是新增了 hProcess 句柄参数,该参数用于指定远程线程所在的目标进程。
可以通过任意方式获取目标进程的句柄,但需要确保该句柄具有合适的访问权限,避免后续的函数调用失败,通常应具有如下权限,具体见 API 文档:
1 | PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ |
通过远程线程方式实现 DLL 注入主要是在lpStartAddress和lpParameter这 2 个参数上面做文章。
lpStartAddress 参数用于指定远程线程的处理过程函数,函数原型分别如下:
1 | DWORD WINAPI ThreadProc(LPVOID lpParameter); |
该函数原型与加载 DLL 所使用的 API(Kernel32::LoadLibraryA 或 Kernel32::LoadLibraryW)的原型基本相同:
1 | HMODULE WINAPI LoadLibrary(LPCTSTR lpFileName); |
虽然不是完全相同,但都是接收一个指针参数,返回一个值(在不同架构的软件中,返回值所占字节数有区别,见下面注解),并且调用约定也都是WINAPI。因此,我们可以利用它们之间的相似性,把线程处理函数的地址设为 LoadLibraryA 或 LoadLibraryW 的函数地址。
在 32 位程序中,DWORD 和 HMODULE 都占 4 字节,而在 64 位程序中,DWORD 占 4 字节,HMODULE 占 8 字节。因此下面介绍的获取 DLL 句柄的方式只适用于 32 位程序。
目标进程的 LoadLibrary 的地址是多少呢?
通常来说,无论是否开启 ASLR(地址空间布局随机化),DLL 加载到目标进程的基地址都不会是固定的,因此模块中函数的地址也不是固定的。那么我们如何确定目标进程中的 Kernel32::LoadLibraryA 的地址呢?幸运的是,kernel32.dll、user32.dll、 ntdll.dll 等系统 DLL 在不同的进程中的基地址都是一样的,因此我们可以直接使用当前进程中的 LoadLibraryA 函数地址。
下列系统 DLL 在所有进程中的基地址都是一样的:
kernel32.dll、user32.dll、ntdll.dll、gdi32.dll、gdi32full.dll、comdlg32.dll、shell32.dll、advapi32.dll、msvcrt.dll、 ucrtbase.dll、ole32.dll、oleaut32.dll、rpcrt4.dll、ws2_32.dll、shcore.dll、imm32.dll、crypt32.dll
但不能直接使用下面的方式来传递 LoadLibraryA 或 LoadLibraryW 的地址,因为直接使用 LoadLibraryA 会被解析为我们程序导入段中的 LoadLibraryA 转换函数的地址,这个转换函数的地址只在当前进程有效。
1 | CreateRemoteThread(hTargeProcess, NULL, 0, LoadLibraryA, ...); |
所以我们要通过 GetProAdress 方式获取 LoadLibraryA 地址:
1 | LPVOID pLoadLibraryAddr = (LPVOID)GetProcAddress(GetModuleHandle(TEXT("kernel32.dll")), "LoadLibraryA"); |
解决了 LoadLibraryA 函数地址的问题,再来解决 DLL 路径的问题。DLL 路径字符串(如”C:\InjectDll.dll”)的内存地址位于调用进程的地址空间中,并不位于被注入的目标进程的地址空间中。所以,在目标进程中使用 LoadLibraryA 访问该地址时,会出现访问违规。
为了解决这个问题,我们需要把 DLL 路径字符串存储到被注入进程的地址空间中。通过 Windows 提供的 VirtualAllocEx 和 WriteProcessMemory 函数,可以实现在目标进程地址空间中分配内存和写入内存。
1 | SIZE_T size = strlen(pszDllPath) + 1; |
最后,调用 CreateRemoteThread 创建远程线程:
1 | LPVOID pLoadLibraryAddr = (LPVOID)GetProcAddress(GetModuleHandle(TEXT("kernel32.dll")), "LoadLibraryA"); |
当 CreateRemoteThread 所创建的线程在远程进程地址空间中被创建的时候,就会立即调用 LoadLibraryA 函数,并使用 DLL 路径作为其参数,从而实现 DLL 的加载。
获取 DLL 句柄
正常情况下,CreateRemoteThread 返回了线程句柄,待线程结束后通过 GetExitCodeThread 函数获取的线程退出码,线程退出码是线程处理过程函数的返回值,在使用上面方式进行远程线程注入时,GetExitCodeThread 函数获取的退出码则实际是 LoadLibraryA 函数的返回值,也就是线程的句柄。
1 | HMODULE hDll = NULL; |
正如上面一节注解中所介绍的那样,这种获取注入 DLL 句柄的方式仅适用与 32 位程序,因为 64 位程序的句柄占 8 字节,使用 4 字节的 DWORD 类型存储,可能会导致数据截断。但如果不需要获取 DLL 句柄或者注入后再通过遍历进程模块的方式获取 DLL 句柄,则这种注入方式也同时适用于 32/64 位程序。
取消注入
取消注入就是将 DLL 从目标进程卸载,卸载 DLL 所用的 API 是FreeLibrary,但我们不能直接调用这个函数,因为直接调用的话是在我们的进程中卸载 DLL,而不是目标进程中卸载,很显然这样达不到卸载的目的。我们需要和加载 DLL 时一样,将FreeLibrary的地址作为第 4 个参数传给 CreateRemoteThread 函数:
1 | CreateRemoteThread(hTargeProcess, NULL, 0, FreeLibrary, hDll, 0, NULL); |
ShellCode 版远程线程注入
ShellCode 版远程线程注入是上一节普通远程线程注入的升级版,不仅可以弥补 64 位远程线程注入无法获取 DLL 句柄的遗憾(普通远程线程注入的遗憾在于 4 字节的 DWORD 不足以存储 64 位的句柄),而且对于后面介绍的 RIP/EIP 劫持注入也有一定的铺垫作用。
本节介绍的 ShellCode 使用的都是 64 位寄存器,主要针对 64 位程序,32 位程序还需要做相应的修改。
ShellCode 版远程线程注入的步骤大致如下:
- 获取目标进程句柄(与普通版一样)。
- 在目标进程分配内存(记作 M1),并写入 DLL 路径(与普通版一样)。
- 在目标进程中分配 8 字节内存区域(记作 M2),用于存储返回的 DLL 句柄。
- 构造 ShellCode,该 ShellCode 用于在目标进程中调用 LoadLibrary,并将结果存储到步骤 3 分配的 M2 内存。
- 在目标进程分配内存(记作 M3),并写入步骤 4 构造的 ShellCode,M3 需要有 PAGE_EXECUTE_READWRITE 保护属性。
- 将 ShellCode 所在内存 M3 的地址作为 lpStartAddress 参数传递给 CreateRemoteThread 函数,而不再是 LoadLibrary 的地址。
- 等待线程结束后,从 M2 读取 DLL 句柄的值。
ShellCode 看起来很神秘,其实并不高深,它就是一段汇编代码所对应的机器码而已。我们使用简单的汇编指令即可完成汇编代码的编写工作,如 mov、add、sub、lea、call、ret 等,然后将汇编指令转成机器码。
如何将汇编指令转机器码?
网上有很多工具可以使用,如 ASM to Hex Convert、Online-Assembler-and-Disassembler,也可以借助 x64dbg,在其中修改汇编代码(快捷键 Space),就会自动生成对应的机器码。
下面来讲讲如何使用汇编代码来构造一个函数。
1 | // CreateRemoteThread在调用下面函数时,会将 pDllPathAddress 会作为参数存入到 rcx |
由于 ShellCode 中的地址都是一次性的,在每次注入时都会改变,所以上面代码中使用的硬编码绝对地址不会出现地址失效的问题。
又因为在上述汇编代码中没有使用堆栈获取局部变量、函数参数,所以也就没有使用和暂存 rbp 寄存器,而在 32 位程序中需要使用 ebp 寄存器来获取参数。
此外,在汇编程序中,没有直接的 call 0x12345678 立即数绝对地址形式,call 指令后的立即数会被认为是相对 RIP/EIP 的偏移量。如果需要调用绝对地址需要通过间接方式,如上面汇编代码中的:
1 | ; ❌ 错误 |
而且汇编程序也不支持使用立即数作为内存操作数,需要通过寄存器中转方式,如上面汇编代码中的:
1 | ; ❌ 错误 |
上述汇编代码转成的字节码如下:
1 | BYTE shellCode[] = { |
其中第 5、7 两行中的 0x00 是占位符,需要替换为实际的内存地址:
1 | *(DWORD64*)(&shellCode[16]) = (DWORD64)pLoadLibraryAddr; |
最后通过 CreateRemoteThread 调用该 ShellCode 函数,并传入 DLL 路径地址作为参数:
1 | HANDLE hRemoteThread = CreateRemoteThread(hTargeProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pShellCodeAddress, pDllPathAddress, 0, NULL); |
EIP/RIP 劫持注入
EIP/RIP 劫持与上面介绍的 ShellCode 版远程线程注入有异曲同工之处,但最大的区别在于 EIP/RIP 劫持注入不用调用 CreateRemoteThread 函数,而是直接修改线程的 EIP/RIP。
由于已在上面 ShellCode 版远程线程注入中做了相关知识的铺垫,所以本节不会介绍的那么详细。
下面以 64 位程序为例,介绍 RIP 劫持注入的大致步骤:
- 获取目标进程的句柄。
- 在目标进程分配内存(记作 M1),并写入 DLL 路径。
- 在目标进程分配 8 字节的内存(记作 M2),用于存储返回的 DLL 句柄。
- 遍历目标进程的线程,调用 SuspendThread 函数挂起一个目标线程。
- 调用 GetThreadContext 函数获取挂起线程的 RIP,用于 ShellCode 执行完后跳转到该位置继续执行,不影响目标进程的原有逻辑。
- 构造 ShellCode,该 ShellCode 用于在目标进程中调用 LoadLibrary,并将结果存储到步骤 3 分配的 M2 内存,然后跳转之前的 RIP。
- 在目标进程分配内存(记作 M3),并写入步骤 6 构造的 ShellCode,M3 需要有 PAGE_EXECUTE_READWRITE 保护属性。
- 调用 SetThreadContext 函数修改 RIP 的值为 ShellCode 地址。
- 调用 ResumeThread 函数恢复线程。
- 等待短暂地时间后,从 M2 读取 DLL 句柄的值。
ShellCode 对应的汇编代码如下:
1 | sub rsp, 0x28 |
由于是直接修改 RIP 进行的执行流程跳转,因而会缺少正常函数调用时的 RIP 入栈、参数传递等过程,所以需要手动将 DllPathAddress 赋值给 rcx,并且在代码最后不能调用 ret。
对于 jmp 指令,不能使用jmp OriginalRip这样的方式来实现跳转到绝对地址,需要使用一个寄存器来做间接跳转。
上述汇编代码转成的字节码如下:
1 | BYTE shellCode[] = { |
调用 ResumeThread 函数恢复线程后,线程就会执行 ShellCode。由于没有创建新的线程,所以不能像远程线程注入那样等待线程结束后再读取 DLL 句柄,可以等待一个合适的时间后就尝试读取 DLL 句柄。
1 | Sleep(200); |
获取到 DLL 句柄后,可以再次注入 ShellCode 来调用 FreeLibrary 来释放 DLL。
基于文件映射方式的内存访问
在前面的远程线程注入和 EIP/RIP 注入章节,都使用了 VirtualAllocEx、WriteProcessMemory、ReadProcessMemory 等函数在远程进程中分配内存、读写内存,由于这些函数被广泛使用,已经作为高危函数被 AV/EDR 软件所监控。本节并不是介绍一种新的注入方法,而是介绍一种不使用上述函数,通过使用文件映射方式来读写远程内存的方法。
这种方式目前肯定无法 100% 躲避 AV/EDR,毕竟攻防之路永不止境。
使用文件映射访问目标进程内存及注入的大致步骤如下:
- 调用 CreateFileMapping 函数创建一个文件映射对象,并指定足够的大小,使其可以存储 DLL 路径。如果使用 EIP/RIP 劫持方式注入,还需要存储返回的 DLL 句柄、ShellCode。
- 调用 MapViewOfFile 函数将文件映射对象映射到当前进程地址空间,得到虚拟地址 A。
- 将 DLL 路径、ShellCode 等写入到虚拟地址 A。
- 调用 MapViewOfFile2 函数将文件映射对象映射到目标进程地址空间,得到虚拟地址 B。
- 根据虚拟地址 B 来计算 DLL 路径在目标进程中的真实地址,以及填充 ShellCode 中的地址占位符。
- 等待约 50ms。
- 调用 CreateRemoteThread 或者修改 RIP 来实现注入。
- 获取 DLL 句柄。如果使用 EIP/RIP 注入,则使用虚拟地址 A 读取 DLL 句柄,因为 CPU 会负责数据的同步;如果使用远程线程注入,则直接通过线程退出码来获取 DLL 句柄。
为什么第 6 步需要等待 50ms?因为在多核系统中,CPU 进行同步时可能会有一个短暂的延迟,等待 50ms,确保第 5 步修改的数据已经同步到目标进程。
另外,在 DLL 注入之后,最好不要调用 UnmapViewOfFile2 函数来取消对目标进程的映射,否则目标进程中的相关数据会被清除,可能导致其他错误。
由于 MapViewOfFile2 函数在 Windows 10 1703 版本才引入,所以这种方式支持的操作系统版本有限。
下面是使用文件映射方式结合 RIP 劫持实现的 DLL 注入的完整示例代码:
1 | HMODULE MapRipHijackInjectDll64(DWORD dwProcessID, LPCTSTR pszDllPath, DWORD& dwGLE) { |
用户模式 APC 注入
QueueUserAPC
每个线程都有一个 APC 队列,当线程进入可警告状态(Alertable)时,会按照 FIFO 的顺序执行 APC(异步过程调用)。
在用户模式和内核模式都可以实现 APC 注入,本节介绍如何在用户模式实现 APC 注入。用户模式实现 APC 注入主要使用 QueueUserAPC 函数向目标线程插入 APC。
1 | DWORD QueueUserAPC( |
与远程线程注入的思路类似,也是利用将 LoadLibrary 地址作为 pfnAPC 参数传入,DLL 路径作为 dwData 参数传入(也需要提前在目标进程分配内存和赋值),来实现在目标进程中的加载 DLL 。
APC 注入的弊端在于,当 APC 插入到队列之后,并不会立即执行,需要等待线程进入可警告状态时才会依次执行,那么线程何时进入可警告状态呢?
线程只有调用 SleepEx、SignalObjectAndWait、WaitForSingleObjectEx、WaitForMultipleObjectsEx 或 MsgWaitForMultipleObjectsEx 函数后才会进入可警告状态。
因此当插入 APC 后,我们还要祈祷线程赶快执行上述函数,否则 DLL 永远不会被注入。虽然为了提高注入的成功率,通常会在目标进程的所有线程中都执行 APC 插入操作,但这样成功率依然无法得到保证。
通过未公开的 NtTestAlert 函数虽然也可以使线程执行并清空当前 APC 队列,当该函数只能作用于当前进程中的当前线程,无法操作远程线程。
目前,还可以使用未公开的 NtQueueApcThreadEx 函数进行 APC 注入,使用该函数插入一个特殊的 APC,可以不用等待线程进入 Alertable 状态,但这种方式对系统的版本有限制。
Early Bird
Early Bird 翻译为中文叫“早起的鸟儿”。顾名思义,就是注入的时机很早。
Early Bird 本质上也是一种 APC 注入技术,其间接利用了 NtTestAlert 函数。因为线程在初始化时会主动调用 NtTestAlert 函数来清空和处理 APC 队列。所以我们可以创建一个挂起的进程(当然其主线程也会被挂起),并调用 QueueUserAPC 函数在该线程中插入一个 APC,然后恢复线程。当线程进入初始化流程后会自动调用 NtTestAlert 来清空并处理 APC 队列中的任务,这样我们插入的 APC 就得以执行。
下面代码通过将 LoadLibrary 地址插入 APC 的方式来展示了 Early Bird 的基本流程,我们也可以将其换成 ShellCode 的地址。
1 | PFN_NtTestAlert pfnNtTestAlert = (PFN_NtTestAlert)GetProcAddress(LoadLibraryW(L"ntdll.dll"), "NtTestAlert"); |
Inline Hook
Inline Hook(内联挂钩)是一种直接修改目标函数开头的指令,使其跳转到自定义的代理函数,从而实现 Hook 的技术。
基本步骤如下:
1 | 原始函数: |
从上面基本步骤可以看出,jmp MyHookFunction指令占用 5 字节,使用替换之前指令,会导致第 3 条指令仅部分被替换,这也是 Inline Hook 的技术挑战之一:不同指令长度不同,需要完整覆盖指令边界。
另外,jmp 指令使用相对跳转(目标地址 = 当前 EIP + 相对偏移),因此还需要计算当前指令相对 EIP 的偏移地址。
在执行完 MyHookFunction 函数后,如果需要继续执行原始函数怎么呢?可以在 MyHookFunction 中还原被覆盖的原有指令吗?类似下面的做法:
1 | // 假设在调用前还原,调用后重新hook |
这是错误的,这样做在多线程情况下,可能会出现竞态条件问题,线程 A 正在执行原始函数,而线程 B 可能会在进行重新 Hook,导致无法拦截。而且每次修改指令都需要更改内存保护属性,这种反复修改的代价也很高。
可以通过引入跳板机制来解决上述问题,以 Hook MessageBoxA 为例:
1 | ; 原始函数被修改后: |
在任何时候,需要调用原始 MessageBoxA 时,直接调用 MessageBoxA_Trampoline 即可。

上面的实现方法虽然不难,但仍然有很多需要考虑的情况,特别是要开发一个通用的 Inline Hook 库,此时需要考虑如何界定任意待 Hook 函数的指令边界,确保没有出现本节一开始的部分指令被替换的情况。
说到这里就不得不推出今天的主角 – minhook,该库是目前 Windows 上使用最广泛的 Inline Hook 解决方案,完美解决了上述问题,而且使用起来也非常简单,下面通过几行代码来演示该库的使用。
1 | typedef int(WINAPI * PFN_MessageBoxW)(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType); |
IAT Hook
IAT 的导入地址表(Import Address Table)的简写,该表记录了程序从哪些 DLL 导入了哪些函数。
Windows 应用程序都会依赖系统库或其他第三方库,虽然通过 MT(d)编译的程序会静态链接 CRT,但其他系统库仍然会出现在 IAT 中,因为这些系统库无法被静态链接到应用程序中。
IAT Hook 的原理:针对隐式调用的函数,编译器在生成代码时,会将 call MessageBox 指令指向 IAT 中的一个条目,我们 Hook 掉这个条目(将其中的地址改为自己函数地址),程序执行时就会跳转到我们自己的代码。
但是 IAT Hook 对显式调用(LoadLibrary -> GetProcAddress)调用没有效果,因为 GetProcAddress 函数是从 DLL 的导出表(EAT)中查找的函数原始内存地址,这个过程根本不查阅调用者自身的 IAT 表,所以 Hook 无法生效。
如果要对显示调用的函数进行 Hook,可以使用后面章节介绍的 EAT Hook。
既然是 IAT Hook,那么肯定先要找 IAT 表。根据下图描述的 PE 文件结构,我们可以解析出 IAT 数据。

在解析 IAT 数据之前,先要找到与导入表相关的数据目录项,大致步骤为:
- 首先根据 DOS 头中的 e_lfanew 字段定位到 NT 头。
- 然后找到 NT 头中的 Optional 头。
- 在 Optional 头的尾部存储了一个 DataDirectorys 数组(数组中每个元素都是 IMAGE_DATA_DIRECTORY 类型),该数组中索引为 1 的项中存储的是“导入表”的偏移和大小。
- 根据镜像基地址加上偏移就找到了“导入表数据目录项”了。
IMAGE_DATA_DIRECTORY 类型定义如下:
1 | typedef struct _IMAGE_DATA_DIRECTORY { |
其中,VirtualAddress 中存储的不是绝对地址,而是相对与镜像基址的偏移(RVA),在下面介绍的结构体中的元素所存储的地址也都是 RVA。
VirtualAddress 所指向的是 IMAGE_IMPORT_DESCRIPTOR 数组(导入表描述符),数组中每一个 IMAGE_IMPORT_DESCRIPTOR 元素代表一个依赖的 DLL。
IMAGE_IMPORT_DESCRIPTOR 定义如下:
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR { |
我们知道 DLL 的导出函数分为按名称导出和按序号导出,同样从 DLL 中导入函数也对应这两种情况。
IMAGE_IMPORT_DESCRIPTOR 结构中的 OriginalFirstThunk 字段所指向的数组用于存储导入函数的名称或序号,我们将该数组称为导入名称表(INT),数组中的元素为 IMAGE_THUNK_DATA 类型:
1 | typedef struct _IMAGE_THUNK_DATA32 { |
而 IMAGE_IMPORT_DESCRIPTOR 结构中的 FirstThunk 字段所数组用于存储函数导入后的真实内存地址,我们将该数组称为导入地址表(IAT),数组中的元素也是 IMAGE_THUNK_DATA 类型。
两个数组中的元素类型都是 IMAGE_THUNK_DATA 类型,但在 INT 表中主要使用 AddressOfData 和 Ordinal 字段,其中 AddressOfData 字段存储函数名称的 RAV,Ordinal 字段用于判断当前函数是按名称还是按序号导入(1 为函数名,否则为序号),而在 IAT 表中则主要使用 Function 字段,其中存储的是函数真实地址。
上面出现的 3 个数组(IMAGE_IMPORT_DESCRIPTOR 数组、INT 数组、IAT 数组)都没有提供专门的字段来获取数组元素的个数,那么在遍历数组时,如何判断最后一个元素呢?
通过指针递增的方式来依次遍历每个元素,但元素中的所有字段都为 0 时,表示该元素为数组中的最后一个元素。
说了这么多,读者可能有些混乱了,我画了一个图可能会对理解整个流程有所帮助。

在找到指定模块的指定函数之后,将u1.Function所指向的导入函数地址修改为我们自己函数的地址,就可以实现 Hook 了。当然别忘了保存原始的函数地址,以便后面进行 Unhook 或调用。
下面是 32 位程序使用 IAT Hook user32.dll::MessageBoxW 的完整示例代码(64 位程序将代码中的结构体修改为对应的的 64 位结构体即可)。
1 | PFN_MessageBoxW originalMessageBoxW = NULL; |
EAT Hook
EAT 是导入地址表(Export Address Table)的简写,该表记录了 DLL 导出了哪些函数。与上面介绍的 IAT Hook 不同,IAT Hook 修改的是程序自身的导入表,而 EAT Hook 修改的是 DLL 的导出表。
查找 EAT 的步骤与上面的查找 IAT 的步骤类似。先找到导出表数据目录项(位于 Optional->DataDirectorys 数组的第 0 项),然后定位并解析 IMAGE_EXPORT_DIRECTORY 数组。
IMAGE_EXPORT_DIRECTORY 定义如下:
1 | typedef struct _IMAGE_EXPORT_DIRECTORY |
IMAGE_EXPORT_DIRECTORY 中各个字段的作用及关系如下:
其中,NumberOfNames 字段记录了当前 DLL 按名称导出的函数的总数;
AddressOfNames 字段所指向的表中存储了导出函数的名称(按序号导出的函数不在该表),而 AddressOfFunctions 字段所指向的表中存储了每个函数的地址(包含按名称导出和按序号导出的函数)。因此 AddressOfNames 和 AddressOfFunctions 表不是一一对应的关系,AddressOfFunctions 表中的元素可能比 AddressOfNames 表多。它们之间通过 AddressOfNameOrdinals 表建立连接,AddressOfNames 与 AddressOfNameOrdinals 表是一一对应的关系,通过 AddressOfNames 表的索引可以在 AddressOfNameOrdinals 表中查询到函数在 AddressOfFunctions 表中的索引。
需要注意:
- AddressOfNameOrdinals 所指向的是 WORD 数组,而不是 DWORD 数组。
- IMAGE_EXPORT_DIRECTORY 中存储的 RAV 是相对于模块基地址的偏移,而且 AddressOfFunctions 中存储的也是 RAV(IAT 中存储的却是绝对地址)。
下面是 32 位程序使用 EAT Hook user32.dll::MessageBoxW 的完整示例代码(64 位程序将代码中的结构体修改为对应的的 64 位结构体即可)。
1 | PFN_MessageBoxW originalMessageBoxW = NULL; |
一些过时的内核 Hook 技术
Windows 自 Vista 版本以来,就在 x64 系统中提供了一种内置的安全功能,该功能称为 PatchGuard(PG),用于保护内核的关键区域免遭修改,如果触发该机制就会导致系统蓝屏(BSOD)。
PatchGuard 通过内核模式线程定时检测以下关键区域的完整性:
- 系统服务描述符表(SSDT):监控函数入口地址是否被篡改。
- 全局描述符表(GDT):校验中断处理程序的合法性。
- 中断描述符表(IDT):保护内存段描述符结构。
- 系统映像(ntoskrnl.exe、ndis.sys、hal.dll)。
- 处理器 MSR(系统调用)
- 内核模块列表:验证加载驱动模块是否有效的数字签名。
因此,在 x64 系统上,针对上面区域的 Hook 都已不再稳定有效。
VT Hook
TODO