ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于HOOK机制的微信机器人开发:从注入到消息回调的实战指南

基于HOOK机制的微信机器人开发:从注入到消息回调的实战指南

简介:这是一款基于Hook机制实现的微信机器人项目,面向具备一定Python基础、希望搭建个人或社群自动化工具的开发者与爱好者。项目经过一年迭代,功能更贴近日常使用场景,支持AI对话(GPT、星火、千帆)、关键词拉人进群、自动群发、入群欢迎等,并会不定期维护,有代码能力者也可自行二次开发。资源包共76个文件,以34个png截图、12个py源码、9个pyc编译文件为主,另含xml配置、yaml参数文件及少量jpg、gif素材,整体约4.93MB,目录按Bot_Server、Db_Server、Api_Server、Push_Server、Config等模块划分,结构清晰,便于理解消息接收、分发、数据库与推送的协作逻辑。目前已有846人学习下载,适合想研究Hook机制、微信消息处理流程或搭建社群管理机器人的读者参考,可从中获取完整源码、配置示例与模块化设计思路。

1. 基于HOOK机制的微信机器人:为什么它比协议模拟更抗封

做微信自动化的人,大多经历过一个阶段:先用 Web 协议、再用 iPad 协议,最后发现账号还是保不住。基于 HOOK 机制的微信机器人,走的是另一条路——不伪造客户端,而是把代码注入到真实运行的微信进程里,直接调用它内部的函数、拦截它的消息回调。微信自己怎么收消息,机器人就怎么收;微信自己怎么发消息,机器人就怎么发。从服务端看,流量特征和真人客户端几乎一致。

这套方案解决的核心问题是「稳定性」和「功能深度」。协议模拟只能做服务端暴露出来的接口,而 HOOK 能碰到客户端本地的所有能力:本地数据库、通讯录缓存、朋友圈、群操作、甚至 UI 层的点击模拟。适合谁?适合需要长期挂机、对账号存活率有要求、又愿意折腾逆向工程的开发者。代价是门槛高——你得懂一点 C/C++、懂进程注入、懂逆向工具。下面把我踩过的路拆开讲。

2. 先搞清楚 HOOK 到底钩在哪里:三种注入点与选型

2.1 消息接收链路:从网络回调到本地入库

微信收到一条消息,大致经过:网络层收包 → 解密 → 分发到消息处理模块 → 写入本地 SQLite → 通知 UI 刷新。HOOK 的黄金位置在「分发到消息处理模块」这一步,因为此时消息已经解密成明文结构体,又还没落库,你能拿到最原始的数据。

常见做法是找到消息分发的导出函数或虚表入口,用 inline hook 替换函数头几个字节,跳转到自己的处理逻辑,处理完再跳回去。这样微信自身的流程不受影响,你只是「旁听」了一遍。

// 伪代码:inline hook 的基本骨架(x86 为例) // 1. 保存原始函数头 5 字节 BYTE originalBytes[5]; memcpy(originalBytes, targetFunc, 5); // 2. 构造跳转指令:jmp 到我们的 hook 函数 // 计算相对偏移 = hookFunc - targetFunc - 5 DWORD offset = (DWORD)hookFunc - (DWORD)targetFunc - 5; BYTE jmpCode[5] = { 0xE9 }; memcpy(jmpCode + 1, &offset, 4); // 3. 修改内存保护并写入 DWORD oldProtect; VirtualProtect(targetFunc, 5, PAGE_EXECUTE_READWRITE, &oldProtect); memcpy(targetFunc, jmpCode, 5); VirtualProtect(targetFunc, 5, oldProtect, &oldProtect); // 4. hook 函数里先恢复原始字节,调用原函数,再重新 hook // 这叫 trampoline,是 inline hook 的标准套路

逻辑说明:这段代码做的是「偷梁换柱」——把目标函数开头改成跳转指令。参数上,targetFunc是你逆向找到的消息处理函数地址,hookFunc是你的回调。关键点是第 4 步的 trampoline:调用原函数前必须把原始字节写回去,否则会无限递归。这是新手最容易翻车的地方,表现为微信直接崩溃。

2.2 发送链路:主动发消息要绕过的两个校验

收消息相对简单,发消息才是坑。微信发送消息前会做两层校验:一是登录态校验,二是消息内容合法性校验。如果你直接调用底层发送函数,很可能因为缺少上下文而失败。

