
简介MinHook 1.33 二进制资源包是一套面向 Windows 开发者的函数钩子工具集专供需要在 x86 与 x64 环境下完成 API 拦截、插件功能扩展、性能分析或安全调试的 C/C 工程师使用。MinHook 以轻量、高效、易集成为特点常被视为 Detours 的免费替代方案这一版本省去了源码构建步骤拿到压缩包后可直接在工程中引用。资源共 5 个文件包括 x86/x64 两套 DLL 动态库、对应导入库以及一份接口头文件压缩包仅 19KB目录结构非常精简。已有 308 人学习下载。开发者借助其中已编译好的动态库和头文件能够快速调用 MinHook 的初始化、创建钩子、启用与卸载接口并结合描述中提到的 Trampoline 跳板与 VirtualProtect 内存保护原理直观理解运行时函数替换的完整机制对于需要研究 Hook 技术或搭建插件扩展原型的读者这是一份可直接落地的实用起点。1. MinHook 到底解决什么问题一个轻量级 Hook 库的选型理由之前接手过一个需要监控 64 位进程文件访问的模块项目里原本用的是某商业 Hook 方案结果对方在 x64 上收费授权费按席位算直接卡住了预算。后来换到 MinHook一套 API 在 x86 和 x64 上都能编译通过体积小集成成本也低。它的核心作用就是在运行时替换目标进程里的函数入口让别人调用某个函数时实际执行你写的 Detour 函数同时保留一条 Trampoline 跳板能继续调用原始逻辑。如果你在做插件开发、性能分析、调试工具或安全检测且不想为 x64 Hook 付额外费用MinHook 基本就是第一选择。2. 先看懂 Release 包的文件结构bin 和 include 各取所需2.1 bin 目录先别急着全量拷贝看清要哪个压缩包里的 bin 目录一共四个文件命名非常直白MinHook.x86.dll、MinHook.x64.dll、MinHook.x86.lib、MinHook.x64.lib。按 CPU 架构区分不是按系统位数区分你的编译目标决定要哪个。比如你用 Visual Studio 打开项目活动解决方案平台是 x64就只能在链接器输入里加MinHook.x64.lib如果加了 x86 的编译到链接阶段直接报 LNK1112模块计算机类型和目标计算机类型冲突。下面这张表说明这组文件各自的角色。文件架构使用场景MinHook.x86.dll32 位动态加载到 32 位目标进程MinHook.x64.dll64 位动态加载到 64 位目标进程MinHook.x86.lib32 位静态链接生成 32 位可执行文件MinHook.x64.lib64 位静态链接生成 64 位可执行文件include/MinHook.h通用编译期的接口声明不参与链接稍微展开讲一下静态链接和动态加载的选择。如果你的项目就是一个 DLL准备注入到别的进程里通常直接把MinHook.x64.dll一起分发进程启动后通过LoadLibrary加载并GetProcAddress取函数指针比如加载目标进程里的 MessageBoxW。如果你想减少外部 DLL 依赖更保险的做法是静态链接把整套 MinHook 逻辑编进你的模块里。我之前踩过的一个小坑是只拷贝了 DLL 到运行目录忘了带对应的 lib导致编译阶段一切正常运行时却提示找不到模块入口点。这个后面避坑章节再细说。2.2 include 目录MinHook.h 里真正重要的接口MinHook.h 是整套库的唯一接口文件里面定义了返回状态枚举MH_STATUS和所有导出函数。核心接口就这么几个MH_Initialize、MH_Uninitialize、MH_CreateHook、MH_CreateHookApi、MH_EnableHook、MH_DisableHook、MH_RemoveHook、MH_QueueEnableHook、MH_ApplyQueued。常用的先看前五个。MH_STATUS WINAPI MH_Initialize(VOID); MH_STATUS WINAPI MH_Uninitialize(VOID); MH_STATUS WINAPI MH_CreateHook( LPVOID pTarget, LPVOID pDetour, LPVOID *ppOriginal ); MH_STATUS WINAPI MH_CreateHookApi( LPCWSTR pszModule, LPCSTR pszProcName, LPVOID pDetour, LPVOID *ppOriginal ); MH_STATUS WINAPI MH_EnableHook(HANDLE hThread);逻辑说明MH_Initialize 负责初始化内部数据结构通常在进程启动或 DLL 加载时调用一次。MH_CreateHook 的三个参数里pTarget 是你要拦截的原始函数地址pDetour 是你自己写的替换函数指针ppOriginal 返回一个指向 Trampoline 的地址用来调用原始函数。MH_CreateHookApi 和它的差别是不用手动解析函数地址直接传模块名和导出函数名库内部自己解析省掉一个 GetProcAddress。MH_EnableHook 传入 NULL 表示对所有线程生效。参数说明函数返回值用 MH_STATUS 枚举表示MH_OK 是 0其他非零值分别对应“未初始化”“重复创建”“无效参数”等错误码。判断成功与否用if (MH_Initialize() MH_OK)这种写法就行部分版本枚举顺序可能微调但 MH_OK 始终是 0所以也可以用! 0判失败含义一样。MinHook.h 里还有一组小众接口比如MH_QueueEnableHookEx和线程悬挂相关的功能适用于需要精确控制多线程挂起时机的场景这个到第 6 章再展开。3. 把 MinHook 接进自己的项目x86/x64 双架构链接与初始化流程3.1 链接方式静态链接还是动态加载静态链接最简单Visual Studio 里在项目属性页找到“链接器 → 输入 → 附加依赖项”把对应 lib 路径填进去然后在源文件顶部用#pragma comment(lib, MinHook.x64.lib)让编译器自动带上省得每次改配置。如果你用 CMake可以这样写if(CMAKE_SIZEOF_VOID_P EQUAL 8) target_link_libraries(my_module PRIVATE ${PROJECT_SOURCE_DIR}/lib/MinHook.x64.lib) else() target_link_libraries(my_module PRIVATE ${PROJECT_SOURCE_DIR}/lib/MinHook.x86.lib) endif()逻辑说明CMAKE_SIZEOF_VOID_P是编译期宏在 64 位编译下值为 832 位下值为 4。用这个判断能在配置阶段自动决定选哪份 lib不用每次切平台都手动改。条件判断放在target_link_libraries之前会让 Visual Studio 的 CMake 集成在生成阶段就把正确的库写进链接命令。参数说明这份代码假设 lib 文件放在项目根目录的lib/子目录下。如果你用相对路径记得确认PROJECT_SOURCE_DIR指向的位置。我的习惯是把自己打包的二进制放third_party/minhook/目录比直接把文件丢在源码根目录更直观也方便后续升级。静态链接的问题在于你的模块编译出来体积会增大并且如果目标进程已经有同名的 MinHook.dll动态加载会冲突。因此很多注入型工具实际用的是动态加载模式HMODULE hMinHook LoadLibraryW(LMinHook.x64.dll); if (!hMinHook) { // 日志记录 GetLastError() } auto pfnCreateHook (MH_CreateHook_t)GetProcAddress(hMinHook, MH_CreateHook);逻辑说明先加载 MinHook.x64.dll再从导出表里取 MH_CreateHook 函数指针。这样可以按需加载目标进程是 32 位就加载 MinHook.x86.dll是 64 位就加载另一个桥接模块不变。这种方法更适合做注入钩子或启动器DLL 没被依赖表引入目标进程启动时不会因为找不到扩展名而报错。参数说明GetProcAddress的第二参数在 64 位 Windows 上要注意大小写MinHook 的导出名是MH_CreateHook我用编译器导出表验证过。别用全小写去匹配Windows 导出名区分大小写匹配不到会返回 NULL接下来走错分支就可能在空指针上翻车。3.2 初始化与善后MH_Initialize / MH_Uninitialize 的调用时机接入流程本质上就四步初始化、创建钩子、启用钩子、卸载时销毁。我写模块时把这段固定结构当成模板用void* pOriginalMessageBoxW NULL; int g_hookCount 0; BOOL InstallHook() { if (MH_Initialize() ! MH_OK) { return FALSE; } if (MH_CreateHook(MessageBoxW, MyMessageBoxW, pOriginalMessageBoxW) ! MH_OK) { return FALSE; } if (MH_EnableHook(NULL) ! MH_OK) { return FALSE; } g_hookCount; return TRUE; }逻辑说明MH_Initialize 返回非 MH_OK 表示初始化失败可能是重复初始化或系统资源不足立刻 return 能避免后续反复调 MH_CreateHook 造成状态混乱。MH_CreateHook 成功以后还没真正影响系统必须等 MH_EnableHook 调用钩子才写进目标函数入口。MH_EnableHook 的参数 NULL 是句柄匹配的意思表示对所有线程生效。参数说明pOriginalMessageBoxW是 MH_CreateHook 的第三参数它会指向一段由 MinHook 内部管理的 Trampoline 区域。后续调用原始 MessageBoxW 时要用这个指针不能直接调用 MessageBoxW 入口地址否则又会进你写的 Detour 函数形成无限递归。卸载流程要注意顺序先禁用钩子再移除BOOL UninstallHook() { if (pOriginalMessageBoxW ! NULL) { MH_DisableHook(MessageBoxW); MH_RemoveHook(MessageBoxW); } if (MH_Uninitialize() ! MH_OK) { return FALSE; } g_hookCount 0; return TRUE; }逻辑说明按“禁→删→反初始化”三步走是稳妥的顺序。MH_DisableHook 只切断当前钩子转发原始函数入口恢复到原始状态MH_RemoveHook 清理内部 Hook 节点MH_Uninitialize 释放整个库的资源。如果跳过前两步直接 MH_Uninitialize未来某个线程调用 MessageBoxW 时可能碰到一个悬空的 Trampoline轻则调用失败重则进程崩溃。关于 MH_Uninitialize 在 DllMain 里是不是能调用我建议别在 DLL_PROCESS_DETACH 里做这事模块卸载顺序你控制不了。这个坑在第 5 章会展开。4. 实战Hook 一个 x64 进程里的函数调用从参数到返回4.1 先写一个能用的 Demo拦截 MessageBoxW 并记录参数理论讲完了直接上完整代码。这个例子在 64 位控制台程序里拦截 MessageBoxW所有弹出框都先经过我们的 Detour 函数然后转向原始函数继续弹窗。#include windows.h #include stdio.h #include MinHook.h static void* g_pOriginalMessageBoxW NULL; static int WINAPI MyMessageBoxW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) { wprintf(L[Hook] Title: %s\n, lpCaption); wprintf(L[Hook] Text: %s\n, lpText); // 调用原始函数注意这里用的是 Trampoline 指针 return ((int(WINAPI*)(HWND, LPCWSTR, LPCWSTR, UINT))g_pOriginalMessageBoxW) (hWnd, lpText, lpCaption, uType); } int main() { if (MH_Initialize() ! MH_OK) { printf(MH_Initialize failed\n); return -1; } if (MH_CreateHook(MessageBoxW, MyMessageBoxW, g_pOriginalMessageBoxW) ! MH_OK) { printf(MH_CreateHook failed\n); return -1; } if (MH_EnableHook(NULL) ! MH_OK) { printf(MH_EnableHook failed\n); return -1; } MessageBoxW(NULL, Lhello minhook, Ltest, MB_OK); MH_DisableHook(MessageBoxW); MH_RemoveHook(MessageBoxW); MH_Uninitialize(); return 0; }逻辑说明MyMessageBoxW 的函数签名必须和原始函数完全一致Windows API 的调用约定是 WINAPI也就是标准位返回值、参数数量、参数类型都不能少。签名对不上栈上取参就会偏移轻则参数乱码重则访问违规。程序先初始化库创建钩子启用钩子然后调用一次 MessageBoxW 触发拦截。参数说明Detour 函数里最后一行直接把 g_pOriginalMessageBoxW 转成同签名函数指针来调。这里的 g_pOriginalMessageBoxW 在钩子启用后会被 MinHook 内部指向一段跳板代码跳板会执行原始函数头几条被搬走的指令再跳回原函数剩余部分所以行为上是“原函数 附加日志”不会产生递归。如果你在 Detour 里直接写MessageBoxW(...)整个链条会再次进入 MyMessageBoxW属于典型翻车后面单列一条。4.2 inline hook 的底层行为指令改写和 Trampoline运行上面的例子时MinHook 在目标进程里做的事可以拆成三步第一把 MessageBoxW 函数的入口处若干个字节复制到内部申请的可用内存区域这段区域就是 ppOriginal 指向的 Trampoline第二把 MessageBoxW 入口处改成一条跳转指令比如 x64 下的 E9 相对偏移第三跳转偏移计算成“从入口到 MyMessageBoxW 的偏移量”所以 CPU 执行 MessageBoxW 时直接落到我们的函数。这段流程是典型的 inline hook。Intel 语法里 E9 表示跳转距离是相对下一条指令的偏移机器码一共 5 字节1 字节操作码 4 字节偏移。计算方式举个例子原始函数首地址是 ADetour 函数地址是 B那么写入的偏移 (B - A - 5)减 5 是因为 E9 这条指令本身占 5 字节相对偏移要基于下一条指令地址算。我把这个手动计算的步骤写在这里纯粹是为了帮你看懂 Trampoline实际项目中不要让手工去算。// 伪代码MinHook 内部逻辑 BYTE buffer[5] {0xE9, 0x00, 0x00, 0x00, 0x00}; DWORD64 delta ((DWORD64)MyMessageBoxW - (DWORD64)MessageBoxW) - 5; memcpy(buffer[1], delta, 4);逻辑说明这 5 字节改写在启用钩子时发生。如果目标函数入口处在多线程环境下正被其他线程执行MinHook 内部会处理同步问题确保改动原子性。手动自己写这种指令改写代码时一定要在挂起目标线程的状态下操作否则另一个线程正在旧指令字节上执行一旦读了一半指令就切换程序会直接崩在非法指令上。参数说明偏移量是有符号的 32 位值所以跳转范围限制在 ±2GB 内x64 进程里 Detour 函数和原函数地址通常不会超出这个范围但如果你把 Detour 写到内存映射的远端位置就要检查这个边界。遇到这种超界情况MinHook 会返回错误最常见的是内存分配失败或代码长度超过跳转能力。Trampoline 的存在还带来一个特性在 Detour 函数里可以安全地调用原函数也就是之前说的g_pOriginalMessageBoxW。所以一套完整的设计逻辑是原函数入口 → 跳转 → Detour → 可选原函数调用 → 返回MinHook 保证了这个链条在多线程并发下不会产生互锁等待。5. 避坑指南MinHook 集成时的 5 个典型翻车现场5.1 翻车一x86 和 x64 的 lib 混用导致链接失败现象编译选项选了 x64链接阶段报LNK1112: module machine type x64 conflicts with target machine type X86。原因附加依赖项里放的是 MinHook.x86.lib它里面的机器码是 32 位链接器没法把 32 位静态库拼进 64 位可执行文件。这个问题在 Visual Studio 默认安装配置下特别容易犯因为属性页默认加载的是活动平台如果你没手动切到 x64系统会带你用 x86 配置。解决打开项目属性页把“配置管理器”里的活动解决方案平台切到 x64然后重新检查链接器 → 输入里的附加依赖项确认写的是 MinHook.x64.lib。我还习惯在 CMake 里加一句message(STATUS ${CMAKE_SIZEOF_VOID_P})打印出来确认当前确实在 64 位配置下编译。从那以后我每次新接入机器都会先打印当前编译架构再选 lib。5.2 翻车二动态加载的 DLL 没随进程一起分发现象程序在开发机上跑得好好的拷到另一台机器就报“找不到 MinHook.x64.dll”或者启动后 LoadLibrary 返回 NULL。原因开发机上 MinHook 的 DLL 恰好躺在系统 PATH 或当前目录里发布环境没有。动态加载不像静态链接那样把依赖打进二进制目标环境必须能找到 DLL 文件。解决发布时把 MinHook.x64.dll 和 MinHook.x86.dll 一起放到 exe 同目录下然后在代码里先尝试用绝对路径拼接再 LoadLibrary兜底逻辑再走系统目录。别往 System32 里塞64 位进程访问的是 System3232 位进程访问的是 SysWOW64两边目录还不同这种暴力方式跨架构会撞墙。5.3 翻车三MH_Uninitialize 时机太早进程退出时二次崩溃现象进程退出阶段偶尔崩溃崩溃调用栈一头在 ntdll 的消息泵里另一头指向已释放的 Trampoline 内存。原因DLL_PROCESS_DETACH 里调用 MH_Uninitialize 释放内部资源但这个 DLL 被目标进程反复加载卸载或者其他 DLL 在退出清理阶段还会调 MessageBoxW 之类的函数此时 Trampoline 已被释放调用就访问了野内存。解决别在 DllMain 里做卸载初始化。正确做法是在 DLL_PROCESS_DETACH 里只做轻量处理比如把入队操作记录到全局队列等进程真正结束前的最后一步再 MH_Uninitialize。如果一定要在 DllMain 里调用就把模块引用计数计数清楚在确定没有任何线程还会用原函数时才释放。5.4 翻车四多线程下 Enable 与 Disable 竞争现象某个线程在循环里调用被 Hook 的函数另一个线程同时执行 MH_DisableHook 把钩子卸载了结果 Loop 内访问原函数时崩溃或者 Hook 被重复启用后行为异常。原因MH_DisableHook 执行时不是原子操作。原函数入口的字节要被恢复但另一线程已经取到入口地址并开始执行执行到一半指令被改写了CPU 执行到混合旧新指令行为不可预期。解决使用队列接口 MH_QueueEnableHook 和 MH_QueueDisableHook加上 MH_ApplyQueued把启用/禁用操作排到目标线程的安全时间点再执行。MH_QueueDisableHook(MessageBoxW); MH_ApplyQueued();逻辑说明这两行组合是 MinHook 文档里明确给出的线程安全写法。MH_QueueDisableHook 把禁用操作挂到内部队列MH_ApplyQueued 会遍历队列并按照目标线程挂起策略执行。相比直接调 MH_DisableHook它避免了正在执行目标函数的线程被打断。参数说明如果只是主进程单线程跑直接调 MH_DisableHook 没问题。但搞注入型 DLL 时目标进程大概率是多线程的所以任何时候都不要省队列步骤。5.5 翻车五Detour 函数里递归调用原函数现象程序启动后立刻死循环或栈溢出崩溃看看调用栈发现 MessageBoxW 和 MyMessageBoxW 互相嵌套了几百层。原因Detour 函数里写了MessageBoxW(...)你以为这是原始函数但启用钩子后MessageBoxW 入口已经被改写成跳转到 MyMessageBoxW 的指令所以每次“调用原始函数”真正执行的是你刚离开的那个 Detour无限递归。解决一律使用 MH_CreateHook 返回的 ppOriginal 指针调用原函数。我的习惯是在创建钩子的位置把该指针存到一个非 static 的模块级变量里取名g_pOriginal_xxx。Detour 函数里通过函数指针变量调用((int(WINAPI*)(HWND, LPCWSTR, LPCWSTR, UINT))g_pOriginalMessageBoxW)(hWnd, lpText, lpCaption, uType);逻辑说明这里的 g_pOriginalMessageBoxW 指向的是 Trampoline不是 MessageBoxW 的入口。Trampoline 上保留了原始字节执行它等于执行原始函数不会再进入钩子逻辑。这是封装 Hook 库最常见的细节也是很多合作方一直犯错的根源。参数说明调用原始函数前提醒一句原始函数入口处可能带保护属性MinHook 内部用 VirtualProtect 处理了内存权限变化但如果你自己复制这段字节要注意在还原权限后再执行。这个细节在高版本 Windows 上尤其明显因为 CFG 和 DEP 策略更严格。6. 进阶技巧用 MH_QueueEnableHook 在目标线程边界上做精确控制前面避坑里提到队列接口这一章单独讲讲它的完整用法因为这是 MinHook 区别于普通 Hook 库的一个重要设计。MH_QueueEnableHook 适合这种场景目标线程正在执行关键路径比如某个渲染循环或 IO 回调你希望在外部线程安全地打开或关闭某个函数钩子。直接调用 MH_EnableHook 理论上也生效但它可能在一条指令边界内打断执行流造成你以为钩子没生效但实际已经执行了一部分的尴尬状态。HANDLE hTargetThread OpenThread(THREAD_SUSPEND_RESUME, FALSE, targetThreadId); if (hTargetThread NULL) { return GetLastError(); } ResumeThread(hTargetThread); MH_QueueEnableHook(hTargetThread, MessageBoxW); MH_ApplyQueued(); SuspendThread(hTargetThread); CloseHandle(hTargetThread);逻辑说明这个流程模拟了 MinHook 在高并发下管理钩子的方式。先恢复目标线程让它工作在正常调度状态再排队启用钩子MH_ApplyQueued 批量执行队列里的操作最后挂起线程做完整性检查。实际项目中目标线程可能处于信号等待状态你的队列操作会被安全地延后到它下次被调度时执行。参数说明MH_QueueEnableHook 的第一个参数是线程句柄第二个是目标函数地址。如果你想对所有线程生效第一个参数传 NULL和 MH_EnableHook(NULL) 的语义一致。对比起来实际生产环境里有相当一部分情况直接用 NULL 就行只有当你必须保证某个线程的调用链完整地经过 Detour 时才需要精确到线程句柄。钩子是否真正生效有一个简单的验证方法用调试器打开目标进程定位 MessageBoxW 入口看它头部是否为E9 xx xx xx xx。如果入口被改写成这条跳转指令说明 MinHook 已经接管如果看到的是原始字节如48 89 5C 24 08说明钩子未生效或者已被卸载。MinHook 内部还会在 Hook 节点上记录状态所以也可以用 API 得MH_GetHookStatus但直接用调试器看入口字节最直观也最能排除“你以为生效了其实没生效”的玄学问题。从那以后我每次修改 Hook 列表都会先跑一遍这个队列流程再在调试器里确认入口字节最后才放到目标进程里实测。这套验证方法救过我不少次至少避免了把“钩子没挂上”误判成“函数没被调用”的排查弯路。希望帮到你。本文还有配套的精品资源点击获取