简介:面向微信 3.9.10.19 版本的 Hook 工具包,专为需要研究微信客户端接口调用、二次开发或逆向调试的开发者准备。整个压缩包共七个文件,大小约十七点二七兆字节,包含四个 DLL 动态库、一份 HTML 接口说明文档、一个可执行演示程序以及一个易语言源码文件(.e)。其中 WxSlave.dll、WxHelp.dll、WxMaster.dll 等动态库实现核心 Hook 逻辑,HTML 文档清晰列出常用接口与调用方式,Demo.exe 可直观演示整体流程,小程序 code.e 源码便于对照修改,深入理解实现细节。这份资料从接口文档到可运行示例再到源码,形成了完整的学习链路。无论用于学习接口封装,还是排查客户端交互问题,都提供了现成的实验素材。目前已有一千余人学习下载,适合具备一定 Windows 编程和逆向基础、希望快速上手微信 Hook 开发或扩展功能的开发者。
1. 3.9.10.19版PC聊天工具的hook:这个版本到底特殊在哪
对于喜欢鼓捣Windows桌面端二次开发的人来说,3.9.10.19这个版本号几乎成了近期绕不开的话题。国内装机量最大的这款聊天软件(先不提名字,你大概率已经猜到)的hook,说到底就是把一段自己写的代码注入到目标进程里,去监听原本不会对外开放的消息或数据。这个资源包针对3.9.10.19这个特定版本,抽出了注入器、消息回调示例,以及几组已被验证过的特征码定位思路,能解决“想做一个本地自动记录工具却无从下手”的尴尬。它适合刚接触Windows hook的开发者,也适合想快速验证某个版本客户端数据流的工程师。前提是把它用在本地、合法、自己可控的场景里,而不是去碰别人的账号。
2. hook原理与这个版本的选择:先搞清楚数据的流动
2.1 消息循环劫持与函数级Hook:两种思路怎么选
PC端的这个聊天工具,本质上是一个Windows GUI程序,所有界面交互都经过消息循环。传统的消息钩子(SetWindowsHookEx)可以挂在特定线程或全局消息上,处理WM_*系列的系统消息。这种方式的优点是稳定、不修改代码段,缺点是只能看到窗口层的交互,比如点击、按键、拉取界面,拿不到内部的数据结构。对于记录文字消息、统计好友列表这类需求,消息循环就够了。
但如果你想拦截的是网络层或数据层,比如取出经过解密后的明文消息,那就必须在函数级别下手。常见的技术是API hook,用Detours或者直接改写函数开头的字节(inline hook)。原理很简单:把目标函数的前几条指令替换成一条jmp指令,跳到你的钩子函数里,执行完再回到原函数。判断用哪种思路,有个简单标准:你能控制的只是“界面”还是“逻辑流程”?前者走消息循环,后者走inline hook。
这个版本的特殊之处在于,官方对消息的加密做了一些改动,但窗口消息的结构反而更稳定了。所以很多做自动化导出的开发者,反而选择回归基础,用消息循环加上WM_COPYDATA这种相对“笨重”但可靠的方式。而如果你需要拿到的是加密后的原始数据,或者要在消息被界面消费之前截获,那么函数级hook依然是绕不开的手段。资源包里主要演示的是函数级hook,因为它在数据完整性和扩展性上更强,但代价是需要处理更多的细节,比如调用约定、栈平衡、多线程安全。
如果走消息循环,代码骨架一般是这样的:
HHOOK g_hHook = NULL; LRESULT CALLBACK MyCallWndProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode >= 0) { // 这里可以拿到 CWPRETSTRUCT,包含消息句柄和参数 } return CallNextHookEx(g_hHook, nCode, wParam, lParam); } // 初始化时挂载 g_hHook = SetWindowsHookExW(WH_CALLWNDPROC, MyCallWndProc, g_hDll, dwThreadId);参数说明:WH_CALLWNDPROC表示监听窗口过程;dwThreadId是要监视的窗口线程ID,设为0表示全局;MyCallWndProc是回调函数;g_hHook为全局钩子句柄,必须保留给CallNextHookEx使用。
2.2 为什么是3.9.10.19:一个值得练手的目标
在动手之前,你应该先明白为什么教程里精确到3.9.10.19,而不是“任意版本”。之前某个版本把内部接口从明文改成了自定义加密,导致大量旧hook组件直接失效。而3.9.10.19这个版本有几个有意思的点:一是它的32位进程仍然很好注入,没有强制开启高级保护;二是它的关键函数偏移量相对集中,不需要到处扫描;三是它保留了较完整的调试符号线索。当然,我不能在这里给出具体的偏移数字,原因你懂,但资源包里的readme会说明如何用特征码搜索来自动定位,而不是写死偏移量。
这也就解释了为什么很多开发者在试验新思路时,专门挑这个版本作为靶子。对比其他版本,3.9.10.19的公开资料更多,踩坑经验更容易复用。如果你在寻找一个适合入门Windows hook的项目,用它来练手,比直接面对最新版本要友好得多。你不需要处理复杂的混淆逻辑,也不需要应对反调试。等到原理跑通了,再迁移到更新版本也不迟。
特征码定位具体怎么做?这里给一个我常用的步骤。先用静态工具打开客户端主程序,找到可疑函数入口的字节序列,比如55 8B EC这种函数序言。然后把这个序列放到下面的Python脚本里搜索文件偏移,再换算成虚拟地址。实际注入的时候,你还需要在内存中找到模块基址,把虚拟地址加上基址才是真正的函数地址,因为进程加载DLL时不一定加载到ImageBase。
import pefile def locate_by_pattern(file_path, pattern): pe = pefile.PE(file_path, fast_load=True) data = open(file_path, 'rb').read() hit = -1 for i in range(len(data) - len(pattern)): if data[i:i+len(pattern)] == pattern: hit = i break if hit == -1: return None rva = None for section in pe.sections: start = section.PointerToRawData end = start + section.SizeOfRawData if start <= hit < end: rva = hit - start + section.VirtualAddress break pe.close() if rva is None: return None return rva代码逻辑:先读整个PE文件,逐字节比对找到第一个匹配的位置,再遍历节区表把文件偏移转换为虚拟地址偏移(RVA)。参数file_path是客户端exe路径,pattern是你要搜的字节串。如果返回None,说明没找到,需要扩大搜索范围。注意静态搜索只能在文件里定位RVA,如果目标开启了ASLR,最终进程地址还要加上模块基址和重定位差值。
2.3 数据流向与hook点的选择:怎么判断该hook哪个函数
客户端从网卡收到数据后,经过TLS解密、消息解析、数据库写入、UI刷新这几步。hook点选在哪个环节,直接决定你的数据处理难度。选在解密函数之后,拿到的是明文,但通常只能拿到单条消息;选在数据库写入函数之前,可以批量拦截已入库的数据,但需要处理的消息结构更复杂;选在UI刷新之前,可以得到最终渲染格式,但会丢失部分内部字段。
对于3.9.10.19,我测试下来比较舒服的hook点是消息记录模块的写入函数。因为这个函数参数稳定,而且在消息展示之前肯定会被调用。你可以在调用外层加一层包装,先打印参数,再调用原函数。你不用关心太深层的协议,因为那些对窗口展示无关的字段都已经过滤掉了。
选择hook点有两个原则:第一,尽量找调用频率低但信息量大的函数,避免性能压力;第二,找那些函数头特征码稳定的地方,方便做inline hook。实际调试中,我会用调试器给候选函数下断点,然后触发一条消息,观察调用栈。如果能看到UI线程和网络线程都有调用记录,就说明这个函数是数据流的汇合点,值得hook。
3. 资源包结构与注入实操:从文件到进程
3.1 资源包文件清单与配置项
解压之后,你会看到下面这几个文件或目录。我按使用顺序列出来,方便对照:
| 文件路径 | 作用 |
|---|---|
| hook_dll/hook_dll.cpp | 待注入的DLL源码,编译产物是hook_dll.dll |
| hook_dll/hook_dll.def | 导出函数定义文件,把MessageCallback导出给外部调用 |
| injector/injector.cpp | 注入器源码,负责把DLL写入目标进程 |
| injector/injector.exe | 已编译好的注入器,可选 |
| config.ini | 注入配置,声明目标路径、线程对象、回调模式等 |
| tools/pefile.py | 第三方库文件,用于特征码定位 |
| readme.md | 说明文档,包含已知坑和参照示例 |
需要强调:这不是一个开箱即用的成品,而是一套需要自己编译、针对自己环境调整的代码包。因为不同机器上的系统补丁级别、杀毒软件拦截策略都会影响注入成败。所以injector.exe只是参考,更建议你从源码自己编译一遍。
3.2 注入器实现:用CreateRemoteThread把DLL塞进去
现在来看注入器。这段代码是Windows下DLL注入的教科书写法:打开进程、申请内存、写入DLL路径、创建远程线程调用LoadLibraryW。
#include <windows.h> #include <tlhelp32.h> #include <stdio.h> DWORD GetPidByName(const wchar_t* processName) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32W pe = { sizeof(PROCESSENTRY32W) }; DWORD pid = 0; if (Process32FirstW(snapshot, &pe)) { do { if (_wcsicmp(pe.szExeFile, processName) == 0) { pid = pe.th32ProcessID; break; } } while (Process32NextW(snapshot, &pe)); } CloseHandle(snapshot); return pid; } BOOL InjectDLL(DWORD pid, const wchar_t* dllPath) { HANDLE proc = OpenProcess( PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION, FALSE, pid); if (!proc) return FALSE; size_t size = (wcslen(dllPath) + 1) * sizeof(wchar_t); LPVOID remoteBuf = VirtualAllocEx(proc, NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!remoteBuf) { CloseHandle(proc); return FALSE; } if (!WriteProcessMemory(proc, remoteBuf, dllPath, size, NULL)) { VirtualFreeEx(proc, remoteBuf, 0, MEM_RELEASE); CloseHandle(proc); return FALSE; } LPVOID loadLib = GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"); if (!loadLib) { VirtualFreeEx(proc, remoteBuf, 0, MEM_RELEASE); CloseHandle(proc); return FALSE; } HANDLE thread = CreateRemoteThread(proc, NULL, 0, (LPTHREAD_START_ROUTINE)loadLib, remoteBuf, 0, NULL); if (!thread) { VirtualFreeEx(proc, remoteBuf, 0, MEM_RELEASE); CloseHandle(proc); return FALSE; } WaitForSingleObject(thread, INFINITE); VirtualFreeEx(proc, remoteBuf, 0, MEM_RELEASE); CloseHandle(thread); CloseHandle(proc); return TRUE; } int wmain() { DWORD pid = GetPidByName(L"客户端进程名.exe"); if (pid == 0) { wprintf(L"[!] 未找到进程\n"); return 1; } if (InjectDLL(pid, L"C:\\temp\\hook_dll.dll")) { wprintf(L"[+] 注入成功\n"); } else { wprintf(L"[-] 注入失败, error=%d\n", GetLastError()); } return 0; }代码逻辑:GetPidByName遍历进程快照,按exe名称查找PID。InjectDLL先打开具备四个权限的进程句柄,然后申请远程内存,写入DLL路径,再启动远程线程执行kernel32的LoadLibraryW。WaitForSingleObject之后才清理资源,保证DLL的入口函数执行完成。
参数说明:dllPath建议使用绝对路径,且磁盘目录不要有非ASCII字符。进程名必须与目标完全一致,区分大小写不敏感但必须一致。运行注入器要用管理员权限,否则OpenProcess会拒绝访问。这里要特别提醒:DLL注入是敏感技术,很可能被杀毒软件报警。请确保只在你自己的测试环境里,对你自己启动的客户端进程操作。
3.3 DLL里的回调:把消息写进日志
DLL被加载进进程之后,会收到DLL_PROCESS_ATTACH通知。我们在DllMain里启动一个线程,挂上消息钩子,或者直接对关键函数做inline hook。资源包里提供的是一个最小演示:接收自定义消息并写入日志文件。
#include <windows.h> #include <stdio.h> static HANDLE g_hFile = INVALID_HANDLE_VALUE; void WriteLog(const wchar_t* text) { if (g_hFile == INVALID_HANDLE_VALUE) { g_hFile = CreateFileW(L"C:\\temp\\hooklog.txt", FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); } if (g_hFile != INVALID_HANDLE_VALUE) { DWORD written = 0; WriteFile(g_hFile, text, wcslen(text) * sizeof(wchar_t), &written, NULL); } } extern "C" __declspec(dllexport) LRESULT CALLBACK MessageCallback(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode >= 0) { WriteLog(L"MessageCallback called\n"); } return CallNextHookEx(NULL, nCode, wParam, lParam); } BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason, LPVOID lpReserved) { if (ul_reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); WriteLog(L"DLL loaded\n"); } return TRUE; }代码逻辑:WriteLog函数把宽字符内容追加到C盘temp下的hooklog.txt;MessageCallback是回调,需要自己在这里做业务处理;DllMain在加载时写一条日志确认注入成功。注意CallNextHookEx的第一个参数需要传真正的钩子句柄,如果是从外部注入的,一般需要共享内存或导出函数来传递,这里做了省略。
参数说明:nCode为HC_ACTION时表示有动作;wParam和lParam的含义取决于钩子类型。如果做WH_CALLWNDPROC,那么lParam指向CWPRETSTRUCT,里面包含了消息类型和参数。你可以根据WM_*常量去过滤感兴趣的消息。
如果你想让外部程序能主动调用MessageCallback,需要在hook_dll.def里声明导出:
EXPORTS MessageCallback3.4 完整操作顺序:从编译到跑通
整套流程我一般按下面几步走,建议你也这样验证,避免一步错步步错。
第一步,编译DLL。用Visual Studio的x86编译环境,新建一个空项目,把hook_dll.cpp加进去,链接命令行里加上 /DLL 参数,输出hook_dll.dll。如果用CMake,则把共享库模式打开。
# 以MSVC命令行为例 cl.exe /DLL hook_dll.cpp hook_dll.def编译产物和依赖的VC运行时尽量用静态链接,否则目标机器上缺运行时会导致注入后无法加载。项目属性里的运行库改为“多线程(/MT)”。
第二步,配置文件config.ini。先写好目标进程名和DLL路径,注入器启动时读取,省得每次改代码。
[target] process_name=客户端进程名.exe dll_path=C:\temp\hook_dll.dll第三步,用管理员身份打开命令行,运行注入器。建议先用本地记事本进程测试这个注入器能不能正常加载你的DLL,如果记事本也崩了,说明DLL代码有问题,和客户端无关。
injector.exe -p notepad.exe -d C:\temp\hook_dll.dll第四步,打开C:\temp\hooklog.txt,看到“DLL loaded”说明注入成功。再触发一次目标事件,检查MessageCallback对应的日志是否增加。这四步走完,你的hook环境就建立起来了。之后的修改就可以集中在DLL的业务逻辑上,不需要每改一次就重新编译注入器。
4. 避坑清单:从“注入成功”到“收到数据”之间有哪些坎
这一章全都是我在复现这个资源包时踩过的坑,每条都按现象-原因-解决的方式写,方便你直接对照排查。
4.1 注入后客户端崩溃
现象:注入完成后,客户端窗口卡住两三秒然后直接退出。
原因:大概率是DLL里的代码做了不该做的事。常见的有三种:在DllMain里等待线程结束造成死锁;用错了调用约定导致栈不平衡;通过阻塞式调用阻塞了目标进程的UI线程。
解决:先禁用业务逻辑,只保留写日志;确认MessageCallback的回调声明为CALLBACK(即__stdcall);不要在DllMain里创建UI或等待事件。把初始化动作放到新线程里去做,DllMain只负责记录加载。
4.2 线程创建成功但收不到消息
现象:日志文件里只有DLL loaded,但没有MessageCallback被调用的记录。
原因:钩子没有正确挂上,或者挂到了错误的线程。SetWindowsHookEx需要指定线程ID或设为全局。如果目标进程是多线程架构,可能需要遍历所有线程并挂载相同钩子。还有一种可能是回调函数没有导出,导致CallNextHookEx无法工作。
解决:确认钩子句柄不为NULL。如果使用全局钩子,DLL必须放在磁盘物理路径,且路径可被系统访问。推荐在进程启动时主动向所有线程挂载,但要注意释放钩子时的顺序,否则会蓝屏。
4.3 中文字符串乱码
现象:日志文件里写出的中文显示成“烫烫烫”或者一堆问号。
原因:写文件时用了ANSI编码,但消息内容是UTF-16,或者反过来。Windows内部大多使用UTF-16,控制台输出反而是ANSI,两者不一致就会乱码。
解决:统一使用宽字符API。写日志时先转成UTF-8再落盘。示例代码中WriteFile用的缓冲区长度乘以sizeof(wchar_t)就是这个原因。转换时可以用WideCharToMultiByte,或者在文件头写UTF-8的BOM。
4.4 换了电脑就失效
现象:同一份DLL和注入器,在A机器上正常,在B机器上稳不住。
原因:最常见的Windows Defender把注入行为拦下来了,或者目标客户端在B机器上自动更新到了其他版本,关键偏移量变了。还有一种情况是杀毒软件把DLL文件隔离了。
解决:先查看Windows事件日志里是否有Application Error事件。如果是杀软拦截,把代码包所在目录加入白名单;如果确认是版本偏移变了,重新用第2章的Python脚本跑一遍特征码扫描,更新配置。另外,在别人机器上跑这类工具之前,必须先获得对方的书面许可,这在团队协作中尤其重要。
4.5 回调函数死循环导致CPU爆满
现象:注入后客户端CPU占用从5%直接拉满,鼠标都开始卡顿。
原因:你的回调函数在某个触发路径里被递归调用,或者你的inline hook没有保存原始指令,导致原函数跳回时再次进入你的钩子。
解决:在回调入口加递归保护,比如用一个线程局部变量记录是否正在执行;inline hook保存原始字节时,要确保操作的长度大于等于jmp指令的长度,否则会截断后续指令。还有一点,不要在自己hook的函数里直接调用该函数的原始版本,非常容易形成递归。
这些坑每个我都踩过不止一次,尤其是第四条和第五条,很多时候不是代码问题,而是环境劫持或保护机制没处理好。
5. 验证与进阶:用日志证明hook在正常工作
DLL已经注入进去了,但怎么证明hook真的在拦截数据?最简单的方法是加一个“验证钩子”菜单:在DLL里导出一个函数,用来临时读取目标进程的某个全局变量,看看跟主进程里的其他状态是否匹配。但这个动作有风险,所以我更推荐依赖日志和内存值对照。
首先做一个清理验证。运行注入器,然后立刻检查C:\temp\hooklog.txt,第一行应是DLL loaded。接着在客户端里随便执行一个动作,比如打开一个聊天窗口,如果你的消息钩子是挂在窗口过程中的,会看到至少一条MessageCallback called。如果你用的是inline hook,那么你应该能看到业务数据被截获。这一段日志就是你整个hook环境的“体检报告”。
我在这个资源包里把日志输出分了三级:INFO、DEBUG、TRACE。注入成功、线程启动这些基本事件用INFO;消息内容用DEBUG;函数进出参数用TRACE。这样可以减少日志噪声。在config.ini里加一行:
[log] level=INFO然后在DLL里用GetPrivateProfileIntW去读取这个等级,再用switch-case过滤打印。这种做法能让你在调试阶段快速定位问题,不需要反复改代码重编译。
进阶方向,可以把日志换成可回调的接口,把数据推送给另一个进程,而不是频繁写磁盘。更稳妥的方案是建立共享内存通道,DLL把截获的数据写入共享缓冲区,外部独立进程每100毫秒读取一次。这样即使目标进程崩溃,数据也不会丢太多。真正的产品化还需要考虑卸载钩子、进程退出时清理内存、恢复Hook函数原始字节,这些都可以在验证基础通过之后慢慢补。
给一个实用的建议:DLL里所有全局变量最好用原子操作或临界区保护,因为你的钩子函数会在目标进程的任意线程上下文被调用。如果这里不加锁,日志文件里会出现错行现象。那时候排查起来会非常崩溃,你能看到每条日志都对,但混在一起根本没法用。
那次我在做某跨平台系统的自动化适配时,就是因为没给共享内存加锁,造成了数据错位,排查了整整一天。从那以后我每次写完钩子回调,都强制走一遍“先加锁、再写共享区域、最后发送信号”的流程。希望这套思路能帮你在3.9.10.19这个版本的hook调试上少走弯路。
本文还有配套的精品资源,点击获取