稳妥的做法是 HOOK 发送函数的入口,在微信自己触发发送时「搭便车」,或者构造完整的调用上下文再调用。我一般会先逆向出消息结构体的内存布局,按字段填好,再调用发送函数。

// 伪代码:构造并发送一条文本消息 struct WxMessage { int type; // 消息类型,1=文本 char wxid[64]; // 接收方 wxid char content[4096]; // 消息内容 int msgId; // 消息 ID,可填 0 让微信自己生成 }; void sendText(const char* toWxid, const char* text) { WxMessage msg = {0}; msg.type = 1; strncpy(msg.wxid, toWxid, sizeof(msg.wxid) - 1); strncpy(msg.content, text, sizeof(msg.content) - 1); msg.msgId = 0; // sendMsgFunc 是逆向得到的发送函数地址 sendMsgFunc(&msg); }

参数说明:type字段不同版本微信可能不同,需要自己逆向确认;wxid是微信内部 ID,不是微信号;content长度要留足,超长消息微信会自己分片。这里最大的坑是结构体对齐——不同编译选项下字段偏移会变,一定要用逆向工具实际确认,别照抄网上的偏移量。

2.3 选型对比:inline hook、虚表 hook、导入表 hook

方式适用场景稳定性难度
inline hook普通导出函数高中
虚表 hookC++ 类成员函数中中高
导入表 hook系统 API 调用高低
消息钩子Windows 消息循环高低

选型建议:优先找导出函数做 inline hook,最稳。虚表 hook 适合钩 C++ 对象的方法,但要先定位对象实例。导入表 hook 只能钩系统 API,对微信内部逻辑无能为力。消息钩子适合做 UI 层自动化,不适合做业务逻辑。

3. 从零跑通一个最小 HOOK 机器人:注入、定位、回调

3.1 进程注入:把 DLL 送进微信进程

HOOK 代码必须以 DLL 形式注入到微信进程。最经典的是CreateRemoteThread+LoadLibrary组合。

// 伪代码:远程注入 DLL BOOL injectDll(DWORD pid, const char* dllPath) { HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProcess) return FALSE; // 1. 在目标进程申请内存,写入 DLL 路径 SIZE_T pathLen = strlen(dllPath) + 1; LPVOID remoteMem = VirtualAllocEx(hProcess, NULL, pathLen, MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProcess, remoteMem, dllPath, pathLen, NULL); // 2. 拿到 LoadLibraryA 地址(kernel32 在各进程地址一致) LPVOID loadLib = (LPVOID)GetProcAddress( GetModuleHandleA("kernel32.dll"), "LoadLibraryA"); // 3. 创建远程线程执行 LoadLibraryA HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)loadLib, remoteMem, 0, NULL); WaitForSingleObject(hThread, INFINITE); // 4. 清理 VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); CloseHandle(hThread); CloseHandle(hProcess); return TRUE; }

逻辑说明:核心思路是让目标进程自己调用LoadLibraryA加载你的 DLL。参数上,pid是微信进程 ID,dllPath必须是目标进程能访问的绝对路径。注意:如果微信是 64 位,你的 DLL 也必须是 64 位,位数不匹配注入会静默失败——这是最常见的翻车点,表现为CreateRemoteThread返回成功但 DLL 没加载。

3.2 定位关键函数:用特征码而不是硬编码地址

微信每次更新,函数地址都会变。硬编码地址的机器人活不过一次更新。正确做法是用特征码(signature)扫描。

// 伪代码:特征码扫描 // 特征码是一段字节序列,用 ?? 表示通配 BYTE pattern[] = { 0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x??, 0x53, 0x56 }; char mask[] = "xxxxxx?xx"; DWORD scanPattern(BYTE* moduleBase, DWORD moduleSize, BYTE* pattern, char* mask) { DWORD patLen = strlen(mask); for (DWORD i = 0; i < moduleSize - patLen; i++) { bool found = true; for (DWORD j = 0; j < patLen; j++) { if (mask[j] != '?' && moduleBase[i + j] != pattern[j]) { found = false; break; } } if (found) return (DWORD)(moduleBase + i); } return 0; }

参数说明:pattern是你要找的字节序列,mask里x表示精确匹配、?表示通配。特征码要选函数入口附近、且不随版本变化的字节。我一般会选「函数序言 + 一段独特逻辑」的组合,太短容易误匹配,太长容易随版本失效。扫描范围是整个模块的代码段,用GetModuleInformation拿到基址和大小。

3.3 回调处理:把消息转成自己的数据结构

HOOK 到消息函数后,参数是微信内部的结构体指针。你需要按逆向出的布局解析它。

