diff --git a/README.md b/README.md
index 57481d7..fe3a1f8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# WeChatIntercept
-macOS 微信防撤回工具,持续更新,欢迎star。
+macOS 微信防撤回工具,持续更新,欢迎 star。
## 最新版本
@@ -8,123 +8,106 @@ macOS 微信防撤回工具,持续更新,欢迎star。
### 功能
-- 对方撤回的消息保留可见
-- 自己撤回正常工作
-- 撤回时弹出 macOS 系统通知(显示谁撤回了消息,含用户昵称)
-- 通知开关可随时切换
+- **对方撤回的消息保留可见**,弹出 macOS 系统通知告诉你撤回的消息内容:
+ - 文本消息:`拦截到「张三」撤回了一条消息:你好`
+ - 非文本:`拦截到「张三」撤回了一条消息:[图片] / [视频] / [文件] / [语音] / [红包] / ...`
+ - 拿不到原文时降级为:`拦截到「张三」撤回了一条消息`
- 微信动态更新后自动适配(特征码搜索)
-### 原理
-
-通过注入运行时 hook 动态库(`WeChatAntiRevoke.dylib`),拦截微信内部的 `isRevokeMessage()` 函数:
-
-- 对方撤回 → 返回 false(消息保留)+ 弹出通知
-- 自己撤回 → 返回 true(正常处理)
-
-通过读取当前登录用户 ID(完整字符串匹配),精确区分自己与对方。
-
-Hook 安装采用三级查找策略:
-1. **快速路径**:硬编码地址 + 完整 5 条指令特征码验证
-2. **特征码搜索**:扫描 `wechat.dylib __TEXT` 段,自动定位新版本函数地址
-3. **fallback**:4.1.9 dispatch slot 写入
-
### 适用范围
- macOS 微信 4.1.x 系列(4.1.9 / 4.1.10 已验证,更新版本依赖运行时自动寻址)
- Apple Silicon(arm64)及 Intel(x86_64)
-- macOS Sequoia / Sonoma / Ventura / Tahoe 等(自动处理 provenance 限制)
-
-### 使用
-
-```bash
-chmod +x patch.sh # 添加可执行权限
-./patch.sh # 安装防撤回
-./patch.sh openNotify # 开启撤回通知
-./patch.sh closeNotify # 关闭撤回通知
-./patch.sh --uninstall # 卸载
-./patch.sh --help # 帮助
-```
-
-首次运行可能需要约 30 秒(自动解除系统文件保护并重签名)。
-
-### 配置
-
-配置文件路径:`~/.config/antirevoke/config`
-
-```ini
-notify=1 # 1=开启撤回通知, 0=关闭
-```
-
-安装时默认开启通知,也可通过命令随时切换:
-
-```bash
-./patch.sh openNotify # 等同于设置 notify=1
-./patch.sh closeNotify # 等同于设置 notify=0
-```
-
-修改后立即生效,无需重启微信。
-
-#### 注意:需要给脚本编辑器打开通知权限!!!
-
-
+- macOS Sequoia / Sonoma / Ventura / Tahoe 等
### 依赖
macOS 系统自带工具,无需额外安装:
+
- clang(Xcode Command Line Tools)
- python3
- codesign
-- tar
+- lldb(仅消息原文功能需要;Xcode CLT 自带)
如未安装 Xcode Command Line Tools,运行:`xcode-select --install`
-### 调试
+---
+
+## 使用
+
+### 基础安装(防撤回)
```bash
-./patch.sh --debug # 调试模式(不安装 hook,仅签名允许 lldb attach)
-cat /tmp/antirevoke_debug.log # 查看运行时日志
+chmod +x patch.sh # 添加可执行权限
+./patch.sh # 安装防撤回
+./patch.sh --uninstall # 卸载防撤回
+./patch.sh --help # 查看所有命令
```
-### 微信版本更新后
+首次运行可能需要约 30 秒(自动解除系统文件保护并重签名)。
-微信支持动态更新(无需经过 App Store),更新后 build 号变化可能导致 hook 失效。
+### 可选:启用"撤回通知带原文"
+
+完成基础安装后,在终端前台运行消息监听,撤回通知就会带上原文。
+
+```bash
+./patch.sh --monitor # 前台运行消息监听,Ctrl+C 退出
+```
+
+监听运行期间,收到的消息会被缓存;对方撤回时通知自动展示消息原文。
+
+不运行此命令也不影响防撤回主功能,撤回通知降级为不带原文。
+
+> **已知限制**:部分群聊消息因内部对象结构差异,可能无法缓存原文,撤回时会降级为不带原文的通知。私聊消息和大部分群聊消息可正常获取原文。
+
+> **注意**:需要给脚本编辑器打开通知权限!
+>
+
+### 调试模式(仅开发用)
+
+```bash
+./patch.sh --debug # 不装 hook,仅签名允许 lldb attach
+./patch.sh --monitor # 前台运行消息监听
+cat /tmp/antirevoke_debug.log # 防撤回日志
+cat /tmp/wechat_msg_cache.tsv # 消息缓存
+```
+
+---
+
+## 微信版本更新后
+
+微信支持动态更新,更新后 build 号变化可能导致 hook 失效。
脚本会自动应对:
-1. **特征码动态搜索**:当硬编码地址失效时,自动扫描 `wechat.dylib` 的 `__TEXT` 段查找 `isRevokeMessage` 函数特征码。微信小版本更新(仅函数地址改变、实现不变)的情况下,**自动适配,无需任何操作**。
-
-2. **大版本校验放宽**:脚本只校验 `CFBundleShortVersionString` 是否为 `4.1.x`,不再依赖精确 build 号。新 build 号(如 4.1.11)也能正常安装,运行时通过特征码自动寻址。
-
-3. **失败提示**:如果特征码也匹配不到(微信改了实现),系统通知会弹出提示:
- - `WeChatIntercept 需更新` — 微信版本号变化(如 4.1.10 → 4.1.11),需要更新脚本
- - `WeChatIntercept 异常` — 已适配的 build 号但仍失败(极罕见)
-
-4. **消息对象偏移失效**:如果 `msg+0x18` 偏移读取的 sender ID 持续出现非可打印 ASCII 内容,累计达到阈值后会弹出 `"快去催 WeChatIntercept 作者更新适配"` 通知。日常对方撤回的多次调用不会误触发。
+- **特征码动态搜索**:硬编码地址失效时,自动扫描 `wechat.dylib` 的 `__TEXT` 段查找对应函数。微信小版本更新(仅函数地址改变、实现不变)的情况下,**自动适配,无需任何操作**。
+- **大版本校验放宽**:仅校验 `CFBundleShortVersionString` 是否为 `4.1.x`,不依赖精确 build 号。
+- **失败提示**:如果特征码也匹配不到(微信改了实现),系统通知会弹出提示用户更新脚本。
### 排查步骤
如果防撤回功能不生效:
1. 查看运行时日志:`cat /tmp/antirevoke_debug.log`
-2. 关键日志说明:
- - `[已适配]` — build 号在已知列表,安装应该成功
- - `[未适配]` — build 号未知,靠运行时自动寻址
- - `快速路径命中` — 硬编码地址有效,正常工作
- - `特征码搜索找到` — 自动找到新地址,正常工作(说明微信地址变了但实现没变)
+2. 关键日志:
+ - `[已适配]` / `[未适配]` — build 号识别情况
+ - `快速路径命中` / `特征码搜索找到` — hook 安装路径
- `hook 安装失败` — 需要更新脚本,请前往 GitHub 检查最新版本或提交 issue
3. 提交 issue 时附带:
- 微信版本:`defaults read /Applications/WeChat.app/Contents/Info.plist CFBundleShortVersionString` 与 `CFBundleVersion`
- 调试日志:`/tmp/antirevoke_debug.log`
-### 已知限制
+---
+
+## 已知限制
- **聊天框内无撤回提示**:由于微信 4.x 的架构限制(C++ 实现 + 符号 strip + 数据库加密),无法在聊天界面内插入系统消息。替代方案为 macOS 系统通知。
-- **为什么不能像旧版那样在聊天框内显示提示?**
+- **撤回通知原文需 monitor 在跑**:消息原文功能依赖前台 `./patch.sh --monitor` 监听消息。不运行时撤回通知降级为不带原文。
- 旧版微信 macOS(3.x)使用 Objective-C,可通过 Method Swizzling 调用内部消息插入 API。4.x 版本核心逻辑全部迁移到 C++(仅剩 65 个 ObjC 类,90MB+ 代码段,符号已 strip),撤回处理通过虚函数 + 加密数据库 + 协程调度完成,无法稳定地从外部构造调用链插入消息。
+- **monitor 运行时微信会被 lldb attach**:性能影响极小(每条消息触发约 1ms 断点回调)。如果不需要原文功能,不启动 monitor 即可。
-### 风险说明
+## 风险说明
1. 微信每次升级后,地址、结构体字段、运行时行为都可能变化,补丁可能立即失效。脚本内置特征码自动寻址机制可应对函数地址变化,但无法应对函数实现/消息对象结构的根本变化。
diff --git a/doc/reverse-engineering-guide.md b/doc/reverse-engineering-guide.md
new file mode 100644
index 0000000..6382b72
--- /dev/null
+++ b/doc/reverse-engineering-guide.md
@@ -0,0 +1,1137 @@
+# 微信 macOS 防撤回逆向工程指南
+
+本文档记录了适配微信 4.1.9 (CFBundleVersion: 268602) 和 4.1.10 (CFBundleVersion: 268824) 防撤回功能的完整逆向过程和最终方案,供后续版本升级时参考。
+
+---
+
+## 一、微信 4.1.9 架构概览
+
+### 技术栈
+
+| 层 | 技术 | 用途 |
+|---|---|---|
+| 核心逻辑 `wechat.dylib` (301MB) | **C++** 为主 + 少量 ObjC | 消息收发、撤回处理等 |
+| UI 层 | ObjC/AppKit | 原生 macOS 窗口 |
+| 小程序 `WeChatAppEx` (346MB) | Chromium (CEF) | 小程序引擎 |
+| 网络 | C++ (mmcronet = Chromium net) | HTTP/QUIC |
+
+### 关键特征
+
+- ObjC 类仅剩 **65 个**(对比旧版数千个)
+- 代码段超过 **90MB** 均为 C++,符号全部 strip
+- 核心逻辑通过 `dlopen` 在运行时加载 `Contents/Resources/wechat.dylib`
+- 没有 ObjC 的 `MessageService` 类,无法使用 Method Swizzling
+
+### 文件布局
+
+```
+WeChat.app/
+├── Contents/MacOS/WeChat # 5MB stub launcher
+├── Contents/Resources/wechat.dylib # 301MB 核心逻辑 (FAT: x86_64 + arm64)
+└── Contents/MacOS/WeChatAppEx.app/ # Chromium 子应用
+```
+
+---
+
+## 二、逆向过程
+
+### 2.1 定位 isRevokeMessage() 函数
+
+**方法**:在 wechat.dylib 的 arm64 slice 中搜索消息类型 10002 (0x2712) 的比较模式。
+
+```bash
+# 提取 arm64 slice
+lipo -thin arm64 /Applications/WeChat.app/Contents/Resources/wechat.dylib -output /tmp/wechat_arm64
+```
+
+**搜索特征**:函数比较 `[x0, #0xc] == 10002` 然后返回 bool。
+
+```python
+# 搜索: LDR W8,[X0,#0xc]; MOV W9,#0x2712; CMP W8,W9; CSET W0,EQ; RET
+pattern = struct.pack('
+
+ wxid_xxx
+ 333777384
+ 2651126785189248779
+
+
+
+```
+
+**可提取信息**:
+- `` 中的 CDATA 内容包含**用户昵称**(非 ID)
+- `` 是被撤回的原消息 ID(但无法用于获取原消息内容,因为 DB 加密)
+
+---
+
+## 九、最终方案总结
+
+### 架构
+
+```
+WeChat 主程序
+ ↓ LC_LOAD_DYLIB (注入)
+WeChatAntiRevoke.dylib
+ ↓ constructor (延迟1秒)
+ ↓ 找到 wechat.dylib slide
+ ↓ 写入 inline trampoline 到 isRevokeMessage 入口
+ ↓
+微信调用 isRevokeMessage() 时跳转到 hook 函数
+ ↓ 判断 sender ID == 自己?
+ ├─ 是 → 返回 1(放行撤回)
+ └─ 否 → 提取 XML replacemsg → 发通知 → 返回 0(阻止撤回)
+```
+
+### Hook 方式对比
+
+| 版本 | 方式 | 地址 |
+|------|------|------|
+| 4.1.9 arm64 | BSS dispatch slot 写入 | slot VA = `0x9301838` |
+| 4.1.10 arm64 | inline trampoline(20字节覆盖函数体) | func VA = `0x44FFE20` |
+| 4.1.9/4.1.10 x86_64 | inline trampoline(16字节) | `0x4AF08D0` / `0x4B4E9A0` |
+
+### 通知实现
+
+通过 `osascript` 执行 AppleScript 发送 macOS 本地通知:
+1. 从 `msg+0x130` 的 XML body 中提取 `` 内容(包含用户昵称)
+2. 对内容做 AppleScript 转义(英文双引号、反斜杠)
+3. 写入 `/tmp/antirevoke_notify.scpt`
+4. 异步执行 `osascript /tmp/antirevoke_notify.scpt`
+
+通知开关:通过 `~/.config/antirevoke/config` 配置文件中的 `notify=0/1` 控制。
+
+### 配置文件
+
+路径:`~/.config/antirevoke/config`
+
+```ini
+notify=1 # 1=开启撤回通知, 0=关闭
+```
+
+### 脚本命令
+
+```bash
+./patch.sh # 安装防撤回
+./patch.sh openNotify # 开启撤回通知
+./patch.sh closeNotify # 关闭撤回通知
+./patch.sh --debug # 调试模式(无 hook,允许 lldb attach)
+./patch.sh --uninstall # 卸载
+```
+
+---
+
+## 十、新版本快速适配指南
+
+当微信发布新版本时,按以下步骤适配:
+
+### Step 1:确认版本号
+
+```bash
+defaults read /Applications/WeChat.app/Contents/Info.plist CFBundleVersion
+defaults read /Applications/WeChat.app/Contents/Info.plist CFBundleShortVersionString
+```
+
+### Step 2:提取 arm64 slice
+
+```bash
+lipo -thin arm64 /Applications/WeChat.app/Contents/Resources/wechat.dylib -output /tmp/wechat_arm64
+```
+
+### Step 3:搜索 isRevokeMessage 新地址
+
+```python
+import struct
+with open('/tmp/wechat_arm64', 'rb') as f:
+ data = f.read()
+
+# 搜索特征:LDR W8,[X0,#0xc]; MOV W9,#0x2712; CMP W8,W9; CSET W0,EQ; RET
+pattern = struct.pack(' -c '(*(int*)($x0+0xc) == 0x2712)'
+c
+# 对方撤回后:
+memory read $x0 --count 512
+memory read $x0+0x130 --count 24 --format x
+# 读取 XML 指针内容
+```
+
+### Step 7:验证用户 ID 获取
+
+确认 `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/login/` 目录结构是否变化。
+
+### Step 8:更新 patch.sh 中的地址常量
+
+```c
+static const uintptr_t k_NEW_VERSION_FuncVA_arm64 = 0x新地址;
+static const uintptr_t k_NEW_VERSION_FuncVA_x86_64 = 0x新地址;
+```
+
+### Step 9:测试
+
+1. 对方撤回 → 消息保留 + 通知弹出
+2. 自己撤回 → 正常处理
+3. 正常收发消息 → 不受影响
+
+### Step 10:多设备验证
+
+在不同用户设备上验证(特别注意):
+- macOS Sequoia 的 provenance 问题(需要 entitlements 绕过)
+- `Frameworks/wechat.dylib` stub 的 slide 干扰问题
+- 用户 ID 非 "wxid" 开头的兼容性
+
+---
+
+## 十一、自动寻址机制(应对微信动态更新)
+
+### 11.1 背景
+
+微信支持动态热更新(无需经过 App Store),更新后会替换 `wechat.dylib`,导致:
+- 函数地址(`isRevokeMessage` 的 VA)变化
+- 硬编码地址失效,hook 不生效
+- 用户在不知情的情况下"防撤回功能突然不工作"
+
+### 11.2 三级查找策略
+
+`hook_init` 中按以下顺序查找 `isRevokeMessage` 函数:
+
+```
+1. 快速路径(硬编码地址 + 完整特征码验证)
+ ↓ 失败
+2. 特征码搜索(扫描整个 __TEXT 段)
+ ↓ 失败
+3. 4.1.9 slot fallback(仅 arm64)
+ ↓ 失败
+4. 弹出系统通知告知用户
+```
+
+#### 第一级:快速路径
+
+```c
+uintptr_t func_4110 = slide + k4110_FuncVA_arm64; // 硬编码 0x44FFE20
+uint32_t head_insn = *(volatile uint32_t *)func_4110;
+
+if (head_insn == 0xB9400C08u) {
+ // 进一步验证完整 5 条指令特征码(避免单指令误判)
+ uint32_t *p = (uint32_t *)func_4110;
+ if (p[1] == 0x5284E249u && p[2] == 0x6B09011Fu &&
+ p[3] == 0x1A9F17E0u && p[4] == 0xD65F03C0u) {
+ func_addr = func_4110; // 命中,直接使用
+ }
+}
+```
+
+**关键点**:必须验证完整 5 条指令特征码,单看第一条 `LDR W8, [X0, #0xC]` 会误判(其他函数也可能以这条指令开头)。
+
+#### 第二级:特征码搜索
+
+通过 Mach-O `LC_SEGMENT_64` load command 解析 `__TEXT` 段范围(约 145MB),然后逐 4 字节扫描特征码。
+
+**arm64 特征码**(5 条指令,20 字节):
+```
+0xB9400C08 LDR W8, [X0, #0xC]
+0x5284E249 MOV W9, #0x2712
+0x6B09011F CMP W8, W9
+0x1A9F17E0 CSET W0, EQ
+0xD65F03C0 RET
+```
+
+**x86_64 特征码**(16 字节函数体):
+```
+55 48 89 E5 ; PUSH RBP; MOV RBP, RSP
+81 7F 0C 12 27 00 00 ; CMP DWORD PTR [RDI+0xC], 0x2712
+0F 94 C0 ; SETE AL
+5D C3 ; POP RBP; RET
+```
+
+**性能**:约 145MB / 4 = 36M 次比较,启动时一次性开销约 100-500ms。
+
+#### 第三级:4.1.9 slot fallback
+
+如果是 4.1.9 版本(函数有 dispatch slot 但没有完整特征码),尝试写入 `0x9301838` 的 BSS slot。
+
+### 11.3 版本检测与失败提示
+
+```c
+// 已知支持的 build
+static const char *kKnownBuilds[] = { "268602", "268824", NULL };
+```
+
+`hook_init` 启动时读取 `Info.plist` 的 `CFBundleVersion`,安装失败时根据是否在已知列表中弹出不同通知:
+
+| 场景 | 通知文案 |
+|------|---------|
+| 未知 build(如 4.1.11) | `WeChatIntercept 需更新:微信版本 4.1.11 (build XXX) 未适配,防撤回功能已失效。请前往 GitHub 获取最新脚本` |
+| 已知 build 但失败 | `WeChatIntercept 异常:已知版本 4.1.10 (268824) hook 安装失败,请查看 /tmp/antirevoke_debug.log` |
+
+### 11.4 消息对象偏移失效检测
+
+`hook_isRevokeMessage` 中增加 `is_valid_sender()` 检查 `msg+0x18` 的内容:
+
+```c
+static _Bool is_valid_sender(const char *s) {
+ if (s[0] == '\0') return 1; // 空字符串合法
+ int len = 0;
+ for (int i = 0; i < 32; i++) {
+ unsigned char c = (unsigned char)s[i];
+ if (c == '\0') { len = i; break; }
+ if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '_' || c == '-')) {
+ return 0;
+ }
+ }
+ return (len >= 3);
+}
+```
+
+若 `+0x18` 偏移失效(读到非 ASCII 数据),通知用户偏移异常 + 显示首 16 字节 hex,并保守阻止撤回。
+
+### 11.5 测试自动寻址是否生效
+
+#### 方案 A:模拟"地址失效"场景(验证特征码搜索)
+
+修改 `patch.sh` 中的硬编码地址为错误值:
+```c
+static const uintptr_t k4110_FuncVA_arm64 = 0x4500000; // 故意改错
+```
+
+执行 `./patch.sh` 安装后查看日志:
+```bash
+cat /tmp/antirevoke_debug.log
+```
+
+**预期日志**:
+```
+[AntiRevoke] 微信版本: 4.1.10 (build 268824) [已适配]
+[AntiRevoke] slide=0x... __TEXT=[0x..., +0x...) found=1
+[AntiRevoke] 快速路径未命中,开始特征码搜索...
+[AntiRevoke] 特征码搜索找到: 0x... (offset 0x44FFE20)
+[AntiRevoke] arm64 trampoline 安装成功
+```
+
+**验证点**:
+- 看到 `快速路径未命中` 和 `特征码搜索找到`
+- 找到的 offset 与原始硬编码 `0x44FFE20` 一致
+- 防撤回功能仍然正常工作
+
+测试完成后**记得恢复 `patch.sh`**。
+
+#### 方案 B:模拟"完全失败"场景(验证失败通知)
+
+把硬编码地址和特征码都改错:
+```c
+static const uintptr_t k4110_FuncVA_arm64 = 0x4500000;
+// scan_isRevokeMessage_arm64 中的 pattern 第一项改为 0xDEADBEEF
+```
+
+**预期**:
+- 弹出系统通知 `"WeChatIntercept 异常:已知版本 4.1.10 (268824) hook 安装失败..."`
+- 防撤回不生效
+- 日志显示 `ERROR: hook 安装失败`
+
+#### 方案 C:模拟"未知 build"场景(验证更新提示)
+
+修改 `kKnownBuilds`:
+```c
+static const char *kKnownBuilds[] = { "268602", NULL }; // 移除当前 build
+```
+
+同时把硬编码地址和特征码都改错。
+
+**预期通知**:
+```
+WeChatIntercept 需更新
+微信版本 4.1.10 (build 268824) 未适配,防撤回功能已失效。请前往 GitHub 获取最新脚本
+```
+
+#### 测试检查清单
+
+| 检查项 | 命令 |
+|--------|------|
+| Hook 是否安装成功 | `cat /tmp/antirevoke_debug.log \| grep "trampoline 安装成功\|hook 安装失败"` |
+| 走的哪条路径 | 日志中 `快速路径命中` / `特征码搜索找到` / `slot 写入` 关键字 |
+| 微信版本读取 | 日志首行 `微信版本: x.x.x (build XXX) [已适配/未适配]` |
+| 通知是否弹出 | 屏幕右上角观察 |
+| 防撤回是否生效 | 让对方撤回消息,看是否保留 |
+
+### 11.6 维护建议
+
+当微信发布新版本:
+
+1. **小升级(实现不变,仅地址变化)**:脚本会自动适配,无需任何操作
+2. **小升级(已知版本 build 变化)**:用户提交 issue 后,将新 build 加入 `kKnownBuilds[]`
+3. **大升级(实现变化,特征码失效)**:
+ - 提取 arm64 slice,搜索新的特征码
+ - 更新 `scan_isRevokeMessage_arm64` 中的 pattern
+ - 更新硬编码地址
+ - 同时验证消息对象偏移(`+0x18`、`+0x130`)是否变化
+
+### 11.7 设计原则
+
+1. **不依赖单一硬编码**:地址、特征码、版本号是三层独立的依赖
+2. **失败要可见**:用户必须知道 hook 失效了,而不是默默地不工作
+3. **保守优于激进**:偏移异常时返回 0(阻止撤回),不冒险解析未知数据
+4. **去重通知**:用 `static _Bool warned` 避免通知风暴
+
+---
+
+## 十二、参考项目
+
+- [WeChat-Anti-Revoke-For-Mac](https://github.com/lerry903/WeChat-Anti-Revoke-For-Mac) - 适配 v37342 (x86_64),使用函数指针 slot 替换 + ObjC Method Swizzling
+- [WeChatTweak](https://github.com/sunnyyoung/WeChatTweak) - 旧版静态二进制 patch 方案
+
+---
+
+## 十三、消息原文缓存(撤回通知带原文)
+
+### 13.1 背景
+
+防撤回 hook 拦截撤回行为后,仅能从撤回 XML 的 CDATA 拿到 `XX 撤回了一条消息`,**拿不到被撤回消息的原文**。要在通知里显示原文,需要在消息到达时就把它存下来,撤回到来时按 ID 反查。
+
+直接改 dylib 实现难度高(见下文 13.5),最终采用 **lldb monitor + 文件 IPC** 方案。
+
+### 13.2 总体架构
+
+```
+┌─────────────┐ 断点回调 ┌────────────────────┐
+│ 微信进程 │ ◄──────────────── │ lldb (后台 daemon) │
+│ wechat.dylib│ │ wechat_msg_monitor │
+└─────┬───────┘ └─────────┬──────────┘
+ │ 收到消息 │ 写
+ │ 命中 257712 +20 断点 ▼
+ │ /tmp/wechat_msg_cache.tsv
+ │ 撤回到来 ▲
+ ▼ │ 读
+┌──────────────────────┐ │
+│ WeChatAntiRevoke.dylib│ ──────────────────┘
+│ hook_isRevokeMessage │
+└──────────────────────┘
+ │ 拼通知
+ ▼
+ macOS 通知中心
+```
+
+### 13.3 消息对象(CMessageWrap)字段布局
+
+通过 lldb 实测验证(4.1.10),消息对象关键字段:
+
+| 偏移 | 字段 | 类型 | 说明 |
+|------|------|------|------|
+| `+0x00` | vtable | ptr | C++ 虚函数表 |
+| `+0x08` | FromWrapper | ptr | 包装对象,wrapper+0x08 → wxid 字符串 |
+| `+0x10` | subtype vtable | ptr | 消息类型标识 |
+| `+0x40` | ContentWrapper | ptr | 包装对象,wrapper+0x00 → 展示文本字符串 |
+| `+0x48` | CreateTime | int32 | Unix 时间戳 |
+| `+0x4c` | MsgLocalID | int32 | 本地消息 ID |
+| `+0x50` | MsgSvrID | int64 | **服务器消息 ID(撤回 XML 里的 newmsgid)** |
+| `+0x70` | (某字段) | int64 | 257712 函数会读取此处写入返回值对象 |
+
+**展示文本格式**:
+- 文本:`<昵称> : <正文>`(私聊群聊都是这格式)
+- 图片/视频/文件:`<昵称>在群聊中发了一张图片` 等描述模板
+
+### 13.4 Hook 点:unnamed_symbol257712
+
+通过分析 `isRevokeMessage` 的调用栈(见 §2.1),在 `frame #4` 找到 CMessageWrap 的某个虚方法 `unnamed_symbol257712`:
+
+```
++0x00: stp x20,x19,[sp,#-0x20]! a9be4ff4 ; prologue
++0x04: stp x29,x30,[sp,#0x10] a9017bfd
++0x08: add x29,sp,#0x10 910043fd
++0x0c: mov x19,x1 aa0103f3 ; x19 = msg
++0x10: bl 257706 <相对> ; 调用 257706 处理消息
++0x14: ldr x8,[x19,#0x70] f9403a68 ; 读 msg+0x70
++0x18: str x8,[x0,#0x100] ... ; 写返回值+0x100
++0x1c: epilogue
++0x24: ret
+```
+
+**关键观察**:
+- `+0x10` 处的 `bl 257706` **必须先执行**,因为它会填充 msg 对象内的字段(包括 +0x40 的 content 指针)
+- `+0x14` 时 x19 仍持有 msg 指针(callee-saved),content 字段已就绪
+
+**断点位置选择**:函数入口 + 20 字节(即 `+0x14`,`ldr` 指令处),保证 content 已填充。
+
+### 13.5 失败方案:dylib 内 trampoline
+
+最初尝试用 inline trampoline 在 dylib 内 hook 257712,**失败**:
+
+```c
+void *hook_msgWrap_257712(void *out_obj, void *msg) {
+ void *new_x0 = g_orig_257706(msg); // ← 这里崩溃
+ *(uint64_t*)(new_x0 + 0x100) = *(uint64_t*)(msg + 0x70);
+ cache_put(...);
+ return new_x0;
+}
+```
+
+崩溃栈:
+```
+0 libc++ std::basic_string::operator= FAR=0x17
+1 wechat.dylib +76121620
+2 WeChatAntiRevoke.dylib hook_msgWrap_257712 + 84
+```
+
+**原因**:257706 是 C++ 方法,对调用方栈帧布局有特殊假设(可能通过 `[fp, #-X]` 访问调用方局部变量)。我们的 C hook 函数 prologue 是 clang 自动生成,栈帧不一致,257706 内部读取局部变量时崩溃。
+
+要修复需要写 naked-asm trampoline 完整保留原函数语义,复杂度高。改用 lldb monitor 方案。
+
+### 13.6 lldb monitor 寻址
+
+完全自动寻址,**无任何硬编码地址**:
+
+1. **定位 wechat.dylib**:用 lldb SBTarget API 查模块,**优先选 `/Resources/wechat.dylib`**(核心库 ~140MB),过滤掉 `/Frameworks/wechat.dylib`(stub ~16KB)
+
+2. **特征码扫描**:在 `__TEXT` 段顺序扫描,匹配模式:
+ ```
+ PREFIX (16字节):f44fbea9 fd7b01a9 fd430091 f30301aa
+ GAP ( 4字节):通配(bl 相对跳转,ASLR 后字节会变)
+ SUFFIX ( 4字节):683a40f9
+ ```
+ 实测在 75MB __TEXT 中:PREFIX 命中 4058 次,配合 SUFFIX 校验后唯一确定 1 处。
+
+3. **断点位置**:函数入口 + 20
+
+4. **字段读取**:从 x19 寄存器(callee-saved,hook 时机已是 `mov x19, x1` 之后)取消息对象指针。
+
+### 13.7 文件 IPC 协议
+
+**路径**:`/tmp/wechat_msg_cache.tsv`
+
+**行格式**:
+```
+\t\t\n
+```
+
+**字段清洗**:
+- `\t \n \r` 替换为空格
+- content 剥掉发言人前缀(找首个 ` : ` 截左侧)
+- 限制 from ≤ 63 字节,content ≤ 511 字节
+
+**写端**(`monitor/wechat_msg_monitor.py`):
+- 内存中保留最近 500 行
+- 每次更新整体重写:先写 `*.tmp`,再 `os.replace()` → 原子替换
+- 写频率 = 收消息频率(频繁但每次 < 50KB I/O)
+
+**读端**(dylib `hook_isRevokeMessage`):
+- 从撤回 XML 抽 ``
+- `fopen` + `fgets` 顺序扫描整个文件
+- 命中后**继续扫到末尾**取最后一次写入(兼容覆盖更新)
+- 频率极低(仅撤回时),无需 mmap/索引优化
+
+### 13.8 通知文案处理
+
+dylib 端两步处理:
+
+1. **昵称提取**:从 XML CDATA `"Macanzy" 撤回了一条消息` 抽出纯昵称
+ - 找 `撤回了` 截左侧
+ - 去前后空格
+ - 剥两端英文双引号(微信会给昵称加引号)
+
+2. **消息类型识别**:缓存里的非文本消息内容是描述模板(`XX在群聊中发了一张图片`),用 `strstr` 匹配关键短语转占位符:
+
+ | 模板片段 | 占位符 |
+ |---------|--------|
+ | `发了一张图片` | `[图片]` |
+ | `发了一段视频` | `[视频]` |
+ | `发了一个文件` | `[文件]` |
+ | `发了一段语音` / `发了一条语音消息` | `[语音]` |
+ | `发了一个表情` | `[表情]` |
+ | `发了一个视频号` | `[视频号]` |
+ | `发了一张名片` | `[名片]` |
+ | `发了一个位置` | `[位置]` |
+ | `发了一个红包` | `[红包]` |
+ | `发了一个链接` | `[链接]` |
+ | `发了一个小程序` | `[小程序]` |
+
+3. **最终文案**:
+ - 命中:`拦截到「Macanzy」撤回了一条消息:你好`
+ - 未命中:`拦截到「Macanzy」撤回了一条消息`
+
+### 13.9 LaunchAgent 守护
+
+**daemon 脚本**:`monitor/monitor_daemon.sh`
+- while 循环每 5 秒检查微信进程
+- 微信启动 → 后台 `lldb -b` attach + 加载 Python 脚本
+- 微信退出 → kill lldb,等待
+- 微信重启 → 重新 attach
+
+**部署位置**:必须放在 TCC 安全位置
+- ✅ `~/.local/share/wechatintercept/`
+- ❌ `~/Desktop/`(launchd 拉起的进程读 Desktop 会得到 `Operation not permitted`)
+
+**LaunchAgent plist 关键项**:
+```xml
+RunAtLoad
+KeepAlive
+ThrottleInterval10
+```
+
+### 13.10 entitlements 要求
+
+为让 lldb 能 attach 微信,签名时必须带 `get-task-allow`:
+
+```xml
+com.apple.security.cs.disable-library-validation
+com.apple.security.cs.allow-unsigned-executable-memory
+com.apple.security.get-task-allow
+```
+
+`patch.sh` 的正常签名流程已自动包含这一项。
+
+### 13.11 维护建议
+
+monitor 失效时:
+1. **特征码搜不到**:4.1.x 微信改了 257712 的实现 → 用 lldb 重新反汇编 → 更新 `wechat_msg_monitor.py` 第 32-33 行的 PATTERN
+2. **拿到 content 但是乱码**:消息对象偏移变了 → 用 `wx_monitor_debug_on` + `wx_scan_strings ` 重新探测 +0x40 / +0x08
+3. **lldb attach 失败**:检查 `codesign -d --entitlements - $WECHAT_BIN` 是否有 `get-task-allow`
+
+### 13.12 设计原则
+
+1. **monitor 是可选增强**:缓存不存在时通知降级为旧文案,防撤回主功能不受影响
+2. **进程隔离**:lldb 异常崩溃只影响 monitor,不影响微信运行
+3. **数据生命周期**:`/tmp` 重启清空,符合"消息缓存只对当次会话有意义"的语义
+4. **零硬编码**:版本兼容性靠特征码,不靠地址表
+
+### 13.13 单文件整合
+
+最终交付物仅 `patch.sh` 一个脚本(+ README.md 文档)。
+
+monitor 相关的三个脚本(`wechat_msg_monitor.py`、`monitor_daemon.sh`)以 heredoc 形式内嵌在 `patch.sh` 中:
+
+```bash
+deploy_monitor_files() {
+ mkdir -p "$MONITOR_INSTALL_DIR"
+ cat > "$MONITOR_INSTALL_DIR/wechat_msg_monitor.py" << 'MONITOR_PY'
+ ...内嵌 Python 源码...
+ MONITOR_PY
+ cat > "$MONITOR_INSTALL_DIR/monitor_daemon.sh" << 'MONITOR_DAEMON'
+ ...内嵌 daemon 脚本...
+ MONITOR_DAEMON
+}
+```
+
+运行 `./patch.sh --monitor-install` 时:
+1. 调用 `deploy_monitor_files()` 释放脚本到 `~/.local/share/wechatintercept/`(TCC 安全路径)
+2. 写 LaunchAgent plist 到 `~/Library/LaunchAgents/`
+3. `launchctl load` 注册
+
+命令列表:
+
+| 命令 | 作用 |
+|------|------|
+| `./patch.sh` | 安装防撤回 |
+| `./patch.sh --monitor-install` | 安装消息监听(撤回原文) |
+| `./patch.sh --monitor-uninstall` | 卸载消息监听 |
+| `./patch.sh --monitor-status` | 查看 daemon 状态 |
+| `./patch.sh --monitor` | 前台运行(调试用) |
+| `./patch.sh --uninstall` | 卸载防撤回 |
+
+好处:
+- 用户只需拿到一个文件
+- 不存在"文件相对路径找不到"的问题
+- `--monitor-install` 后再改源码需要重新 `--monitor-install` 才生效(等效于 make install)
+
+### 13.14 +0x40 双重解引用(最终方案)
+
+最终确认的 content 读取路径(4.1.10,a244573118 账号登录):
+
+```
+msg_obj + 0x40 → ptr(8字节指针值)
+ptr → std::string 结构体(24字节):[data_ptr][size][cap|flag]
+data_ptr → 实际 UTF-8 文本("昵称 : 正文" 格式)
+```
+
+**不是之前以为的 "wrapper 对象 +0x00"**,而是**指向 std::string 结构体本身**。
+
+std::string 布局(libc++ arm64):
+- 长字符串:`data_ptr > 0x1_0000_0000` 且 `size > 0 < 4096` → 从 data_ptr 读 size 字节
+- SSO 短字符串:`data_ptr < 0x1_0000_0000`(不像指针)→ 前 22 字节就是数据,找 `\0` 截断
+
+### 13.15 路径 B 问题(部分群聊不覆盖)
+
+测试发现**同一版本(4.1.10)不同群**的消息走不同代码路径:
+
+| 群 | flag2 | +0x40 指向 | content |
+|---|---|---|---|
+| `44001654746@chatroom` | 1 | 堆上 std::string(有内容) | ✅ |
+| `wxid_oofpkofuv87f12` (私聊) | 1 | 堆上 std::string(有内容) | ✅ |
+| `34757577531@chatroom` | 1 | BSS 段全局空 string(dp=0, sz=0) | ❌ |
+| `45680706775@chatroom` | 2 | 代码段地址(非堆) | ❌ |
+
+路径 B 特征:
+- `ptr40 = 0x123xxxxxxx`(BSS/DATA 段地址,不是堆 `0x4xxxxxxxx` 或 `0x0axxxxxxxx`)
+- std::string 内容永远为空(dp=0, sz=0)
+- 三层指针扫描(0x400 字节 + 每指针下钻 0x100)也找不到正文
+- 手动在 257712 入口下断点**命中不了**——说明这些群的消息**不经过 257712 函数**
+
+结论:257712 只被**部分消息处理路径**调用。路径 B 的消息虽然 monitor 能看到(因为断点还是命中了——可能是另一条无关消息触发的),但其对象内不含 content。
+
+**后续方向**(未实施):
+1. 找一个所有消息都必经的更上层函数
+2. 或者 hook 数据库写入点(所有消息最终入库时 content 一定已就绪)
+3. 或者用 naked-asm trampoline 在 dylib 内 hook,避免 lldb batch 模式时序问题
+
+### 13.16 最终交付状态
+
+| 功能 | 覆盖率 | 说明 |
+|------|--------|------|
+| 防撤回(对方撤回保留可见) | 100% | inline trampoline + 特征码自动寻址 |
+| 撤回通知 | 100% | 始终弹出 |
+| 通知带消息原文 | ~70% | 私聊 + 部分群聊;依赖 `--monitor` 前台运行 |
+| 通知不带原文(降级) | 剩余 ~30% | 部分群聊路径 B + monitor 未运行时 |
diff --git a/patch.sh b/patch.sh
index 587e5f0..fa44d99 100755
--- a/patch.sh
+++ b/patch.sh
@@ -1,30 +1,9 @@
#!/bin/bash
-#
-# ============================================================
# 微信防撤回一键安装脚本
-# ============================================================
-#
-# 适用版本: 微信 4.1.9 (CFBundleVersion: 268602)
-# 微信 4.1.10 (CFBundleVersion: 268824)
-# 适用平台: macOS (Apple Silicon + Intel)
-# 依赖工具: clang, codesign, python3 (macOS 系统自带)
-#
-# 使用方法:
-# chmod +x patch.sh
-# ./patch.sh # 安装防撤回
-# ./patch.sh --uninstall # 卸载(恢复原始微信)
-#
-# 原理:
-# 通过 DYLD 注入一个运行时 hook 动态库,
-# 拦截微信的 isRevokeMessage() 函数,
-# 区分对方撤回和自己撤回:
-# - 对方撤回 → 返回 false(消息保留不被删除)
-# - 自己撤回 → 返回 true(正常处理,不会闪退)
-#
-# 4.1.9: 通过写入内建 hook dispatch slot (BSS 区域) 实现
-# 4.1.10: dispatch slot 机制已移除,改用 inline trampoline patch
-#
-# ============================================================
+# 适用:微信 4.1.x (Apple Silicon + Intel)
+# 依赖:clang / codesign / python3 (macOS 自带)
+# 用法:./patch.sh [--monitor-install|--uninstall|--debug|--help]
+# 详细原理 / 版本适配指南见 doc/reverse-engineering-guide.md
set -e
@@ -125,7 +104,6 @@ compile_dylib() {
#import
#import
-// ── 日志 ─────────────────────────────────────────────────────
static FILE *g_logFile = NULL;
static void log_open(void) {
@@ -136,27 +114,24 @@ static void log_open(void) {
if (g_logFile) { fprintf(g_logFile, "[AntiRevoke] " fmt "\n", ##__VA_ARGS__); fflush(g_logFile); } \
} while(0)
-// ── 常量 ─────────────────────────────────────────────────────
+// 注意:Resources/wechat.dylib 是核心库(~140MB),Frameworks/ 下是 stub(~16KB),不能 hook 错
static const char *kDylibSuffix_Resources = "Resources/wechat.dylib";
static const char *kDylibSuffix_Frameworks = "Frameworks/wechat.dylib";
-static const int32_t kRevokeType = 0x2712; // 10002
+static const int32_t kRevokeType = 0x2712; // isRevokeMessage 比较的 MsgType 常量
-// 配置文件路径:~/.config/antirevoke/config
-// 格式:每行一个 key=value
-// notify=1 开启通知(默认)
-// notify=0 关闭通知
-static char g_config_path[512] = {0};
-// ── 版本地址表 ───────────────────────────────────────────────
+
+// 已知 build 的硬编码地址(特征码搜索失败时的兜底)
static const uintptr_t k419_SlotVA_arm64 = 0x9301838;
static const uintptr_t k4110_FuncVA_arm64 = 0x44FFE20;
static const uintptr_t k4110_FuncVA_x86_64 = 0x4B4E9A0;
static const uintptr_t k419_FuncVA_x86_64 = 0x4AF08D0;
-// ── 获取当前登录用户 ID ──────────────────────────────────────
+// 当前登录用户 wxid,用于区分"自己撤回"vs"对方撤回"
static char g_my_id[64] = {0};
static _Bool g_my_id_loaded = 0;
+// 通过取 ~/Library/Containers/.../app_data/login/ 下最新修改的目录名判定
static void load_my_user_id(void) {
if (g_my_id_loaded) return;
g_my_id_loaded = 1;
@@ -202,31 +177,10 @@ static void load_my_user_id(void) {
}
}
-// ── 配置 ─────────────────────────────────────────────────────
-static void init_config_path(void) {
- const char *home = getenv("HOME");
- if (home) {
- snprintf(g_config_path, sizeof(g_config_path), "%s/.config/antirevoke/config", home);
- }
-}
-static _Bool is_notify_enabled(void) {
- if (g_config_path[0] == '\0') return 1; // 配置路径未初始化,默认开启
- FILE *f = fopen(g_config_path, "r");
- if (!f) return 1; // 配置文件不存在,默认开启
- char line[128];
- _Bool enabled = 1;
- while (fgets(line, sizeof(line), f)) {
- if (strncmp(line, "notify=0", 8) == 0) { enabled = 0; break; }
- }
- fclose(f);
- return enabled;
-}
static void send_notification(const char *text) {
- if (!is_notify_enabled()) return;
-
- // 对英文双引号和反斜杠做转义
+ // osascript 对 " 和 \ 敏感,必须转义
char *escaped = (char *)malloc(1024);
if (!escaped) return;
int j = 0;
@@ -247,19 +201,19 @@ static void send_notification(const char *text) {
});
}
-// ── 检查 sender 偏移是否仍有效 ───────────────────────────────
-// 仅检查前 4 字节是否为可打印 ASCII(微信 ID 总以可打印字符开头)
-// 缩小检查范围避免误判撤回流程中的二次调用(其 sender 可能是 std::string 元数据)
+// 微信小版本升级 sender 偏移可能漂移;仅检查前 4 字节是否可打印 ASCII
+// 失效时静默放行(return 1),不影响微信内部撤回流程;阈值后弹一次催更新通知
static _Bool is_valid_sender(const char *s) {
- if (s[0] == '\0') return 1; // 空字符串 = 自己撤回确认
+ if (s[0] == '\0') return 1; // 空 = 自己撤回的内部回调
for (int i = 0; i < 4; i++) {
unsigned char c = (unsigned char)s[i];
- if (c < 0x20 || c > 0x7E) return 0; // 非可打印字符
+ if (c < 0x20 || c > 0x7E) return 0;
}
return 1;
}
-// ── hook 函数 ────────────────────────────────────────────────
+// 入口:被微信 isRevokeMessage 替换。
+// 返回 1 = 该消息是撤回(按原行为处理);返回 0 = 阻止微信删除消息
__attribute__((visibility("default")))
_Bool hook_isRevokeMessage(void *msg) {
if (msg == NULL) return 0;
@@ -271,13 +225,8 @@ _Bool hook_isRevokeMessage(void *msg) {
const char *sender = (const char *)((uint8_t *)msg + 0x18);
- // 检查 sender 偏移是否仍有效(前 4 字节必须可打印 ASCII)
- // 失效时静默放行(return 1),避免影响撤回流程的内部状态
- // 真正的偏移失效会持续触发,达到阈值时弹一次"催更新"通知
if (!is_valid_sender(sender)) {
ARLOG("WARN: sender 区域非可打印 ASCII,跳过此次调用");
-
- // 累计失效次数,达到阈值时弹通知催更新(仅一次)
static int g_invalid_count = 0;
static _Bool g_warned = 0;
g_invalid_count++;
@@ -294,24 +243,24 @@ _Bool hook_isRevokeMessage(void *msg) {
});
}
}
- return 1; // 静默放行,不影响业务流程
+ return 1;
}
- // 自己撤回 → 放行
+ // 自己撤回 → 放行(让微信正常处理)
if (sender[0] == '\0') return 1;
if (g_my_id[0] != '\0' && strncmp(sender, g_my_id, strlen(g_my_id)) == 0) return 1;
// 对方撤回 → 阻止
ARLOG("拦截: %.20s", sender);
- // 提取通知内容
+ // 从 msg+0x130 (ptr) / +0x138 (len) 读撤回 XML
char notify_text[256] = {0};
#if defined(__arm64__) || defined(__aarch64__)
- // arm64:从 msg+0x130 读取 XML body,提取 replacemsg(含用户昵称)
uint64_t xml_ptr = *(uint64_t *)((uint8_t *)msg + 0x130);
uint64_t xml_len = *(uint64_t *)((uint8_t *)msg + 0x138);
if (xml_ptr > 0x100000000ULL && xml_len > 0 && xml_len < 4096) {
+ // CDATA 内容形如 "Macanzy" 撤回了一条消息
const char *xml_body = (const char *)xml_ptr;
const char *cs = strstr(xml_body, "") : NULL;
@@ -326,11 +275,91 @@ _Bool hook_isRevokeMessage(void *msg) {
}
#endif
- char content[512] = {0};
- if (notify_text[0] != '\0')
- snprintf(content, sizeof(content), "拦截到%s", notify_text);
- else
- snprintf(content, sizeof(content), "拦截到 %s 撤回了一条消息", sender);
+ // 反查 lldb monitor 写入的消息缓存(/tmp/wechat_msg_cache.tsv)
+ char orig_content[512] = {0};
+ _Bool has_orig = 0;
+#if defined(__arm64__) || defined(__aarch64__)
+ if (xml_ptr > 0x100000000ULL && xml_len > 0 && xml_len < 4096) {
+ const char *xml_body = (const char *)xml_ptr;
+ const char *p = strstr(xml_body, "");
+ uint64_t newmsgid = 0;
+ if (p) {
+ p += 10;
+ int digits = 0;
+ while (*p >= '0' && *p <= '9' && digits < 20) {
+ newmsgid = newmsgid * 10 + (uint64_t)(*p - '0');
+ p++; digits++;
+ }
+ if (digits == 0) newmsgid = 0;
+ }
+ if (newmsgid != 0) {
+ FILE *cf = fopen("/tmp/wechat_msg_cache.tsv", "r");
+ if (cf) {
+ char line[1024];
+ while (fgets(line, sizeof(line), cf)) {
+ char *t1 = strchr(line, '\t');
+ if (!t1) continue;
+ *t1 = '\0';
+ uint64_t row_svrid = 0;
+ int d2 = 0;
+ for (const char *q = line; *q >= '0' && *q <= '9' && d2 < 20; q++, d2++)
+ row_svrid = row_svrid * 10 + (uint64_t)(*q - '0');
+ if (d2 == 0 || row_svrid != newmsgid) continue;
+ char *t2 = strchr(t1 + 1, '\t');
+ if (!t2) continue;
+ char *nl = strchr(t2 + 1, '\n');
+ if (nl) *nl = '\0';
+ strncpy(orig_content, t2 + 1, sizeof(orig_content) - 1);
+ has_orig = (orig_content[0] != '\0');
+ }
+ fclose(cf);
+ }
+ ARLOG("撤回反查: svrid=%llu %s",
+ (unsigned long long)newmsgid, has_orig ? "命中" : "未命中");
+ }
+ }
+#endif
+
+ // 非文本消息描述模板 → 占位符
+ if (has_orig) {
+ struct { const char *needle; const char *replace; } kReplaces[] = {
+ {"发了一张图片", "[图片]"}, {"发了一段视频", "[视频]"},
+ {"发了一个文件", "[文件]"}, {"发了一段语音", "[语音]"},
+ {"发了一条语音消息", "[语音]"}, {"发了一个表情", "[表情]"},
+ {"发了一个视频号", "[视频号]"}, {"发了一张名片", "[名片]"},
+ {"发了一个位置", "[位置]"}, {"发了一个红包", "[红包]"},
+ {"发了一个链接", "[链接]"}, {"发了一个小程序", "[小程序]"},
+ {NULL, NULL},
+ };
+ for (int i = 0; kReplaces[i].needle; i++) {
+ if (strstr(orig_content, kReplaces[i].needle)) {
+ strncpy(orig_content, kReplaces[i].replace, sizeof(orig_content) - 1);
+ break;
+ }
+ }
+ }
+
+ // 从 notify_text 抽纯昵称:剥 "撤回了" 后缀 + 首尾空格 + 英文双引号
+ char nick[128] = {0};
+ if (notify_text[0] != '\0') {
+ const char *p = strstr(notify_text, "撤回了");
+ if (p && p > notify_text) {
+ const char *start = notify_text;
+ size_t nlen = (size_t)(p - notify_text);
+ while (nlen > 0 && start[nlen - 1] == ' ') nlen--;
+ while (nlen > 0 && *start == ' ') { start++; nlen--; }
+ if (nlen >= 2 && start[0] == '"' && start[nlen - 1] == '"') { start++; nlen -= 2; }
+ if (nlen > 0 && nlen < sizeof(nick)) { memcpy(nick, start, nlen); nick[nlen] = '\0'; }
+ }
+ }
+ const char *who = (nick[0] != '\0') ? nick : sender;
+
+ char content[768] = {0};
+ if (has_orig) {
+ snprintf(content, sizeof(content), "拦截到「%s」撤回了一条消息:%s", who, orig_content);
+ } else {
+ snprintf(content, sizeof(content), "拦截到「%s」撤回了一条消息", who);
+ }
send_notification(content);
@@ -363,8 +392,6 @@ static uintptr_t find_wechat_slide(const struct mach_header **out_header) {
return fallback;
}
-// ── 解析 wechat.dylib 的 __TEXT 段范围 ───────────────────────
-// 返回 1 = 成功,0 = 失败
static _Bool find_text_segment(const struct mach_header *header, uintptr_t slide,
uintptr_t *out_start, size_t *out_size) {
if (!header) return 0;
@@ -403,10 +430,7 @@ static _Bool find_text_segment(const struct mach_header *header, uintptr_t slide
return 0;
}
-// ── 特征码搜索:在 __TEXT 段中查找 isRevokeMessage 函数 ──────
-// arm64 特征:5 条指令的 isRevokeMessage(无 dispatch slot 的 4.1.10 形态)
-// LDR W8, [X0, #0xC]; MOV W9, #0x2712; CMP W8, W9; CSET W0, EQ; RET
-// 返回函数 VA(slide + offset),未找到返回 0
+// arm64 isRevokeMessage 特征码:LDR W8,[X0,#C]; MOV W9,#0x2712; CMP; CSET; RET
static uintptr_t scan_isRevokeMessage_arm64(uintptr_t text_start, size_t text_size) {
static const uint32_t pattern[5] = {
0xB9400C08u, 0x5284E249u, 0x6B09011Fu, 0x1A9F17E0u, 0xD65F03C0u
@@ -427,11 +451,7 @@ static uintptr_t scan_isRevokeMessage_arm64(uintptr_t text_start, size_t text_si
return 0;
}
-// x86_64 特征:完整函数(16 字节)
-// 55 48 89 E5 (push rbp; mov rbp,rsp)
-// 81 7F 0C 12 27 00 00 (cmp [rdi+0xC], 0x2712)
-// 0F 94 C0 (sete al)
-// 5D C3 (pop rbp; ret)
+// x86_64 isRevokeMessage 特征码
static uintptr_t scan_isRevokeMessage_x86_64(uintptr_t text_start, size_t text_size) {
static const uint8_t pattern[] = {
0x55, 0x48, 0x89, 0xE5,
@@ -451,8 +471,6 @@ static uintptr_t scan_isRevokeMessage_x86_64(uintptr_t text_start, size_t text_s
return 0;
}
-// ── 版本检测 ─────────────────────────────────────────────────
-// 已知支持的 build:4.1.9 (268602)、4.1.10 (268824)
static const char *kKnownBuilds[] = { "268602", "268824", NULL };
static _Bool is_known_build(const char *build) {
@@ -463,7 +481,6 @@ static _Bool is_known_build(const char *build) {
return 0;
}
-// 读取 Info.plist 中的 CFBundleVersion + CFBundleShortVersionString
static void read_wechat_version(char *short_ver, size_t short_sz,
char *build, size_t build_sz) {
short_ver[0] = '\0';
@@ -477,7 +494,6 @@ static void read_wechat_version(char *short_ver, size_t short_sz,
}
}
-// ── 内存保护工具 ─────────────────────────────────────────────
static kern_return_t make_rw(uintptr_t addr, size_t len) {
uintptr_t page = addr & ~(uintptr_t)0x3FFF;
size_t sz = (addr + len - page + 0x3FFF) & ~(size_t)0x3FFF;
@@ -491,7 +507,7 @@ static kern_return_t make_rx(uintptr_t addr, size_t len) {
VM_PROT_READ | VM_PROT_EXECUTE);
}
-// ── arm64 inline trampoline(20 字节)─────────────────────────
+// arm64: LDR X16,#8; BR X16; ; NOP — 共 20 字节覆盖原函数入口
static _Bool install_arm64_trampoline(uintptr_t func_addr, uintptr_t hook_addr) {
kern_return_t kr = make_rw(func_addr, 20);
if (kr != KERN_SUCCESS) { ARLOG("ERROR: make_rw kr=%d", kr); return 0; }
@@ -512,7 +528,7 @@ static _Bool install_arm64_trampoline(uintptr_t func_addr, uintptr_t hook_addr)
return 1;
}
-// ── x86_64 inline trampoline(16 字节)───────────────────────
+// x86_64: JMP [RIP+0]; ; NOP; RET — 共 16 字节
static _Bool install_x86_64_trampoline(uintptr_t func_addr, uintptr_t hook_addr) {
kern_return_t kr = make_rw(func_addr, 16);
if (kr != KERN_SUCCESS) { ARLOG("ERROR: x86_64 make_rw kr=%d", kr); return 0; }
@@ -532,9 +548,7 @@ static _Bool install_x86_64_trampoline(uintptr_t func_addr, uintptr_t hook_addr)
return 1;
}
-// ── Hook 安装失败时通知用户 ─────────────────────────────────
static void notify_install_failed(const char *short_ver, const char *build, _Bool known_build) {
- if (!is_notify_enabled()) return;
char *cmd = (char *)malloc(2048);
if (!cmd) return;
@@ -543,20 +557,17 @@ static void notify_install_failed(const char *short_ver, const char *build, _Boo
char body[512];
if (known_build) {
- // 已知 build 但仍失败(极罕见)
snprintf(title, sizeof(title), "WeChatIntercept 异常");
snprintf(body, sizeof(body),
"已知版本 %s (%s) hook 安装失败,请查看 /tmp/antirevoke_debug.log",
short_ver, build);
} else {
- // 未知 build:可能是版本变化或仅 build 号变化
snprintf(title, sizeof(title), "WeChatIntercept 需更新");
snprintf(body, sizeof(body),
"微信版本 %s (build %s) 未适配,防撤回功能已失效。请前往 GitHub 获取最新脚本",
short_ver, build);
}
- // 转义 body 中的双引号和反斜杠
char escaped[1024];
int j = 0;
for (int i = 0; body[i] && j < (int)sizeof(escaped) - 2; i++) {
@@ -583,10 +594,8 @@ static void hook_init(void) {
dispatch_get_main_queue(), ^{
log_open();
- init_config_path();
ARLOG("hook_init 启动");
- // 读取微信版本
char short_ver[32] = {0};
char build[32] = {0};
read_wechat_version(short_ver, sizeof(short_ver), build, sizeof(build));
@@ -602,7 +611,6 @@ static void hook_init(void) {
return;
}
- // 解析 __TEXT 段范围(用于特征码搜索)
uintptr_t text_start = 0;
size_t text_size = 0;
_Bool has_text = find_text_segment(header, slide, &text_start, &text_size);
@@ -613,13 +621,12 @@ static void hook_init(void) {
_Bool installed = 0;
#if defined(__arm64__) || defined(__aarch64__)
- // 1. 先尝试硬编码地址(快速路径)
+ // 三级查找:硬编码快速路径 → 特征码搜索 → 4.1.9 slot fallback
uintptr_t func_addr = 0;
uintptr_t func_4110 = slide + k4110_FuncVA_arm64;
uint32_t head_insn = *(volatile uint32_t *)func_4110;
if (head_insn == 0xB9400C08u) {
- // 进一步验证完整 5 条指令特征码(避免误判)
uint32_t *p = (uint32_t *)func_4110;
if (p[1] == 0x5284E249u && p[2] == 0x6B09011Fu &&
p[3] == 0x1A9F17E0u && p[4] == 0xD65F03C0u) {
@@ -628,14 +635,6 @@ static void hook_init(void) {
}
}
- // 2. 快速路径失败 → 尝试 4.1.9 slot
- if (func_addr == 0) {
- void **slot = (void **)(slide + k419_SlotVA_arm64);
- // 简单验证:检查 slot 周围是否在 __DATA 段(不严格)
- // 先记录,后面如果特征码搜索也失败再尝试 slot
- }
-
- // 3. 特征码搜索(兜底)
if (func_addr == 0 && has_text) {
ARLOG("快速路径未命中,开始特征码搜索...");
uintptr_t found = scan_isRevokeMessage_arm64(text_start, text_size);
@@ -646,14 +645,13 @@ static void hook_init(void) {
}
}
- // 4. 安装 trampoline
if (func_addr != 0) {
if (install_arm64_trampoline(func_addr, hook)) {
ARLOG("arm64 trampoline 安装成功");
installed = 1;
}
} else {
- // 5. 最后尝试 4.1.9 slot 方式
+ // 4.1.9 slot fallback
void **slot = (void **)(slide + k419_SlotVA_arm64);
uintptr_t page = (uintptr_t)slot & ~(uintptr_t)0x3FFF;
kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)page, 0x4000,
@@ -671,14 +669,12 @@ static void hook_init(void) {
uintptr_t func_419_x86 = slide + k419_FuncVA_x86_64;
const uint32_t kFuncHead = 0xE5894855u;
- // 1. 快速路径
if (*(volatile uint32_t *)func_4110_x86 == kFuncHead) {
func_addr = func_4110_x86;
} else if (*(volatile uint32_t *)func_419_x86 == kFuncHead) {
func_addr = func_419_x86;
}
- // 2. 特征码搜索
if (func_addr == 0 && has_text) {
ARLOG("快速路径未命中,开始特征码搜索...");
uintptr_t found = scan_isRevokeMessage_x86_64(text_start, text_size);
@@ -689,7 +685,6 @@ static void hook_init(void) {
}
}
- // 3. 安装 trampoline
if (func_addr != 0) {
if (install_x86_64_trampoline(func_addr, hook)) {
ARLOG("x86_64 trampoline 安装成功");
@@ -816,6 +811,7 @@ resign_app() {
echo "[INFO] 重签名(注入 entitlements 绕过 Library Validation)..."
# 创建 entitlements 文件
+ # get-task-allow 允许 lldb attach(用于 monitor.sh 写消息缓存供撤回反查)
local ENT_FILE="/tmp/antirevoke_ent.plist"
cat > "$ENT_FILE" << 'ENTITLEMENTS'
@@ -826,6 +822,8 @@ resign_app() {
com.apple.security.cs.allow-unsigned-executable-memory
+ com.apple.security.get-task-allow
+
ENTITLEMENTS
@@ -968,12 +966,7 @@ do_install() {
resign_app
verify_install
- # 创建默认配置(开启通知)
- local CONFIG_DIR="$HOME/.config/antirevoke"
- mkdir -p "$CONFIG_DIR"
- if [ ! -f "$CONFIG_DIR/config" ]; then
- echo "notify=1" > "$CONFIG_DIR/config"
- fi
+
echo ""
echo "=============================="
@@ -982,12 +975,8 @@ do_install() {
echo ""
echo " 功能: 对方撤回的消息将保留可见"
echo " 自己撤回消息正常工作"
- echo ""
- echo " 通知开关:"
- echo " $0 openNotify 开启撤回通知"
- echo " $0 closeNotify 关闭撤回通知"
- echo ""
- echo " 卸载: $0 --uninstall"
+ echo ""
+ echo " 卸载: $0 --uninstall"
echo ""
}
@@ -1074,59 +1063,659 @@ do_uninstall() {
echo ""
}
-CONFIG_DIR="$HOME/.config/antirevoke"
-CONFIG_FILE="$CONFIG_DIR/config"
+# ======================== 消息监听(撤回原文)========================
+
+# 释放内嵌脚本到 MONITOR_INSTALL_DIR
+deploy_monitor_files() {
+ MONITOR_INSTALL_DIR="/tmp/wechatintercept_monitor"
+ mkdir -p "$MONITOR_INSTALL_DIR"
+
+ # wechat_msg_monitor.py
+ cat > "$MONITOR_INSTALL_DIR/wechat_msg_monitor.py" << 'MONITOR_PY'
+# -*- coding: utf-8 -*-
+"""
+WeChat 消息监听器(lldb Python 脚本)
+在 wechat.dylib __TEXT 段扫描 CMessageWrap 虚方法特征码,
+断点命中时读消息字段写入 TSV 缓存供 dylib 反查撤回原文。
+用法:./monitor.sh 或 ./monitor.sh --install
+"""
+
+import lldb
+import struct
+import datetime
+
+# CMessageWrap 虚方法(257712)特征码(4.1.10 实测)
+# PREFIX 4条 + 通配 bl(4字节) + SUFFIX 1条
+PATTERN_PREFIX = bytes.fromhex("f44fbea9" "fd7b01a9" "fd430091" "f30301aa")
+PATTERN_SUFFIX = bytes.fromhex("683a40f9")
+PATTERN_GAP = 4
+
+# 消息对象字段偏移(4.1.10 验证;微信升级后可能变化)
+OFF_FLAG1 = 0x28
+OFF_FLAG2 = 0x2c
+OFF_CONTENT_PTR = 0x40 # wrapper ptr; wrapper+0x00 → content char*
+OFF_CREATE_TIME = 0x48
+OFF_MSG_LOCAL = 0x4c
+OFF_MSG_SVR = 0x50 # int64, 与撤回 XML 对应
+OFF_FROM_PTR = 0x08 # wrapper ptr; wrapper+0x08 → wxid char*
+
+WRAPPER_DATA_PTR = 0x08
+
+_g_msg_count = 0
+_g_seen_svrid = set()
+_g_debug_dump = False
+
+# 缓存文件:dylib 反查撤回原文用。svrid 十进制,字段 \t 分隔,原子 rename 写入
+CACHE_FILE = "/tmp/wechat_msg_cache.tsv"
+_CACHE_MAX_LINES = 500
+_g_cache_lines = []
+
+
+def _sanitize_field(s):
+ if not s:
+ return ""
+ return s.replace("\t", " ").replace("\n", " ").replace("\r", " ")
+
+
+def _strip_sender_prefix(content):
+ # 微信 +0x40 存的是 "<昵称> : <正文>" 格式,剥掉前缀只留正文
+ if not content:
+ return content
+ idx = content.find(" : ")
+ if idx > 0 and idx < 64: # 昵称不会超过 64 字符
+ return content[idx + 3:]
+ return content
+
+
+def _is_valid_content(stripped, from_user):
+ if not stripped or len(stripped) < 2:
+ return False
+ if stripped == from_user:
+ return False
+ if stripped.startswith("<"):
+ return False
+ return True
+
+
+def _cache_append(svrid, from_user, content):
+ if svrid == 0 or not content:
+ return
+ try:
+ body = _strip_sender_prefix(content)
+ line = "{}\t{}\t{}\n".format(
+ svrid,
+ _sanitize_field(from_user)[:63],
+ _sanitize_field(body)[:511],
+ )
+ _g_cache_lines.append(line)
+ if len(_g_cache_lines) > _CACHE_MAX_LINES:
+ del _g_cache_lines[: len(_g_cache_lines) - _CACHE_MAX_LINES]
+
+ # 原子写,避免 dylib 读到半行
+ tmp = CACHE_FILE + ".tmp"
+ with open(tmp, "w", encoding="utf-8", errors="replace") as f:
+ f.writelines(_g_cache_lines)
+ import os
+ os.replace(tmp, CACHE_FILE)
+ except Exception as e:
+ print(" [cache] write failed: {}".format(e))
+
+
+def _read_mem(process, addr, size):
+ if addr == 0:
+ return None
+ err = lldb.SBError()
+ data = process.ReadMemory(addr, size, err)
+ if not err.Success():
+ return None
+ return data
+
+
+def _read_u32(process, addr):
+ data = _read_mem(process, addr, 4)
+ if data is None:
+ return None
+ return struct.unpack("= 0:
+ data = data[:nul]
+ try:
+ return data.decode("utf-8", errors="replace")
+ except Exception:
+ return repr(data)
+
+
+def _read_std_string_via_wrapper(process, wrapper_ptr):
+ # wrapper 结构: +0x00 vtable, +0x08 data ptr
+ if wrapper_ptr == 0:
+ return ""
+ data_ptr = _read_u64(process, wrapper_ptr + WRAPPER_DATA_PTR)
+ if data_ptr is None or data_ptr == 0:
+ return ""
+
+ # 简化:不区分 char*/SSO,直接当 C 字符串读
+ s = _read_cstring(process, data_ptr, max_len=512)
+ return s
+
+
+def _read_std_string_inplace(process, addr):
+ # libc++ std::string 24字节布局: LSB of byte[23] == 0 → SSO, == 1 → heap
+ data = _read_mem(process, addr, 24)
+ if data is None:
+ return ""
+ last_byte = data[23]
+ if last_byte & 0x01 == 0:
+ # SSO(最低位=0)
+ size = last_byte >> 1
+ if size > 22:
+ return ""
+ return data[:size].decode("utf-8", errors="replace")
+ else:
+ # 长字符串
+ ptr = struct.unpack(" 4096:
+ return ""
+ body = _read_mem(process, ptr, size)
+ if body is None:
+ return ""
+ return body.decode("utf-8", errors="replace")
+
+
+
+def _try_read_content(process, msg_obj):
+ # +0x40 是 std::string inplace(libc++ [data_ptr][size][cap|0x80...])
+ # 注意:断点命中瞬间 data_ptr 指向的内存可能还没就绪(时序问题),
+ # 所以尝试两次读取:第一次失败就 fallback,最后再试一次
+ def _try_str40():
+ # +0x40 存的是指针 → 指向 std::string 结构
+ ptr40 = _read_u64(process, msg_obj + 0x40)
+ if not ptr40 or ptr40 < 0x100000000 or ptr40 > 0x10000000000:
+ return ""
+ raw = _read_mem(process, ptr40, 24)
+ if not raw or len(raw) < 24:
+ return ""
+ dp = struct.unpack("= 2:
+ return text
+ # SSO:数据直接在 raw[0:22]
+ nul = raw.find(b"\x00", 0, 22)
+ sso_data = raw[:nul] if nul >= 0 else raw[:22]
+ if sso_data and len(sso_data) >= 2:
+ try:
+ return sso_data.decode("utf-8", errors="strict")
+ except UnicodeDecodeError:
+ pass
+ return ""
+
+ t = _try_str40()
+ if t:
+ return (t, "+0x40(str)")
+
+ return ("", "")
+
+
+def on_msg_hit(frame, bp_loc, dict_):
+ # 返回 False = 自动 continue(不停在 lldb)
+ global _g_msg_count, _g_seen_svrid
+
+ process = frame.GetThread().GetProcess()
+
+ # x19 = msg obj; callee-saved, 在 +12 (mov x19,x1) 后已就绪
+ x19 = frame.FindRegister("x19").GetValueAsUnsigned()
+ if x19 == 0:
+ return False
+
+ msg_obj = x19
+ if msg_obj < 0x100000000 or msg_obj > 0x1000000000000:
+ return False
+
+ create_time = _read_u32(process, msg_obj + OFF_CREATE_TIME)
+ msg_local = _read_u32(process, msg_obj + OFF_MSG_LOCAL)
+ msg_svr_lo = _read_u32(process, msg_obj + OFF_MSG_SVR)
+ msg_svr_hi = _read_u32(process, msg_obj + OFF_MSG_SVR + 4)
+ if create_time is None or msg_svr_lo is None or msg_svr_hi is None:
+ return False
+ msg_svr = (msg_svr_hi << 32) | msg_svr_lo
+
+ if create_time < 1577836800 or create_time > 1893456000: # 2020~2030
+ return False
+
+ if msg_svr in _g_seen_svrid:
+ return False
+ if msg_svr != 0:
+ _g_seen_svrid.add(msg_svr)
+ if len(_g_seen_svrid) > 1000:
+ _g_seen_svrid = set(list(_g_seen_svrid)[-500:])
+
+ # from: +0x08 wrapper, wrapper+0x08 才是字符串
+ from_wrapper = _read_u64(process, msg_obj + OFF_FROM_PTR)
+ from_user = _read_std_string_via_wrapper(process, from_wrapper) if from_wrapper else ""
+
+ flag1 = _read_u32(process, msg_obj + OFF_FLAG1)
+ flag2 = _read_u32(process, msg_obj + OFF_FLAG2)
+ subtype_vtbl = _read_u64(process, msg_obj + 0x10)
+
+ # flag2=1: +0x40 → ptr → std::string(已验证)
+ # flag2=2: 不同类结构,尝试扩大范围搜索
+ content, content_off = _try_read_content(process, msg_obj)
+
+ try:
+ ts = datetime.datetime.fromtimestamp(create_time).strftime("%Y-%m-%d %H:%M:%S")
+ except Exception:
+ ts = str(create_time)
+
+ _g_msg_count += 1
+ print("─" * 60)
+ print("[wx_msg #{}] {}".format(_g_msg_count, ts))
+ print(" obj : 0x{:016x}".format(msg_obj))
+ print(" svrid : 0x{:016x}".format(msg_svr))
+ print(" localid : 0x{:08x}".format(msg_local or 0))
+ print(" flag : 0x{:x} / 0x{:x}".format(flag1 or 0, flag2 or 0))
+ print(" subtype : 0x{:x}".format(subtype_vtbl or 0)) # +0x10 处的 vtable,用于区分消息类型
+ print(" from : {}".format(from_user))
+ if content:
+ print(" content@{}: {}".format(content_off, content[:200]))
+ else:
+ print(" content : ")
+
+
+
+ # 写缓存前排除误读
+ if content and msg_svr != 0:
+ stripped = _strip_sender_prefix(content)
+ if stripped and _is_valid_content(stripped, from_user):
+ _cache_append(msg_svr, from_user, content)
+
+ if _g_debug_dump:
+ _dump_msg_object(process, msg_obj)
+ # must be in breakpoint context or object freed
+ _deep_scan(process, msg_obj)
+
+ return False
+
+
+def _dump_msg_object(process, addr, obj_size=0x100):
+ raw = _read_mem(process, addr, obj_size)
+ if raw is None:
+ print(" [dump] 读取失败")
+ return
+ print(" [dump] obj @ 0x{:x} ({} bytes):".format(addr, obj_size))
+ for off in range(0, obj_size, 16):
+ line = raw[off:off + 16]
+ hex_part = " ".join("{:02x}".format(b) for b in line)
+ ascii_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in line)
+ print(" +0x{:03x}: {} {}".format(off, hex_part, ascii_part))
+
+ print(" [deref] 候选指针字段:")
+ for off in range(0, obj_size, 8):
+ if off + 8 > len(raw):
+ break
+ ptr = struct.unpack(" 0x10000000000:
+ continue
+ sub = _read_mem(process, ptr, 64)
+ if sub is None:
+ continue
+ printable = sum(1 for b in sub[:32] if 0x20 <= b < 0x7F)
+ if printable < 4:
+ continue
+ ascii_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in sub[:48])
+ print(" +0x{:03x} -> 0x{:x}: {}".format(off, ptr, ascii_part))
+
+
+def scan_pattern(process, start_addr, size, max_size=512 * 1024 * 1024):
+ if size > max_size:
+ size = max_size
+
+ chunk_size = 4 * 1024 * 1024 # 4MB
+ overlap = len(PATTERN_PREFIX) + PATTERN_GAP + len(PATTERN_SUFFIX)
+
+ pos = 0
+ chunks_read = 0
+ chunks_failed = 0
+ prefix_hits = 0 # PREFIX 匹配但 SUFFIX 不匹配的次数
+ bytes_scanned = 0
+
+ while pos < size:
+ read_size = min(chunk_size + overlap, size - pos)
+ data = _read_mem(process, start_addr + pos, read_size)
+ if data is None:
+ chunks_failed += 1
+ pos += chunk_size
+ continue
+
+ chunks_read += 1
+ bytes_scanned += len(data)
+
+ idx = 0
+ while True:
+ i = data.find(PATTERN_PREFIX, idx)
+ if i < 0:
+ break
+ prefix_hits += 1
+ suffix_pos = i + len(PATTERN_PREFIX) + PATTERN_GAP
+ if suffix_pos + len(PATTERN_SUFFIX) <= len(data):
+ if data[suffix_pos:suffix_pos + len(PATTERN_SUFFIX)] == PATTERN_SUFFIX:
+ print(" 扫描完成: chunks ok={} fail={} bytes={} prefix_hits={}".format(
+ chunks_read, chunks_failed, bytes_scanned, prefix_hits))
+ return start_addr + pos + i
+ idx = i + 1
+
+ pos += chunk_size
+
+ print(" 扫描完成(未找到): chunks ok={} fail={} bytes={} prefix_hits={}".format(
+ chunks_read, chunks_failed, bytes_scanned, prefix_hits))
+ return 0
+
+
+def find_wechat_dylib_text(target):
+ # NOTE: 微信 4.1.x 有两个 wechat.dylib (Resources/ 核心 vs Frameworks/ stub)
+ # 必须用完整路径区分
+ candidates = []
+ for module in target.module_iter():
+ spec = module.GetFileSpec()
+ filename = spec.GetFilename() or ""
+ if filename != "wechat.dylib":
+ continue
+ directory = spec.GetDirectory() or ""
+ full_path = directory + "/" + filename
+ for sec in module.section_iter():
+ if sec.GetName() == "__TEXT":
+ load_addr = sec.GetLoadAddress(target)
+ size = sec.GetByteSize()
+ candidates.append((full_path, load_addr, size))
+ break
+
+ if not candidates:
+ return (0, 0)
+
+ for path, addr, size in candidates:
+ if "/Resources/" in path:
+ print(" [match] {} __TEXT @ 0x{:x} size=0x{:x}".format(path, addr, size))
+ return (addr, size)
+
+ candidates.sort(key=lambda x: x[2], reverse=True)
+ path, addr, size = candidates[0]
+ print(" [fallback] {} __TEXT @ 0x{:x} size=0x{:x}".format(path, addr, size))
+ return (addr, size)
+
+
+def cmd_start(debugger, command, result, internal_dict):
+ target = debugger.GetSelectedTarget()
+ if not target:
+ result.SetError("没有 target,先 attach 微信进程")
+ return
+ process = target.GetProcess()
+ if not process or not process.IsValid():
+ result.SetError("没有 process")
+ return
+
+ print(">>> 扫描 wechat.dylib __TEXT 特征码 ...")
+ text_addr, text_size = find_wechat_dylib_text(target)
+ if text_addr == 0:
+ print(" 候选模块:")
+ for module in target.module_iter():
+ spec = module.GetFileSpec()
+ fn = spec.GetFilename() or ""
+ if "wechat" in fn.lower():
+ print(" - {}/{}".format(spec.GetDirectory() or "", fn))
+ result.SetError("未找到 wechat.dylib __TEXT 段(确认 wechat.dylib 已加载)")
+ return
+ if text_size < 1024 * 1024:
+ print(" [WARN] __TEXT size=0x{:x} 异常偏小,可能匹配到 stub".format(text_size))
+
+ func_addr = scan_pattern(process, text_addr, text_size)
+ if func_addr == 0:
+ result.SetError("特征码未匹配(可能版本不一致,需更新 PATTERN)")
+ return
+ # 断点在 +20: +12 mov x19,x1 已执行(obj ready), +16 bl已完成(content ready)
+ # 不能更早,否则 x19 或 content 还没就绪
+ BP_OFFSET_FROM_FUNC_HEAD = 20
+ bp_addr = func_addr + BP_OFFSET_FROM_FUNC_HEAD
+
+ print(" msg func @ 0x{:x}(断点 @ 0x{:x} = +{})".format(
+ func_addr, bp_addr, BP_OFFSET_FROM_FUNC_HEAD))
+
+ bp = target.BreakpointCreateByAddress(bp_addr)
+ if not bp.IsValid():
+ result.SetError("断点创建失败")
+ return
+ bp.SetScriptCallbackFunction("wechat_msg_monitor.on_msg_hit")
+ bp.SetAutoContinue(True)
+ print(">>> 断点 #{} 已设置 @ 0x{:x}(自动 continue)".format(bp.GetID(), bp_addr))
+ print(">>> 输入 'continue' 让微信跑起来;收到的消息会打印在这里")
+ print(">>> 停止监听:bp delete {}".format(bp.GetID()))
+
+
+def cmd_stop(debugger, command, result, internal_dict):
+ target = debugger.GetSelectedTarget()
+ if not target:
+ return
+ print("请手动 'breakpoint delete ' 删除断点")
+
+
+def cmd_stats(debugger, command, result, internal_dict):
+ print("已捕获消息数: {}".format(_g_msg_count))
+ print("去重表大小 : {}".format(len(_g_seen_svrid)))
+ print("调试 dump 模式: {}".format("ON" if _g_debug_dump else "OFF"))
+
+
+def cmd_debug_on(debugger, command, result, internal_dict):
+ global _g_debug_dump
+ _g_debug_dump = True
+ print("[debug] dump 模式已打开。下次命中会输出原始字节。")
+
+
+def cmd_debug_off(debugger, command, result, internal_dict):
+ global _g_debug_dump
+ _g_debug_dump = False
+ print("[debug] dump 模式已关闭。")
+
+
+def _deep_scan(process, addr, scan_size=0x200):
+ print(" [deep_scan] @ 0x{:x}".format(addr))
+ raw = _read_mem(process, addr, scan_size)
+ if raw is None:
+ print(" [deep_scan] 读取失败")
+ return
+
+ found = 0
+ seen_ptrs = set()
+ seen_ptrs.add(addr)
+ for off in range(0, len(raw), 8):
+ if off + 8 > len(raw):
+ break
+ ptr = struct.unpack(" 0x10000000000:
+ continue
+ if ptr in seen_ptrs:
+ continue
+ seen_ptrs.add(ptr)
+
+ sub = _read_mem(process, ptr, 96)
+ if sub is None:
+ continue
+
+ for start in range(0, min(64, len(sub))):
+ ok = 0
+ for i in range(start, min(start + 8, len(sub))):
+ b = sub[i]
+ if 0x20 <= b < 0x7F:
+ ok += 1
+ else:
+ break
+ if ok >= 4: # 放宽到 4 个连续字符
+ end = start
+ for i in range(start, min(start + 80, len(sub))):
+ if sub[i] == 0:
+ break
+ end = i + 1
+ txt = sub[start:end]
+ try:
+ s = txt.decode("utf-8", errors="replace")
+ print(" L1 +0x{:03x} -> 0x{:x} +0x{:02x}: {}".format(off, ptr, start, s))
+ found += 1
+ except Exception:
+ pass
+ break
+
+ for sub_off in range(0, len(sub), 8):
+ if sub_off + 8 > len(sub):
+ break
+ sub_ptr = struct.unpack(" 0x10000000000:
+ continue
+ if sub_ptr in seen_ptrs:
+ continue
+ seen_ptrs.add(sub_ptr)
+ sub2 = _read_mem(process, sub_ptr, 96)
+ if sub2 is None:
+ continue
+ ok = 0
+ for i in range(min(8, len(sub2))):
+ if 0x20 <= sub2[i] < 0x7F:
+ ok += 1
+ else:
+ break
+ if ok >= 4:
+ end = 0
+ for i in range(min(80, len(sub2))):
+ if sub2[i] == 0:
+ break
+ end = i + 1
+ txt = sub2[:end]
+ try:
+ s = txt.decode("utf-8", errors="replace")
+ print(" L2 +0x{:03x}/+0x{:02x} -> 0x{:x}: {}".format(off, sub_off, sub_ptr, s))
+ found += 1
+ except Exception:
+ pass
+
+ if found == 0:
+ print(" [deep_scan] 未发现可读字符串")
+ else:
+ print(" [deep_scan] 共 {} 处".format(found))
+
+
+def cmd_scan_strings(debugger, command, result, internal_dict):
+ # must be in breakpoint context or object freed
+ args = command.strip().split()
+ if not args:
+ print("用法: wx_scan_strings ")
+ return
+ try:
+ addr = int(args[0], 16) if args[0].startswith("0x") else int(args[0])
+ except ValueError:
+ print("地址格式错误")
+ return
+
+ target = debugger.GetSelectedTarget()
+ process = target.GetProcess()
+ if not process or not process.IsValid():
+ print("没有 process")
+ return
+
+ _deep_scan(process, addr)
+
+
+def __lldb_init_module(debugger, internal_dict):
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_start wx_monitor_start'
+ )
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_stop wx_monitor_stop'
+ )
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_stats wx_monitor_stats'
+ )
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_debug_on wx_monitor_debug_on'
+ )
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_debug_off wx_monitor_debug_off'
+ )
+ debugger.HandleCommand(
+ 'command script add -f wechat_msg_monitor.cmd_scan_strings wx_scan_strings'
+ )
+ print("[wechat_msg_monitor] 已加载。命令:")
+ print(" wx_monitor_start — 扫描特征码、下断点、开始监听")
+ print(" wx_monitor_stop — 停止监听")
+ print(" wx_monitor_stats — 查看统计")
+ print(" wx_monitor_debug_on — 打开 dump 调试模式")
+ print(" wx_monitor_debug_off — 关闭 dump 调试模式")
+ print(" wx_scan_strings — 深度扫描对象里的字符串(需先 process interrupt)")
+
+MONITOR_PY
-do_open_notify() {
- mkdir -p "$CONFIG_DIR"
- if grep -q "^notify=" "$CONFIG_FILE" 2>/dev/null; then
- sed -i '' 's/^notify=.*/notify=1/' "$CONFIG_FILE"
- else
- echo "notify=1" >> "$CONFIG_FILE"
- fi
- echo "[INFO] 撤回通知已开启"
}
-do_close_notify() {
- mkdir -p "$CONFIG_DIR"
- if grep -q "^notify=" "$CONFIG_FILE" 2>/dev/null; then
- sed -i '' 's/^notify=.*/notify=0/' "$CONFIG_FILE"
- else
- echo "notify=0" >> "$CONFIG_FILE"
+
+
+
+do_monitor_foreground() {
+ WECHAT_PID=$(pgrep -x WeChat | head -1 || true)
+ if [ -z "$WECHAT_PID" ]; then
+ echo "[ERROR] 微信未运行"; exit 1
fi
- echo "[INFO] 撤回通知已关闭"
+ deploy_monitor_files
+ INIT_FILE=$(mktemp /tmp/wx_monitor_init.XXXXXX)
+ cat > "$INIT_FILE" << EOF
+command script import "$MONITOR_INSTALL_DIR/wechat_msg_monitor.py"
+process attach --pid $WECHAT_PID
+wx_monitor_start
+continue
+EOF
+ trap "rm -f $INIT_FILE" EXIT
+ echo "[INFO] attach 微信 (pid=$WECHAT_PID),Ctrl+C 退出"
+ lldb -s "$INIT_FILE"
}
# ======================== 入口 ========================
case "${1:-}" in
- openNotify)
- do_open_notify
- ;;
- closeNotify)
- do_close_notify
- ;;
--debug|-d)
do_debug
;;
--uninstall|-u)
do_uninstall
;;
+ --monitor)
+ do_monitor_foreground
+ ;;
--help|-h)
print_banner
echo "用法:"
- echo " $0 安装防撤回"
- echo " $0 openNotify 开启撤回通知"
- echo " $0 closeNotify 关闭撤回通知"
- echo " $0 --debug 调试模式(无 hook,允许 lldb)"
- echo " $0 --uninstall 卸载"
- echo " $0 --help 帮助"
+ echo " $0 安装防撤回"
+ echo " $0 --monitor 前台运行消息监听(调试用)"
+ echo " $0 --debug 调试模式(无 hook,允许 lldb)"
+ echo " $0 --uninstall 卸载防撤回"
+ echo " $0 --help 帮助"
;;
"")
do_install
;;
*)
echo "[ERROR] 未知参数: $1"
- echo "用法: $0 [openNotify|closeNotify|--uninstall|--debug|--help]"
+ echo "用法: $0 [--help]"
exit 1
;;
esac