逆向工程这四个字,在很多开发者印象里要么是高手炫技,要么是见不得光的灰色技能。其实我做了多年程序调试与安全研究之后,越来越觉得它是理解软件运行机制最直接的一条路。今天聊的题目——Windows平台微信QQ防撤回补丁,正好可以拆成两半看:一半是“防撤回补丁”这个具体需求,另一半是前面的“逆向工程”四个字。很多学习逆向的朋友第一次真正上手分析一个现实中的商业软件,就是从这类小工具开始的。它能让你把PE结构、调用约定、内存布局、API Hook这些抽象概念全部串起来,也能让你见识到消息机制、UI刷新、数据库状态同步这些底层模块是怎么协作的。
这篇文章会从需求拆解、技术选型、Hook原理、实操流程、问题排查这几个维度展开。适合的人群很明确:想入门逆向工程但找不到合适练手目标的开发者、对Windows平台消息机制感兴趣的安全研究人员,以及那些已经装了各种补丁工具、想知道背后原理的好奇型用户。我会把通用方法论和实战细节都讲清楚,但只讲原理和通用实现思路,不针对任何商业软件提供尽量可用的特定版本方案。这一点先达成共识,后面内容才有讨论价值。
1. 目标拆解:防撤回补丁到底做了什么
1.1 消息撤回流程:先搞清楚要对抗的是什么
很多人以为消息撤回是服务端把消息彻底删掉了,所以客户端什么也拿不到。其实真实流程比这微妙得多。以主流即时通讯客户端为例,撤回动作发生时,服务端并不会删除接收方已经收到的消息内容,而是向客户端推送一条“撤回指令”。这条指令至少要携带一个关键信息:撤回的消息ID,可能还有撤回者的身份标识。客户端收到指令后,才在自己的本地数据库里把这个消息标记为撤回状态,同时从消息列表气泡区移除对应内容,并把“撤回了一条消息”的提示文案渲染到界面。
这个设计的初衷是同步性:发送方撤回,接收方也得在界面层面同步看到结果。但问题在于,接收方本地明明还存着完整的消息内容、图片缓存、文件路径。撤回删除在本质上是一个“本地状态变更 + UI刷新”过程。防撤回补丁的所有思路,都是围绕一个目标:让这个删除动作不生效,或者让UI重新渲染时把内容保留下来。
理解这层机制很关键。因为“防撤回”不是去网络层拦截加密流量,更不是伪造服务器响应,而是在客户端进程内部做文章。这也就决定了补丁的通常形态:一个DLL注入目标进程,Hook住某个关键函数,让撤回逻辑走偏。
1.2 三种技术路线与选型逻辑
针对撤回删除这个本地动作,大体上有三条技术路线可以实现“防撤回”。我在早期学习时都试过,这里直接对比一下。
第一种是静态二进制补丁,直接改客户端安装目录下的可执行文件或DLL文件。比如在IDA里分析出关键分支指令,把“判断为撤回后执行删除”的跳转改掉,保存回去。优点是无需注入,加载即生效;缺点非常致命:文件校验、版本升级、签名机制都会让补丁失效,而且修改官方程序文件本身容易触发完整性校验。
第二种是DLL注入加Hook,把写好的DLL注入目标进程,通过Hook拦截关键函数,在运行时篡改逻辑。优点是不改动磁盘文件、卸载注入即可恢复原状、切换逻辑灵活;缺点是技术门槛较高,需要处理注入方式、Hook稳定性、多线程竞争等问题。目前市面上的补丁工具绝大多数走这条路。
第三种是纯内存动态修改,用调试器附加进程后直接改内存数据或代码段。这种方案适合临时验证、分析定位,不适合做常态化工具,因为每次启动都得重新附加调试再改一遍。
三条路线的取舍逻辑很明确:静态补丁最省事但最脆,纯内存修改最灵活但不可持久,DLL注入加Hook是二者之间的平衡点。我建议学习逆向时把三条路线都体验一遍,但真正做自己的工具,优先走DLL注入加Hook这条路。一方面是因为它贴近实际安全产品技术栈,另一方面是它能锻炼对PE加载、导入表、函数调用约定这些底层知识的综合运用能力。
2. 逆向工程基础:环境、工具与定位思路
2.1 搭建隔离环境与必备工具清单
在动手分析任何商业客户端之前,先把实验室环境搭好。我个人的习惯是准备一台虚拟机,Windows 10或者Windows 11的64位版本都行,安装完系统后立刻做一个干净快照。每次分析前恢复快照,既能保证环境一致性,也能避免误操作破坏宿主系统。虚拟机里断不断网看需求,但建议至少有一份独立断网快照用于安全分析。
工具清单大概如下:
| 工具 | 用途 | 备注 |
|---|---|---|
| x64dbg | 用户态动态调试 | 64位、32位调试都支持,比OllyDbg更现代化 |
| Ghidra | 静态分析 | 免费,反编译能力强大,适合定位逻辑 |
| Process Explorer | 进程与模块查看 | 可以看进程加载了哪些DLL |
| Process Monitor | 文件、注册表、网络行为监控 | 用于观察客户端启动时的行为 |
| MinHook / Detours | Hook框架 | 写Hook代码时用,都是成熟框架 |
| 注入器或Loader | 将DLL注入目标进程 | 自己写个Loader更好,能加深理解 |
版本选择上要注意:目标客户端是32位还是64位,直接决定你用的是x64dbg还是普通调试器,以及注入DLL的架构必须与目标进程一致。一个64位进程不能加载32位Hook DLL,这在很多新手身上栽过跟头。
另外强烈建议安装一个系统的符号包,虽然商业客户端的符号指望不上,但系统DLL的符号能帮你快速过滤掉无关调用,在处理崩溃、分析栈回溯时非常有帮助。
2.2 定位关键函数的三个切入角度
拿到一个商业客户端,没有源码、没有符号表,最痛苦的事情就是不知道代码在哪个函数里。我总结出三条比较实用的切入路径。
第一条是字符串交叉引用。打开Ghidra或静态工具,在客户端模块的内存范围里搜索“撤回了一条消息”、“已撤回”、“消息已撤回”这些提示文案。找到字符串地址后,查看哪些函数引用了它。这些函数往往就是撤回处理逻辑的入口或UI刷新函数。这条路径最直接,因为它面向的是用户可见行为。
第二条是行为断点。如果你更习惯动态调试,可以附加调试器,在可能被调用的UI刷新API、消息循环、数据库操作API上下断点。比如检测到哪些函数调用了删除Item、刷新List的相关系统API,沿着调用栈回溯就能找到业务逻辑层。这个方法比静态搜索更动态,但需要你具备一点系统API的敏感度:看到InvalidateRect、RemoveObject这类函数,要有一种“这可能是UI删除动作”的条件反射。
第三条是模块依赖观察。用Process Explorer打开目标进程,先看它加载了哪些模块,特别是和消息存储、数据库、UI框架相关的第三方DLL。撤回操作涉及数据状态变更,大概率会经过某个数据库读写库或持久化模块,在那些模块的导出函数上下断点,有时候比在主程序里大海捞针高效得多。
三条路径不是互相孤立的。我实际定位时通常先静态搜索字符串确定一个区间,再用动态调试下断点验证,两个方向互相印证,速度快到不敢想象。
3. Hook原理深挖:API Hook与参数计算
3.1 撤回链路分析:Hook点应该选在哪一层
在动手写Hook之前,先把自己放在客户端的思维模型里,理解撤回指令从网络到达后是怎么流转的。我一般把整条链路分成三层:网络层、业务逻辑层、UI展示层。
网络层负责接收服务端推送的消息包,解析出指令类型和消息ID。业务逻辑层拿这个消息ID去本地数据库查找对应记录,把状态标记为是撤回,然后通知UI层刷新。UI层接到指令后,执行真正的气泡删除操作,并把提示文案渲染出来。
那么Hook应该选在哪一层?这是一个很典型的逆向工程决策问题。每层都有优缺点。Hook网络层的好处是可以拿到原始数据,理论上你能完整截获所有撤回指令再决定要不要交给业务层;缺点是网络层函数通常与加密握手、长连接维持强相关,参数结构复杂,调用频繁,稍有不慎就会引入延迟或崩溃。
Hook业务逻辑层是大多数补丁的选择。这一层有一个明确的业务动作:按消息ID查本地记录并标记状态。如果你Hook住这个查询标记函数,让它在判断是撤回时不修改本地记录状态,那么就算UI层照常刷新,它也找不到“被标记为删除”的消息,气泡自然就不会消失。这里还有一个非常关键的附加好处:本地数据库的状态没变,当你重新进入会话或重新加载历史消息时,消息仍然是完整的。
Hook UI层则是最后的手段。直接阻止UI删除气泡,问题在于UI刷新机制非常复杂,聊天窗口里的消息列表可能复用缓存、虚拟化滚动,单纯阻止一次刷新而不修正数据状态,很容易出现“消息一闪而过”、“滚动后又消失”的奇怪现象。
我自己的经验是:优先找业务逻辑层的状态变更函数,其次才是UI层。你在动态调试里反复观察撤回动作时,只要能看到一个函数把某个消息记录的状态值改成“已撤回”,那基本就是理想的Hook点了。
3.2 IAT Hook与Inline Hook的实现对比
选定Hook层之后,接下来面临第二个选择:用IAT Hook还是Inline Hook。
IAT Hook的思路非常简单,修改目标进程导入地址表里某个系统API的地址,使其指向你自己的函数。当目标进程调用这个系统API时,执行流就直接跳进了你的钩子里。它不需要修改函数头部指令,风险较低,逻辑也直观。但局限同样明显:它只能拦截通过导入表调用的API。如果目标代码是直接通过GetProcAddress动态获取函数地址,或者自己封装了一层、直接拿到内核调用入口,IAT Hook就完全失效了。
Inline Hook则是从函数入口处把原始指令篡改为跳转指令,无论调用者从哪里拿到函数地址,只要执行到这个入口,就会被劫持。它的通用性强,但实现细节更复杂,还需要保存原始指令并构造一个“跳板函数”用于完成原逻辑调用。
多数防撤回补丁最终选择Inline Hook,因为客户端内部的业务函数不像系统API那样总是通过导入表暴露,你定位到的是一个内部函数地址,IAT Hook无从谈起。Inline Hook的具体流程是这样的:
- 保存目标函数前5到12字节的原始机器码;
- 在目标函数头部写入跳转指令,指向你自己的Hook函数;
- Hook函数中先执行自定义逻辑,需要调用原功能时,通过“跳板”执行保存的原始指令,再跳回目标函数剩余部分;
- 处理多线程并发访问,保证修改跳转指令的过程不会被其他线程同时执行。
这里有一个新手常踩的坑:保存原始指令的长度不是随便定5字节就行。你保存的指令边界必须完整,保证跳板里的原始指令能正确执行。如果从指令中间切断,跳板执行时就会解成完全不同的指令序列,轻则逻辑错误,重则崩溃。我一般会用反汇编引擎先解码,确认覆盖到完整指令边界,再计算保存长度。
3.3 64位平台的长跳转与偏移计算
动态调试时绕不开的一个细节是偏移计算,尤其是在64位平台上。x86架构下Inline Hook最常用的方式是在函数头部写入一个E9开头的相对跳转指令。E9指令的操作数是一个32位相对偏移,计算公式为:
偏移 = 目标地址 - (当前指令地址 + 5)这条指令一共5字节,1字节操作码加4字节偏移。偏移量是相对于下一条指令的地址计算的。举个具体例子:原始函数入口在0x140001000,你的Hook函数在0x140002000,那么偏移就是0x140002000 - 0x140001005 = 0xFEFB。如果目标地址与源地址的距离超出32位能表达的范围,E9跳转就失效了。
64位程序里这种情况很常见,因为模块基址、代码段和Hook函数可能分布在相距很远的地址。这时候不能直接用E9,需要换一种方式:先写入一条“mov rax, 目标地址;jmp rax”的指令序列,机器码大致是48 B8后面跟8字节绝对地址,接着FF E0跳转。这样就不受32位偏移限制,但要注意它会额外修改RAX寄存器,如果你的Hook函数没有保存RAX,原函数的后续逻辑就会遭殃。
正确的做法是在Hook入口处做一个完整的寄存器现场保存,要么用栈把可能破坏的寄存器压进去,要么直接在你的汇编跳板里先push、处理、再pop。另外64位系统还有栈对齐要求。如果你在Hook函数里直接调用系统API,必须保证RSP在Call边界按16字节对齐,否则百分百出现莫名其妙的崩溃。
我在给一位朋友排查他写的64位补丁时遇到过这样一个情况:Hook逻辑明明正确,但一执行到某个系统API调用就崩溃,后来发现是他的汇编跳板里多压了一个寄存器,导致调用前栈没有对齐。这种问题不用Windbg看一眼汇编栈,靠纯直觉很难定位。
4. 实战演练:从行为观察到一个可验证的补丁雏形
4.1 附加调试器,利用字符串和调用栈定位关键函数
现在我们把前面所有理论串起来,走一遍完整的分析流程。以下操作都是我自己在调试各种客户端时反复执行的套路,你可以在学习环境里原样复现。
第一步,启动目标客户端,正常登录,进入一个聊天会话。用Process Explorer找到进程ID,确认它是32位还是64位。
第二步,用x64dbg附加该进程。附加之后不要急着操作,先按一次暂停,再在反汇编面板里搜索“撤回了一条消息”这个Unicode字符串。如果客户端模块把字符串放在了堆内存而不是静态资源区,直接在静态区搜不到,这时候可以再去Procmon或者内存窗口里搜索。实在搜不到也没关系,直接在撤回事件发生的瞬间用“内存访问断点”拦截字符串读取。
第三步,找到字符串引用后,顺着交叉引用回溯函数。一般会先看到一个UI渲染函数,然后往上走一两层就能碰到业务逻辑函数。此时在每一个候选函数上下F2断点,然后在真实环境让对方向你撤回一条消息。
断点命中后,关键动作来了:不要急于单步,先看调用栈。调用栈会清晰地展示出“谁调用了它”。如果你看到栈里的上层函数有消息ID处理、数据库查询相关参数,那就基本锁定了业务逻辑函数。此时重点观察寄存器和栈窗口里的参数值,比如这个消息ID、状态码、原始内容指针。把它们记录下来,后续写Hook全看这些参数怎么用。
4.2 用通用Hook框架Demo演示补丁逻辑
定位到关键函数之后,后面编写补丁的流程其实相当模板化。我用MinHook框架写一个通用示范代码。这里只演示捕点原理,函数地址需要你在调试器里替换成自己环境里的真实地址。
#include <Windows.h> #include <MinHook.h> #include <cstdio> // 演示用逻辑结构,实际目标函数参数不一定是这个形态 struct MsgItem { int msgId; int state; // 0=正常 1=已读 2=已撤回 wchar_t content[256]; }; typedef int (WINAPI* ProcessMsgFn)(MsgItem* item); ProcessMsgFn fpOriginal = nullptr; // Hook逻辑:拦截撤回状态变更 int WINAPI HookedProcessMsg(MsgItem* item) { if (item && item->state == 2) { // 关键:不把state改成已撤回 / 不触发删除 // 而是改成“保留但忽略撤回提示” item->state = 0; // 这里不调用原函数,直接返回成功 // 因为原函数会执行删除动作 return 0; } // 其他消息类型继续走原始逻辑 return fpOriginal(item); } BOOL APIENTRY DllMain(HMODULE hDll, DWORD reason, LPVOID) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hDll); // 真实使用中:用调试器或分析工具拿到目标函数地址, // 把这个地址传给 Mh_CreateHook 的入口参数。 // 例如:MH_CreateHook((LPVOID)REAL_FUNC_ADDR, &HookedProcessMsg, &fpOriginal); // 然后 MH_EnableHook(MH_ALL_HOOKS); } return TRUE; }这段代码的逻辑非常直白。你要拦截的并不是整个消息处理流程,而仅仅是“当state等于撤回状态时”的分支。判断这个分支后,自定义逻辑直接修改状态值,然后返回成功,绕开原函数里的删除动作。其他状态的消息则调回原函数正常处理。这里面最关键的一点:千万别在Hook函数里调用可能触发重入的系统API。比如为了调试弹一个MessageBox,这个消息框的消息循环很可能让目标进程再次进入消息处理函数,导致递归死锁。我见过太多初学者在这里翻车。
写完之后,还需要一个Loader把DLL注入到目标进程。常见的注入方式包括CreateRemoteThread配合LoadLibrary、SetWindowsHookEx、或者直接用支持注入的调试框架。我倾向于自己写一个简单的CreateRemoteThread注入器,虽然老套但可控性好,也便于理解注入的本质。注入成功后,目标进程的Module列表里会出现你注入的DLL,MinHook会在DLL加载时完成HOOK替换。
4.3 多场景回归测试与验证要点
补丁写完、注入成功,远没有到庆祝的时候。验证阶段才是真正考验人的地方。撤回的场景远比你想象的丰富:单聊撤回、群聊撤回、撤回纯文字、撤回图片、撤回文件、撤回语音、对方撤回时你正在其他会话界面……每种场景的代码路径都可能不一样。
我的建议是列一张清单,逐项测试:
- 对方主动撤回文字消息,确认我的聊天窗口保留内容,同时不再出现撤回提示;
- 对方撤回图片,确认图片缩略图和原图都能打开;
- 对方撤回文件,点击文件仍能正常漫游或打开;
- 群聊多人同时操作,确认不闪退、不卡顿;
- 重启客户端后再翻聊天记录,确认历史消息仍然完整存在;
- 切换网络环境、重新拉取离线消息,确认补丁在离线消息场景下也有效。
这几个测试项中,最容易被忽略的是“重启后历史消息是否完整”。很多补丁为了省事,只做了UI层的删除拦截,本地数据库状态已经是已撤回,那么重启后重新加载消息记录时,客户端会从数据库读取到已撤回标记,之前保住的聊天内容瞬间消失。比较好的補丁方案应该同时处理数据状态和UI展示两层。这也是为什么我一直强调要在业务逻辑层的状态变更函数上做文章,因为你只有在这个层面改变状态,数据库持久化的结果才会跟着改变。
5. 常见问题与排查技巧实录
5.1 注入后客户端崩溃,问题大概率出在函数签名
补丁注入后客户端立刻崩溃,这是最常见的事故现场。我排查过很多次,最终原因几乎都集中在Hook函数与原函数的签名不一致上。
调用约定、参数个数、参数大小、返回值类型,任何一个地方对不上,栈指针就会出现错位。比如原函数是stdcall约定,参数由被调用方清理栈,你的Hook函数却声明成cdecl,那么返回时栈没有被正确清理,进程立刻爆掉。32位程序里这种问题尤其多,64位程序因为统一使用Microsoft x64调用约定,崩溃概率相对低,但参数结构不对同样会程序崩溃。
排查方法我习惯三步走。第一步,先去掉Hook逻辑,只做“调用原函数”的空Hook,看是否崩溃。空Hook都崩,说明签名或跳板有问题。第二步,把Hook函数里的业务逻辑逐步注释,二分定位到具体是在哪个函数调用处崩溃。第三步,看调用栈,崩溃地址如果落在系统DLL里,重点检查传参;崩在跳板函数,重点检查保存的原始指令长度与执行流。
5.2 补丁不生效,先别急着怀疑Hook框架
补丁注入成功、没有崩溃,但撤回消息依然消失。这种情况同样常见。别急着怀疑MinHook有Bug,绝大多数问题出在Hook地址本身。
商业客户端几乎都启用了ASLR,进程每次启动时模块基址都会变化。你如果在某次调试中记下一个绝对地址,然后把这个地址写死在补丁里,那换一次启动就废了。正确的做法是在运行时动态获取目标模块基址,加上你在静态分析阶段算出的偏移量,得到当前进程里的真实函数地址。这也是为什么完整的补丁工具往往会内置特征码搜索,用一段固定的字节模式去内存里扫描定位,而不是依赖绝对地址。
另一个常见的失效原因是目标函数被优化内联。你在静态分析时看到的逻辑,实际运行时已经被编译器内联到了调用处,你Hook的那个函数根本不存在于最终代码里。这会表现为:搜索字符串能搜到,但下断点断不下来。解决思路是把Hook点下移到调用该逻辑的父级函数,或者在更稳定的边界API上Hook。
5.3 杀软误报与加固机制带来的挑战
Hook DLL注入进程后,被杀毒软件或安全防护组件拦截,可以说是必然结果。注入本身这种行为特征和木马远控极度相似:一个陌生DLL通过远程线程方式加载到正常进程,必然触发行为检测。我在学习阶段就遇到过几次刚点下注入按钮、虚拟机就被强制断网隔离的情况。
处理这个问题要分清场景。如果你是纯学习、在隔离虚拟机里做实验,那直接把虚拟机的实时防护关掉,或者设置白名单就行。如果你希望在主力机上长期使用这类补丁,我建议还是放弃吧:一方面杀软实时防护和Hook DLL的冲突永无止境,另一方面商业客户端的加固模块也会主动检测注入行为。一旦被检测到,轻则功能失效,重则客户端直接拒绝登录或触发风控。技术需求归技术需求,合规边界归合规边界,这个分寸要想清楚。
顺带提一句加固对抗。你用调试器附加进程时,如果发现频繁收到调试事件、断点被清除、API行为异常,说明目标启用了反调试机制。这种情况下不要硬刚,硬刚下去没有意义。换一个版本的研究目标、用静态分析补齐信息,或者走内核态方案,都是更高效的选择。
5.4 WOW64与32位补丁在64位系统的兼容性陷阱
最后聊一个很多文章都不爱提的细节:32位目标进程在64位Windows系统上的兼容性问题。
Windows通过WOW64层让32位程序在64位系统里运行。这个环境有不少坑,最典型的是文件系统和注册表重定向。如果你的Loader以64位进程运行,你去访问System32目录,系统会把你引导到Sysnative或者通过WOW64转换到SysWOW64。同理,注册表里的某些键也被重定向到WOW6432Node。这会导致你在注入时找不到目标DLL路径,或者在Hook内部访问某个文件时读写到了错误的位置。
处理办法很简单:如果你的目标是32位客户端,尽量让Loader也编译成32位程序,这样整个进程环境保持同一视角,避免重定向干扰。另外x64dbg调试32位进程时要确保选择正确的调试引擎,别用64位视图去解析32位模块的地址。
WOW64还有一个隐藏坑:32位进程调用系统API时,WOW64层会做线程状态转换,栈布局和64位原生进程不同。如果你在Hook函数里嵌入汇编代码,不要想当然地复制64位环境下的寄存器偏移,必须现场实测寄存器状态。
6. 最后分享一点个人习得
技术层面该讲的差不多都讲了,最后说点学习层面的体会。逆向工程这门手艺,真正值钱的从来不是“能破解某款软件”这个结果,而是在无数次打断点、看调用栈、算偏移、查文档之后沉淀下来的问题定位能力。我在调试中见过太多人一看到目标有反调试就慌,一看到函数地址变来变去就放弃,其实这些都是常态,商业软件没有义务让你一眼看穿。
防撤回补丁这个题目,对逆向初学者来说是一个特别完整的练手项目:它涉及字符串定位、函数回溯、调用约定、Inline Hook、注入机制、多线程稳定性、杀软对抗等多个维度。把一个点练透,你再去接触其他客户端插件、辅助工具、自动化脚本,思路都是一通百通的。
但我更要强调边界感。理解机制用于学习、研究、提升技术认知,和实际去篡改商业软件行为是两个层面的事情。技术能力本身是中性的,怎么用取决于你自己。我个人的建议是:学到原理、跑通Demo、在隔离虚拟机里验证到足够有成就感,就停下来,把时间投入到更有建设性的项目里去。毕竟,我们热爱的是工程与手艺本身,而不是给聊天记录做永生。
后续如果你有兴趣,可以沿着这个方向再挖一层:研究一下目标客户端在收到离线消息时的同步机制,看看消息数据库的字段定义,或者尝试自己写一个通用注入Loader替代手工注入器。每往前走一步,你对Windows平台软件运行机制的理解都会深一块。