前言与说明
先说明:我本人完全不懂逆向。 下面的内容不是我独立写出的专业逆向教程,而是 GPT 根据这次完整操作过程、x32dbg 动态结果、Git 历史、脚本输出和最终测试结果整理出的总结文档。
这次协作中,我负责实际操作:登录和重启微信、用另一台设备发送并撤回消息、观察私聊和群聊界面、确认文件/图片/微信表情的效果。GPT 配合 Skills 和 MCP 负责整理项目历史、分析反汇编、设置和清理日志点、读写测试内存、核对字节与哈希,并把全过程整理成文档。
本文尽量讲清楚:用了什么、为什么这么查、走过哪些弯路,以及最后 3 个字节为什么能同时做到“原消息保留”和“撤回提示显示”。
适用范围: 本文结论只针对 WeChat 3.9.12.56 x86 的指定 WeChatWin.dll。不同版本、不同架构的地址和字节都可能变化,不能直接套用。
先看最终结果
最终成果已发布至 FL0VEF/WeChat-AntiRecall。仓库包含已验证的 WeChatWin.dll、效果图,以及安装和恢复说明。
最终找到的特征如下:
目标版本: WeChat 3.9.12.56 x86
目标模块: WeChatWin.dll
RVA: 0x12474E2
file offset: 0x12468E2
原字节: 8A 4E 35
替换字节: B1 00 90
测试结果:
| 场景 | 原消息/内容 | 聊天详情撤回提示 | 会话列表摘要 | 微信响应 |
|---|---|---|---|---|
| 私聊文字 | 保留 | 显示 | 显示撤回提示 | 正常 |
| 群聊文字 | 保留 | 显示 | 显示撤回提示 | 正常 |
| 文件 | 保留 | 显示 | 正常 | 正常 |
| 图片 | 保留 | 显示 | 正常 | 正常 |
| 微信表情 | 保留 | 显示 | 正常 | 正常 |
其中私聊文字、群聊文字分别进行了明确的场景测试;文件、图片和微信表情是后续人工回归确认。非文本类型没有把私聊、群聊的所有组合逐项拆开测试,因此本文只记录已经实际观察到的结论,不扩大推断。
准备了哪些工具
实际工具
| 工具 | 本次用途 | 零基础理解 |
|---|---|---|
| x32dbg | 附加 32 位微信、查看汇编、观察运行状态、临时修改内存 | 用来暂停并观察程序“此刻正在执行什么” |
| x64dbg MCP | 让 GPT 程序化控制当前 x32dbg 会话 | 相当于 x32dbg 和 GPT 之间的遥控接口 |
| PowerShell | 查看进程状态、计算哈希、复制和比较 DLL | Windows 自带的命令行工具 |
| Python | 写小脚本处理 PE、字节和日志 | 用来自动完成重复计算 |
| pefile | 将 RVA 映射到文件偏移等 PE 解析 | 帮助读懂 Windows DLL 的结构 |
| Capstone | 离线反汇编和指令核对 | 把机器字节翻译成汇编指令 |
| Git | 查看 RevokeMsgPatcher 的历史提交和旧版 x86 特征 | 从旧版本经验中寻找定位方向 |
| RevokeMsgPatcher | 参考补丁数据格式、历史特征和项目实现 | 本次逆向使用的开源参考项目 |
本次没有依赖 IDA Pro。IDA/Ghidra 当然也能做更深入的静态分析,但这个案例主要通过项目历史、离线反汇编和 x32dbg 动态验证完成。
用到的 Skills
Skill 更像“分析方法和工作流程”,不是调试器本身。
| Skill | 本次作用 |
|---|---|
ctf-sandbox-orchestrator |
约束分析范围,要求优先相信真实运行结果,并保留可复现证据 |
reverse-engineering |
按“分诊 → 静态分析 → 动态验证 → 综合结论”的流程推进 |
binary-diff |
参考 Git 中其他 x86 版本的历史特征,帮助迁移分析思路 |
browser-automation |
提供 Windows 桌面程序、x32dbg 界面和截图取证的操作思路 |
docs-generator |
将最终结论整理成 Evidence → Finding → Path 的技术文档 |
organize-markdown-docs |
检查博客结构、Markdown 可读性和敏感路径清理 |
这里需要单独说明:没有一个名字就叫“x32dbg Skill”的独立 Skill。本次是由 reverse-engineering 提供动态逆向方法,browser-automation 提供桌面交互方法,再由 x64dbg MCP 直接控制 x32dbg。
用到的 x64dbg MCP 能力
虽然 MCP 名字是 x64dbg,但插件同时服务于 x64dbg/x32dbg。目标是 32 位微信,所以实际前端始终是 x32dbg。
本次主要使用了这些能力:
debug_get_state / debug_pause / debug_run
module_get
memory_read / memory_search / memory_write
disassembly_range
breakpoint_set / breakpoint_set_log / breakpoint_list / breakpoint_delete_all
patch_list
script_execute / script_execute_batch
thread_list / stack_get_trace
它们分别负责确认调试器是否暂停、读取模块基址、搜索唯一字节特征、读取反汇编、设置自动日志点、清理断点、写入临时内存补丁,以及判断微信“未响应”究竟是程序问题还是调试器暂停。
小白先认识几个概念
| 概念 | 通俗解释 | 本次实例 |
|---|---|---|
| x86 / 32 位 | 程序使用 32 位指令和寄存器,因此要用 x32dbg | WeChatWin.dll 是 PE32 / I386 |
| DLL | 被主程序加载的功能模块 | 撤回处理主要位于 WeChatWin.dll |
| RVA | 相对模块加载基址的地址 | 最终点为 0x12474E2 |
| runtime address | DLL 加载进内存后的真实地址 | 模块基址 + RVA |
| file offset | 指令在磁盘 DLL 文件中的位置 | 最终为 0x12468E2 |
| ASLR | 每次运行时模块可能被加载到不同地址 | 不能永久照抄上一次 runtime address |
| 特征码 | 用一段较稳定的字节定位目标逻辑 | 最终上下文在该 DLL 中唯一命中一次 |
| 内存 A/B | 只在当前进程改字节,对比修改前后效果 | 重启微信即可恢复原始内存 |
| 软件断点 | 临时把指令改成 CC,命中时调试器接管 |
点太多或暂停太久会让微信看起来卡死 |
| 硬件断点 | 使用 CPU 的 DR0–DR3 寄存器监控 | 数量有限,本次多线程环境中不够稳定 |
本次运行中,WeChatWin.dll 曾加载到 0x6A200000,所以:
runtime address = 0x6A200000 + 0x12474E2
= 0x6B4474E2
但 file offset 不是简单使用 RVA,也不能把 runtime address 直接写入磁盘文件。它需要根据 PE section 的虚拟地址和原始文件位置换算;本次结果是 0x12468E2。
第一步:先固定目标,不要分析错 DLL
原始目标信息:
文件: WeChatWin.dll
类型: PE32 / I386
大小: 75,187,360 bytes
SHA1: 9D17F22EEEA17CBB6D782B88E3443F09BB356757
SHA256: 13404AAAEB663E8B676DBDF5A59946D2060B0E3E1F8B936AA6BFC4744CA8F511
哈希很重要。同样显示为 3.9.12.56 的文件也可能因为渠道、更新方式或二次修改而不同。只有哈希一致,本文记录的偏移和字节才有直接参考价值。
第二步:先看参考项目和历史版本
本次逆向参考的上游项目是 huiyadanli/RevokeMsgPatcher。项目的 patch.json 保存了大量旧版本的精确修改或通用特征,Git 历史中也留下了多个 x86 版本的经验。
本次重点参考了两类历史:
- 提交
2da6b60:微信 3.9.9.43 带撤回提示; - 提交
66245b7:3.7 开始的特征替换为带防撤回提示的特征; - 其他
3.9.x、3.7.xx86 特征及项目的通用ReplacePatterns。
历史记录的价值不是直接照抄偏移,而是告诉我:
- “只保留原消息”和“保留原消息同时显示提示”通常是两种不同特征;
- 带提示方案往往不是简单返回,而是改变某个状态或分支;
- 新版本必须重新确认实际执行链,旧地址只能帮助缩小范围。
第三步:建立真实撤回调用链
动态结果确认,收到撤回同步消息时会按 XML type 4 进入:
SyncMgr::ProcessRevokeMsg
RVA 0x17B2580
随后本次普通消息路径大致如下:
这条链是后续判断的基础。源代码、字符串和函数名只能提供解释,真正决定结论的是实际运行时命中了哪条分支。
第四步:用自动日志点确认 UPDATE、INSERT 和 EVENT
为了减少人工单步,调试器使用了自动继续日志点:
Break Condition = 0
Log Condition = 1
Fast Resume = 0
这类点命中后会记录信息并继续运行,理论上不会像普通断点一样一直停住。不过它仍然会让所有线程发生一次极短的暂停,所以不能无限增加。
普通文字撤回的基线结果是:
UPDATE @ RVA 0x124796F = 1 次
INSERT @ RVA 0x12479CA = 0 次
EVENT @ RVA 0x1247A1B = 1 次
这三项说明:正常撤回会走 UPDATE,把已有消息对象更新为撤回系统消息;不会新插入一条独立系统消息;更新后仍会发布 UI/提示事件。
第五步:几个看起来合理、实际错误的方向
误命中一:0x2E2D91C
这个地址撤回时曾命中,看起来很可疑,但静态拆解后发现它属于:
/ipportrecords2.xml
mars::stn::NetSource
IP / port / domain / historyresult
也就是说,它只是撤回期间刚好发生的并发网络活动,和撤回消息逻辑无关。将这里的 74 改成 EB 只会改变网络历史记录中的时间戳字段选择。
教训:断点命中只说明“执行过”,不说明“和当前目标有关”。必须结合调用栈、字符串和功能 A/B 验证。
误方向二:整体跳过撤回入口
如果在 XML type 4 或 ProcessRevokeMsg 入口直接返回,原消息可能保住,但撤回提示链也被跳过,不符合目标。
排除三:ConvertOriginalMsgToRevokeMsg
静态上,RVA 0x17B29D4 调用的函数确实像“把原消息转换成撤回消息”。但本次真实普通文字撤回中,它前面的路径日志命中 0 次,而 AddOrUpdateRevokeMsg 命中 1 次。因此它不是本次事件的首选补丁点。
排除四:0x12473C4 存在性查询
这个 call 根据两组键查询记录是否存在并返回布尔值。它没有删除或覆盖原消息的业务语义。把它 NOP 掉只会伪造 exist 状态,反而可能污染后续逻辑。
暂不采用:强制重新生成 LocalId
RVA 0x12478A7 控制是否补充/生成 64 位 LocalId。强行走生成路径可能造成重复、错序或数据库主键问题,而且不会直接改变 UPDATE/INSERT 标志,因此没有作为第一轮测试。
第六步:第一次部分成功,为什么还不够
第一轮真正有价值的候选是:
RVA: 0x1247961
file offset: 0x1246D61
原字节: 74 0E
测试字节: EB 0E
这里把条件跳转改为无条件跳转,从而跳过后面的 UPDATE 调用。
实际效果:
- 原消息保留;
- 左侧会话列表摘要显示“撤回了一条消息”;
- 聊天详情没有新增撤回提示行;
- 微信正常响应。
这次结果非常关键。它证明:
- UPDATE 确实与原消息消失有关;
- 会话列表摘要可以由后面的 EVENT 更新;
- 聊天详情中的提示行不能只靠 EVENT,它需要一条真正保存到消息列表中的独立系统消息;
- 单纯跳过 UPDATE 只做了一半,还必须让 INSERT 正常执行。
换句话说,左侧会话摘要和右侧聊天详情不是完全相同的数据/UI 路径。
第七步:为什么“先本地删除再撤回”也没用
为了尝试自然触发 INSERT,做过一次实验:
- 另一设备发送唯一文本;
- 电脑端本地删除该消息;
- 另一设备再撤回;
- 同时观察原消息查询返回点和 INSERT 点。
结果:
QRET = 1
INSERT = 0
也就是说,虽然电脑界面里已经删除,但底层数据库或缓存仍然能够查询到原消息。UI 上的“删除”不等于撤回处理链中的对象彻底不存在,所以并不会自然走 INSERT。
第八步:最终点为什么有效
继续向前追踪后,关键点收敛到:
; RVA 0x12474E2
mov cl, byte ptr [esi+35h]
mov byte ptr [ebp-16h], cl
...
test cl, cl
jz new_object_path
这个 CL 字节不只在后面决定 UPDATE 还是 INSERT,它在更早的位置就决定了撤回系统消息对象按“更新模式”还是“新增模式”构造。
原始逻辑可以简化理解为:
最终修改为:
8A 4E 35 mov cl,[esi+35h]
↓
B1 00 90 mov cl,0; nop
其中:
B1 00是mov cl,0,只把 CL 设置为 0;90是nop,用于补齐原来 3 字节的指令长度;- 它不会像
xor ecx,ecx那样额外清空 ECX 高位,也不会提前改变 EFLAGS,因此是更保守的替换。
修改后,SaveRevokeSysMsg 从源头按“新增撤回系统消息”构造对象,后面自然进入 INSERT:
原消息记录 → 不再被 UPDATE 覆盖
撤回系统提示记录 → 作为独立消息 INSERT
EVENT → 继续更新会话列表摘要
这正好同时满足三个 UI 目标。
为什么调试过程中微信经常“卡死”
这是本次最浪费时间、也最值得记录的坑。
Windows 显示“未响应”不等于程序一定崩溃。只要主界面线程被调试器暂停,窗口无法处理消息,系统就会显示未响应。
几次卡住时观察到:
x32dbg state = paused
EIP 位于 ntdll/kernelbase 等系统代码
breakpoints = 0
WeChat Responding = False
执行继续运行后,微信又恢复 Responding=True。这说明至少多次“卡死”是调试器把整个进程停住,而不是补丁本身造成死循环。
本次总结出的稳定做法是:
- 日志点最多设置 2~3 个低频软件点;
- 获取一次结果后立刻删除全部断点;
- 不再使用本环境中容易导致未响应的硬件断点;
- 最终 A/B 测试时保持零断点;
- 暂停不到一秒,只写一个候选字节组;
- 立即继续运行;
- 写完后 Detach x32dbg,再做界面测试。
四个软件日志点曾在没有触发撤回前就让微信持续未响应;硬件断点在这个多线程/MCP 环境中也不稳定。因此“多打几个点更高效”并不总成立,调试器本身会改变程序时序。
最终特征与文件证据
原始 DLL
大小: 75,187,360 bytes
SHA1: 9D17F22EEEA17CBB6D782B88E3443F09BB356757
SHA256: 13404AAAEB663E8B676DBDF5A59946D2060B0E3E1F8B936AA6BFC4744CA8F511
最终字节上下文
原始完整上下文:
8A 4E 35 88 4D EA C6 45 EB 00 84 C9 0F 84 81 00 00 00
修改后完整上下文:
B1 00 90 88 4D EA C6 45 EB 00 84 C9 0F 84 81 00 00 00
在本次 DLL 中,原始完整上下文唯一命中一次;生成测试 DLL 后,修改后的完整上下文也仅在 file offset 0x12468E2 命中一次。文件大小保持不变,离线比较只有以下 3 个字节发生变化:
0x12468E2: 8A → B1
0x12468E3: 4E → 00
0x12468E4: 35 → 90
生成的独立测试 DLL 哈希为:
SHA1: 7F40EB826FAC495E26439793F7AF96C8734E207D
SHA256: 7439141EF93F6EAD6B9E329B7690495CE37890C5AD9FD363EA0E4CC7C2DC77B4
对应的成品 DLL、效果图以及安装和恢复说明已发布在 FL0VEF/WeChat-AntiRecall。仓库中的 WeChatWin.dll 已完成离线字节、哈希和文件替换后的干净启动复验,其 SHA1、SHA256 与上文记录的测试 DLL 哈希一致;实际效果与进程内存测试一致:原消息/内容保留,聊天详情撤回提示和会话列表摘要正常,微信保持响应。
验证过程如何保证不是巧合
整个过程尽量遵守四个原则:
- 一次只改一个变量。例如测试
0x12474E2时,旧候选0x1247961必须保持原始74 0E。 - 使用唯一测试文本。每轮发送不同标识,避免把上一轮日志当成本轮结果。
- 先确认原字节和唯一命中。基址、版本或字节不一致就不写内存。
- 失败立即回到最早的不确定阶段。不因为某个地址命中过就直接认定它是撤回逻辑。
测试矩阵中的证据来源也做了区分:
| 结论 | 证据来源 |
|---|---|
| UPDATE/INSERT/EVENT 次数 | x32dbg 自动日志点和 hit count |
| 内存中最终字节 | MCP memory_read 复核 |
| 断点已清空、调试器状态 | MCP breakpoint_list、debug_get_state |
| 私聊/群聊 UI 效果 | 人工实际发送并撤回后观察 |
| 文件/图片/微信表情效果 | 人工非文本回归观察 |
| 文件偏移、哈希、唯一性 | PowerShell/Python 离线核验 |
Evidence → Finding → Path
为了方便以后复查,这里保留一份精简证据链。更完整的地址、日志点和排除项见文末的专业报告。
Evidence
| ID | 证据 | 关键结果 |
|---|---|---|
| E-01 | 原始 DLL 哈希和 PE 信息 | 固定目标为指定 3.9.12.56 x86 DLL |
| E-02 | UPDATE/INSERT/EVENT 三点日志 | 基线为 UPDATE=1 / INSERT=0 / EVENT=1 |
| E-03 | 0x1247961 内存 A/B |
原消息保留,但详情提示缺失,只有会话摘要更新 |
| E-04 | 本地删除后撤回双点日志 | QRET=1 / INSERT=0,本地删除不能自然触发 INSERT |
| E-05 | 0x12474E2 零断点内存 A/B |
私聊文字四项目标全部通过 |
| E-06 | Detach 后群聊回归 | 群聊文字效果与私聊一致,微信正常响应 |
| E-07 | 非文本人工回归 | 文件、图片、微信表情同样有效 |
Findings
| ID | 状态 | 结论 |
|---|---|---|
| F-01 | 已验证 | 0x1247961 只跳过 UPDATE,不能生成聊天详情独立提示 |
| F-02 | 已验证 | 会话摘要 EVENT 与聊天详情消息 INSERT 是不同更新路径 |
| F-03 | 已验证 | 0x12474E2: 8A 4E 35 → B1 00 90 能让提示按新增对象/INSERT 保存 |
| F-04 | 已验证 | 最终特征覆盖私聊、群聊文字,并对文件、图片、微信表情有效 |
| F-05 | 已验证边界 | 多次未响应由调试器 paused 造成,不能直接当成补丁失败 |
Path
固定 DLL 哈希
→ 参考旧版 x86 特征,但不照抄地址
→ 动态确认 ProcessRevokeMsg / AddOrUpdateRevokeMsg / SaveRevokeSysMsg
→ 证明基线走 UPDATE 而非 INSERT
→ 用 0x1247961 证明“原消息覆盖”和“会话摘要”可分离
→ 排除本地删除自然触发 INSERT
→ 向前定位源 update/exist 标志 0x12474E2
→ 强制 CL=0,让对象完整走新增构造 + INSERT
→ 私聊、群聊和非文本回归通过
参考资料与项目
- 本文最终成果仓库:FL0VEF/WeChat-AntiRecall
- 逆向参考项目 RevokeMsgPatcher:https://github.com/huiyadanli/RevokeMsgPatcher
- RevokeMsgPatcher README 与 Wiki 中的微信防撤回原理、版本支持说明
- Git 提交
2da6b60:https://github.com/huiyadanli/RevokeMsgPatcher/commit/2da6b6087069ee94d60007ffe9fd88e05b6e5d8a - Git 提交
66245b7:https://github.com/huiyadanli/RevokeMsgPatcher/commit/66245b7 - 《我已经看到了,撤回也没用了(PC微信防撤回补丁)》:https://www.cnblogs.com/meowv/p/11428772.html
- HackHP 原始参考链接:https://www.hackhp.com/archives/919.html
- x64dbg/x32dbg:https://x64dbg.com/
最后的边界说明
- 该特征只对本文固定哈希的 WeChat
3.9.12.56 x86负责; - 微信升级后必须重新定位或至少重新做唯一特征和行为验证;
- 进程内存修改在重启微信后会自动恢复;
- 独立文件补丁已经完成离线核对,并已通过文件替换后的干净启动复验,效果与进程内存测试一致;
- 本文最终成果已独立发布在 FL0VEF/WeChat-AntiRecall。