// 伪代码:消息回调 void __stdcall onMessage(void* msgPtr) { // 按逆向布局解析 WxRawMsg* raw = (WxRawMsg*)msgPtr; if (raw->type != 1) return; // 只处理文本 // 转成 UTF-8 char utf8Content[4096] = {0}; WideCharToMultiByte(CP_UTF8, 0, raw->content, -1, utf8Content, sizeof(utf8Content), NULL, NULL); // 交给业务逻辑 handleMessage(raw->fromWxid, utf8Content); // 调用原始函数,保证微信自身流程继续 callOriginal(msgPtr); }

逻辑说明:WxRawMsg是你根据逆向结果定义的结构体,字段顺序必须和内存布局一致。WideCharToMultiByte做编码转换,微信内部多用 UTF-16。最后一定要调用原始函数,否则微信自己的消息处理会断掉,表现为消息不显示、UI 卡死。

4. 避坑指南:五个让机器人当场翻车的细节

4.1 注入后微信闪退

现象:注入成功瞬间微信进程消失。原因:DLL 里在DllMain做了太多事,比如创建线程、加载资源。DllMain在加载锁内执行,任何阻塞操作都会死锁。解决:DllMain里只做最轻量的初始化,把 HOOK 逻辑放到一个独立线程里延迟执行。

4.2 消息重复处理

现象:一条消息触发了两次回调。原因:inline hook 的 trampoline 写错,或者同时 hook 了多个入口。解决:检查 trampoline 是否在调用原函数前恢复了原始字节;用日志确认回调次数,只保留一个 hook 点。

4.3 特征码更新后失效

现象:微信更新后机器人找不到函数。原因:特征码选得太靠近版本相关逻辑。解决:多准备几组特征码做冗余,或者用「函数调用关系」定位——先找一个稳定的调用者,再顺着调用链找目标。

4.4 发送消息无响应

现象:调用发送函数返回成功但消息没发出去。原因:缺少登录态上下文,或者结构体字段偏移错误。解决:用逆向工具实际确认结构体布局;优先在微信自己发送时搭便车,而不是凭空构造。

4.5 多开时 hook 串号

现象:开了两个微信,消息发错账号。原因:DLL 是进程级注入,但你的全局变量是模块级共享。解决:所有状态用线程局部存储(TLS)或者按进程 ID 隔离,别用全局单例。

5. 进阶:用消息队列解耦业务与 HOOK 层

HOOK 层最忌讳写业务逻辑。回调函数里做数据库查询、网络请求,会阻塞微信主线程,轻则卡顿重则崩溃。我的习惯是:HOOK 层只负责「收消息、丢队列」,业务层从队列里取。

// 伪代码:无锁队列解耦 #include <atomic> struct MsgNode { char from[64]; char content[4096]; MsgNode* next; }; std::atomic<MsgNode*> g_head{nullptr}; // HOOK 回调:只入队,不做业务 void onMessage(void* msgPtr) { WxRawMsg* raw = (WxRawMsg*)msgPtr; MsgNode* node = new MsgNode(); strncpy(node->from, raw->fromWxid, sizeof(node->from) - 1); // ... 填充 content node->next = g_head.load(); while (!g_head.compare_exchange_weak(node->next, node)) {} callOriginal(msgPtr); } // 独立线程:出队处理 void workerThread() { while (true) { MsgNode* node = g_head.load(); if (node && g_head.compare_exchange_weak(node, node->next)) { handleMessage(node->from, node->content); delete node; } else { Sleep(10); } } }

逻辑说明:用无锁栈做单生产者单消费者队列,HOOK 回调只做入队,耗时操作全在 worker 线程。参数上,Sleep(10)是空转间隔,太短浪费 CPU,太长增加延迟,10ms 是个平衡点。注意内存管理——new出来的节点必须有人delete,否则长时间运行会内存泄漏。

验证方法:跑一个压测脚本,每秒往机器人发 100 条消息,观察微信内存占用和消息延迟。如果内存持续上涨,就是队列泄漏;如果延迟飙升,就是 worker 线程处理不过来,需要加线程或优化业务逻辑。

最后说个血泪教训:别在 HOOK 层用异常。微信进程里抛异常,如果没有正确的栈展开信息,整个进程直接挂掉,连日志都留不下。所有可能失败的操作都用返回值判断,别图省事用 try-catch。这个习惯帮我省了无数次「微信突然消失但不知道为什么」的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进