返回逆向工程
逆向工程 / 发布 2026-08-13 11:36 / 更新 2026-08-17 17:55

WeChat 3.9.12.56 x86 防撤回逆向

复盘 WeChat 3.9.12.56 x86 防撤回特征的定位、动态验证、踩坑过程和最终字节。

逆向工程WeChatx32dbgMCP防撤回

前言与说明

先说明:我本人完全不懂逆向。 下面的内容不是我独立写出的专业逆向教程,而是 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.x x86 特征及项目的通用 ReplacePatterns。

历史记录的价值不是直接照抄偏移,而是告诉我:

  1. “只保留原消息”和“保留原消息同时显示提示”通常是两种不同特征;
  2. 带提示方案往往不是简单返回,而是改变某个状态或分支;
  3. 新版本必须重新确认实际执行链,旧地址只能帮助缩小范围。

第三步:建立真实撤回调用链

动态结果确认,收到撤回同步消息时会按 XML type 4 进入:

SyncMgr::ProcessRevokeMsg
RVA 0x17B2580

随后本次普通消息路径大致如下:

flowchart TD A[收到同步 XML type 4] --> B[ProcessRevokeMsg<br/>RVA 0x17B2580] B --> C[查询原消息<br/>0x17B27F2 → 0x1671490] C --> D[AddOrUpdateRevokeMsg<br/>0x17B2A57 → 0x12472D0] D --> E[SaveRevokeSysMsg<br/>0x1247467 → 0x1247480] E --> F[撤回系统消息 type 改为 0x2710<br/>RVA 0x124783F] F --> G{update / exist 标志} G -->|1| H[UPDATE<br/>RVA 0x124796F] G -->|0| I[INSERT<br/>RVA 0x12479CA] H --> J[发布 EVENT 0x3C5<br/>RVA 0x1247A1B] I --> J

这条链是后续判断的基础。源代码、字符串和函数名只能提供解释,真正决定结论的是实际运行时命中了哪条分支。

第四步:用自动日志点确认 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 调用。

实际效果:

  • 原消息保留;
  • 左侧会话列表摘要显示“撤回了一条消息”;
  • 聊天详情没有新增撤回提示行;
  • 微信正常响应。

这次结果非常关键。它证明:

  1. UPDATE 确实与原消息消失有关;
  2. 会话列表摘要可以由后面的 EVENT 更新;
  3. 聊天详情中的提示行不能只靠 EVENT,它需要一条真正保存到消息列表中的独立系统消息;
  4. 单纯跳过 UPDATE 只做了一半,还必须让 INSERT 正常执行。

换句话说,左侧会话摘要和右侧聊天详情不是完全相同的数据/UI 路径。

第七步:为什么“先本地删除再撤回”也没用

为了尝试自然触发 INSERT,做过一次实验:

  1. 另一设备发送唯一文本;
  2. 电脑端本地删除该消息;
  3. 另一设备再撤回;
  4. 同时观察原消息查询返回点和 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,它在更早的位置就决定了撤回系统消息对象按“更新模式”还是“新增模式”构造。

原始逻辑可以简化理解为:

flowchart LR A[读取 update/exist 标志到 CL] --> B{CL 是否为 0} B -->|否| C[按已有记录构造] C --> D[UPDATE 原消息] B -->|是| E[按新增记录构造] E --> F[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。这说明至少多次“卡死”是调试器把整个进程停住,而不是补丁本身造成死循环。

本次总结出的稳定做法是:

  1. 日志点最多设置 2~3 个低频软件点;
  2. 获取一次结果后立刻删除全部断点;
  3. 不再使用本环境中容易导致未响应的硬件断点;
  4. 最终 A/B 测试时保持零断点;
  5. 暂停不到一秒,只写一个候选字节组;
  6. 立即继续运行;
  7. 写完后 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 哈希一致;实际效果与进程内存测试一致:原消息/内容保留,聊天详情撤回提示和会话列表摘要正常,微信保持响应。

验证过程如何保证不是巧合

整个过程尽量遵守四个原则:

  1. 一次只改一个变量。例如测试 0x12474E2 时,旧候选 0x1247961 必须保持原始 74 0E。
  2. 使用唯一测试文本。每轮发送不同标识,避免把上一轮日志当成本轮结果。
  3. 先确认原字节和唯一命中。基址、版本或字节不一致就不写内存。
  4. 失败立即回到最早的不确定阶段。不因为某个地址命中过就直接认定它是撤回逻辑。

测试矩阵中的证据来源也做了区分:

结论 证据来源
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
  → 私聊、群聊和非文本回归通过

参考资料与项目

最后的边界说明

  • 该特征只对本文固定哈希的 WeChat 3.9.12.56 x86 负责;
  • 微信升级后必须重新定位或至少重新做唯一特征和行为验证;
  • 进程内存修改在重启微信后会自动恢复;
  • 独立文件补丁已经完成离线核对,并已通过文件替换后的干净启动复验,效果与进程内存测试一致;
  • 本文最终成果已独立发布在 FL0VEF/WeChat-AntiRecall。