WeChatIntercept/doc/reverse-engineering-guide.md

1138 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 微信 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('<IIIII', 0xB9400C08, 0x5284E249, 0x6B09011F, 0x1A9F17E0, 0xD65F03C0)
```
**结果**arm64 dylib slice
| 项目 | 值 |
|------|------|
| 函数 VA (dylib) | `0x44E3D50` |
| Hook dispatch slot VA | `0x9301838` |
| FAT arm64 slice offset | `0x9B18000` |
**函数结构**
```asm
0x44E3D50: adrp x9, #0x9301000 ; 加载 hook 表页地址
0x44E3D54: ldr x9, [x9, #0x838] ; 加载 hook 函数指针 (slot)
0x44E3D58: cbz x9, #0x44E3D60 ; 如果 slot 为空,跳过
0x44E3D5C: br x9 ; 跳转到 hook 函数
0x44E3D60: ldr w8, [x0, #0xc] ; 读取消息类型
0x44E3D64: mov w9, #0x2712 ; 10002
0x44E3D68: cmp w8, w9 ; 比较
0x44E3D6C: cset w0, eq ; 返回 bool
0x44E3D70: ret
```
### 2.2 微信内建 Hook Dispatch 机制
微信在每个类型检查函数开头都有一个 **hook dispatch slot** 机制:
1.`__DATA` BSS 区域加载一个函数指针
2. 如果不为 NULL跳转到该函数允许外部 hook
3. 如果为 NULL执行原始逻辑
这个机制可能用于微信自己的热修复系统。我们可以利用它来安装 hook**无需修改代码段**。
**Slot 地址**`0x9301838`(在 `__DATA` segment 的 BSS 区域,运行时零初始化)
### 2.3 区分"自己撤回"和"对方撤回"
**问题**:简单让 `isRevokeMessage()` 返回 false 会导致自己撤回时闪退(内部状态不一致)。
**解决方法**:通过打日志逆向消息对象字段,找到区分标志。
#### 打日志方法
在 hook 函数中 dump 消息对象内存:
```objc
FILE *logFile = fopen("/tmp/wechat_revoke_debug.log", "a");
for (int offset = 0; offset <= 0x100; offset += 4) {
int32_t val = *(int32_t *)((uint8_t *)msg + offset);
if (val != 0 && val != (int32_t)0xAAAAAAAA) {
fprintf(logFile, " [+0x%02x] = %d (0x%08x)\n", offset, val, (uint32_t)val);
}
}
```
#### 实验结果
| 场景 | `[msg+0x18]` 的值 | 含义 |
|------|-------------------|------|
| 对方撤回 | `0x64697877` ("wxid" little-endian) | 包含对方的微信 ID |
| 自己撤回第1次调用 | `0` | 空(初始事件) |
| 自己撤回第2次调用 | 纯数字 ID | 撤回确认的 msg ID |
#### 最终判断逻辑
```c
uint32_t field18 = *(uint32_t *)((uint8_t *)msg + 0x18);
if (field18 == 0x64697877) { // "wxid" in little-endian
return 0; // 对方撤回 → 阻止
}
return 1; // 自己撤回 → 放行
```
### 2.4 x86_64 版本的对应地址
| 项目 | arm64 | x86_64 |
|------|-------|--------|
| isRevokeMessage VA | `0x44E3D50` | `0x4AF08D0` |
| 函数特征 | `LDR+MOV+CMP+CSET+RET` | `CMP [RDI+0xc],0x2712; SETE; RET` |
| Hook slot VA | `0x9301838` | 需重新分析(不同偏移) |
---
## 三、最终方案DYLD 注入
### 3.1 方案架构
```
WeChat 主程序
↓ LC_LOAD_DYLIB (注入)
WeChatAntiRevoke.dylib
↓ constructor (延迟1秒)
找到 wechat.dylib 的 ASLR slide
将 hook 函数指针写入 slot (slide + 0x9301838)
微信调用 isRevokeMessage() 时自动跳转到 hook
```
### 3.2 注入方式
通过 Python 直接修改微信主可执行文件的 Mach-O header在 load commands 末尾追加 `LC_LOAD_DYLIB`
```python
# LC_LOAD_DYLIB 结构
lc_data = struct.pack('<I', 0xC) # cmd = LC_LOAD_DYLIB
lc_data += struct.pack('<I', cmd_size) # cmdsize
lc_data += struct.pack('<I', 24) # name offset
lc_data += struct.pack('<I', 2) # timestamp
lc_data += struct.pack('<I', 0x10000) # current_version
lc_data += struct.pack('<I', 0x10000) # compat_version
lc_data += dylib_name # @executable_path/../Resources/WeChatAntiRevoke.dylib
```
### 3.3 Hook 安装
利用微信自带的 dispatch slotBSS 区域,运行时可写):
```objc
__attribute__((constructor))
static void hook_init(void) {
dispatch_after(1, ^{
uintptr_t slide = find_wechat_slide();
void **slot = (void **)(slide + 0x9301838);
// BSS 区域默认 RW直接写入
*slot = (void *)&hook_isRevokeMessage;
});
}
```
### 3.4 签名要求
修改后必须重签名,否则触发 `CODESIGNING - Invalid Page` 崩溃:
```bash
codesign --force --sign - WeChatAntiRevoke.dylib
codesign --force --sign - WeChat (主程序)
codesign --force --deep --sign - WeChat.app
```
---
## 四、微信 4.1.10 适配记录 (CFBundleVersion: 268824)
### 4.1 关键变化
| 项目 | 4.1.9 | 4.1.10 |
|------|-------|--------|
| arm64 isRevokeMessage VA | `0x44E3D50` | `0x44FFE20` |
| x86_64 isRevokeMessage VA | `0x4AF08D0` | `0x4B4E9A0` |
| arm64 hook dispatch slot | `0x9301838`(存在) | **已移除** |
| arm64 函数大小 | 9 条指令36 字节)| 5 条指令20 字节) |
### 4.2 arm64 函数结构变化
**4.1.9**9 条,有 hook dispatch
```asm
0x44E3D50: adrp x9, #0x9301000 ; 加载 hook 表页地址
0x44E3D54: ldr x9, [x9, #0x838] ; 加载 hook 函数指针
0x44E3D58: cbz x9, #0x44E3D60 ; slot 为空则跳过
0x44E3D5C: br x9 ; 跳转到 hook
0x44E3D60: ldr w8, [x0, #0xc] ; 原始逻辑...
0x44E3D64: mov w9, #0x2712
0x44E3D68: cmp w8, w9
0x44E3D6C: cset w0, eq
0x44E3D70: ret
```
**4.1.10**5 条,无 dispatch精简版
```asm
0x44FFE20: ldr w8, [x0, #0xc]
0x44FFE24: mov w9, #0x2712
0x44FFE28: cmp w8, w9
0x44FFE2C: cset w0, eq
0x44FFE30: ret
```
### 4.3 4.1.10 Hook 方案Inline Trampoline
由于 dispatch slot 已移除,改为运行时覆写函数体。
**arm64 trampoline20 字节,恰好覆盖整个函数)**
```
offset 0: 58 00 00 50 LDR X16, #8 ; 从 PC+8 加载绝对地址
offset 4: D6 1F 02 00 BR X16 ; 跳转
offset 8: [8 字节 hook 函数绝对地址]
offset 16: 1F 20 03 D5 NOP
```
**x86_64 trampoline16 字节,恰好覆盖整个函数)**
```
offset 0: FF 25 00 00 00 00 JMP QWORD PTR [RIP+0]
offset 6: [8 字节 hook 函数绝对地址]
offset 14: 90 NOP
offset 15: C3 RET
```
### 4.4 版本自动识别逻辑
运行时在 `hook_init` 中通过读取函数头部指令来区分版本,无需读取 `Info.plist`
- **arm64**:读取 `slide + 0x44FFE20` 处的 4 字节
- `== 0xB9400C08``LDR W8,[X0,#0xC]`)→ 4.1.10inline trampoline
- 其他 → 4.1.9,写入 dispatch slot `0x9301838`
- **x86_64**:先探测 4.1.10 VA再探测 4.1.9 VA头部均为 `55 48 89 E5``PUSH RBP; MOV RBP,RSP`);匹配哪个就 patch 哪个
---
## 五、新版本适配步骤
当微信发布新版本时,按以下步骤适配:
### Step 1提取 arm64 slice
```bash
lipo -thin arm64 /Applications/WeChat.app/Contents/Resources/wechat.dylib -output /tmp/wechat_arm64
```
### Step 2搜索 isRevokeMessage 新地址
搜索特征模式 `LDR W8,[X0,#0xc]; MOV W9,#0x2712; CMP; CSET; RET`
```python
pattern = struct.pack('<IIIII', 0xB9400C08, 0x5284E249, 0x6B09011F, 0x1A9F17E0, 0xD65F03C0)
idx = arm64_data.find(pattern)
```
### Step 3确定新的 hook slot 地址
`isRevokeMessage` 函数开头的 `ADRP + LDR` 指令解码 slot 地址:
```python
# 读取函数前两条指令
adrp_insn = ... # 解码得到页地址
ldr_insn = ... # 解码得到页内偏移
slot_va = adrp_page + ldr_offset
```
### Step 4验证区分逻辑是否仍有效
用打日志的方式验证 `+0x18` 字段是否仍然是 "wxid" 判断条件。如果新版本改变了消息对象结构,需要重新 dump 字段。
### Step 5更新 hook.m 中的地址常量
```c
static const uintptr_t kSlotVA = 0x新地址;
```
### Step 6测试
1. 对方撤回 → 消息保留
2. 自己撤回 → 不闪退
3. 正常收发消息 → 不受影响
---
## 六、踩过的坑
### 5.1 纯静态 patch 导致自己撤回闪退
直接 patch `isRevokeMessage()``MOV W0,#0; RET` 会导致自己撤回时崩溃。原因:
- 自己撤回后服务器回复确认(也是 type=10002
- 如果 `isRevokeMessage` 返回 false后续代码期望的上下文对象未被创建
- 访问 null 对象的 `+0x168` 字段 → SIGSEGV
### 5.2 `__DATA` BSS 区域不在文件中
Slot 地址在 `__DATA` segment 的 BSS 区域(`filesize < vmsize`运行时才存在不能通过静态修改文件来设置 slot 只能通过运行时内存写入
### 5.3 代码签名验证
修改 `wechat.dylib` 后必须重签名即使是恢复原始字节只要之前签名过一次哈希就已经变了每次修改后都要
```bash
codesign --force --sign - wechat.dylib
codesign --force --deep --sign - WeChat.app
```
### 5.4 `com.apple.provenance` 保护
App Store/官网下载的微信有 provenance 属性阻止任何修改解决
```bash
tar --no-xattrs -cf - -C /Applications WeChat.app | tar -xf - -C /tmp/
rm -rf /Applications/WeChat.app
mv /tmp/WeChat.app /Applications/WeChat.app
```
### 5.5 wechat.dylib 是 dlopen 加载的
hook dylib constructor 执行时 wechat.dylib 可能还未加载必须用 `dispatch_after` 延迟执行
### 5.6 macOS Sequoia `com.apple.provenance` 无法清除
macOS 15 (Sequoia) 在文件移入 `/Applications` 时会自动附加 `com.apple.provenance`即使用 `tar --no-xattrs` 重打包也无法永久清除
**解决方案**签名时注入 entitlements 绕过 Library Validation
```bash
codesign --force --sign - --entitlements ent.plist /Applications/WeChat.app/Contents/MacOS/WeChat
```
其中 `ent.plist` 包含
- `com.apple.security.cs.disable-library-validation` = true
- `com.apple.security.cs.allow-unsigned-executable-memory` = true
### 5.7 Frameworks/wechat.dylib 是 stub不能使用其 slide
4.1.10 中存在两个 `wechat.dylib`
- `Contents/Frameworks/wechat.dylib`16K stub
- `Contents/Resources/wechat.dylib`~147M 核心库
两者 ASLR slide 不同。`find_wechat_slide()` 必须优先匹配 Resources 路径否则用错误的 slide 计算函数地址会导致 hook 失败
### 5.8 仅匹配 "wxid" 前缀无法覆盖所有用户
旧版判断逻辑 `field18 == 0x64697877` 只在对方 ID "wxid_" 开头时生效自定义微信号 "a244573118"不以 "wxid" 开头会被误判为"自己撤回"而放行
**解决**读取当前登录用户 ID做完整字符串比较
### 5.9 用户 ID 读取时机
`hook_init` 延迟 1 秒时用户可能尚未完成登录特别是切换账号场景此时 `app_data/login/` 目录仍指向上一个账号
**解决**使用懒加载 `hook_isRevokeMessage` 首次收到 type=0x2712 消息时才读取用户 ID
### 5.10 在聊天界面内插入系统消息不可行
尝试过的方案
1. **Hook `is_revoke` 标记函数 (0x4736C10)**NOP 后消息仍然消失删除发生在标记之前
2. **NOP 两个 BLR 虚函数调用**导致后续代码空指针 crash
3. **调用 `0x4D5FD70`(插入本地消息函数)**参数是栈上复杂 C++ 结构体需要 session 对象和消息内容对象构造极困难
4. **数据库直接操作**微信使用 SQLCipher 加密无法直接 INSERT
微信撤回的真实流程
```
isRevokeMessage=1 → BLR 虚函数UPDATE msg_type=10002, message_content=replacemsg→ 标记 is_revoke=1
```
**结论**无法稳定地在聊天界面内插入提示最终方案为 `isRevokeMessage` 返回 0阻止撤回+ macOS 本地通知提醒用户
---
## 七、关键工具和命令
```bash
# 查看微信版本
defaults read /Applications/WeChat.app/Contents/Info.plist CFBundleVersion
# 查看 FAT binary 信息
lipo -detailed_info /Applications/WeChat.app/Contents/Resources/wechat.dylib
# 查看 segment 信息
otool -arch arm64 -l /tmp/wechat_arm64
# 搜索字符串
strings /tmp/wechat_arm64 | grep -i revoke
# 反汇编(需要 capstone
pip3 install capstone
# 编译 hook dylib
clang -arch arm64 -arch x86_64 -shared -framework Foundation -o hook.dylib hook.m
# 签名
codesign --force --deep --sign - /Applications/WeChat.app
```
---
## 八、消息对象结构(已知字段)
基于 v268602 / v268824 版本的实验结果
| 偏移 | 类型 | 含义 |
|------|------|------|
| +0x00 | ptr | 可能是 vptr 或引用计数 |
| +0x04 | int32 | 固定为 1 |
| +0x0C | int32 | **消息主类型**10002=撤回) |
| +0x10 | int32 | 消息子类型用于 type=0x31 的消息 |
| +0x18 | char[] | **撤回操作发起者 ID**std::string SSO buffer直接存储字符内容|
| +0x18+len+pad | char[] | 会话对方 ID |
| +0x90 | - | 未初始化标记区 |
| +0xB8 | float | 固定 1.0 (0x3F800000) |
| +0xF4 | int32 | 始终为 0不是方向字段 |
### 区分自己/对方的关键4.1.10 最终方案)
`[msg+0x18]` 存储的是**执行撤回操作的人的完整 ID**C 字符串SSO 模式直接存储)。
判断逻辑
1. `[msg+0x18]` 为空首字节=0 自己撤回确认 放行
2. `[msg+0x18]` == 当前登录用户 ID 自己撤回 放行
3. 其他 对方撤回 阻止
**注意**旧方案仅判断前 4 字节是否为 "wxid"在用户自定义微信号 wxid_ 开头时失效
必须用完整字符串匹配
### 获取当前登录用户 ID
从以下目录读取最近修改的子目录名
```
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/login/
```
该目录下每个子目录名即为一个曾登录的用户 ID `key_info.dat` 的修改时间排序最新的即当前登录用户
**关键**必须使用懒加载在首次收到撤回消息时才读取避免在登录完成前读取到上一个账号
### msg+0x18 内存布局实例
**自己撤回**用户 ID = "a244573118"
```
+0x18: 61 32 34 34 35 37 33 31 31 38 00 00 00 00 00 00 a244573118......
+0x28: 00 00 00 00 00 00 00 0a 77 78 69 64 5f 6f 6f 66 ........wxid_oof
```
**对方撤回**对方 ID = "wxid_zx0ckqw4o4u022"
```
+0x18: 77 78 69 64 5f 7a 78 30 63 6b 71 77 34 6f 34 75 wxid_zx0ckqw4o4u
+0x28: 30 32 32 00 00 00 00 13 61 32 34 34 35 37 33 31 022.....a2445731
```
### msg+0x130 XML bodystd::string 堆分配)
撤回消息的完整 XML body `msg+0x130` `std::string` 堆分配模式
| 偏移 | 类型 | 含义 |
|------|------|------|
| +0x130 | uint64 | 指针指向堆上的字符串内容 |
| +0x138 | uint64 | 字符串长度 |
| +0x140 | uint64 | 容量最高位 0x80 = 堆分配标志) |
XML 格式
```xml
<sysmsg type="revokemsg">
<revokemsg>
<session>wxid_xxx</session>
<msgid>333777384</msgid>
<newmsgid>2651126785189248779</newmsgid>
<replacemsg><![CDATA["用户昵称" 撤回了一条消息]]></replacemsg>
</revokemsg>
</sysmsg>
```
**可提取信息**
- `<replacemsg>` 中的 CDATA 内容包含**用户昵称** ID
- `<msgid>` 是被撤回的原消息 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 trampoline20字节覆盖函数体 | func VA = `0x44FFE20` |
| 4.1.9/4.1.10 x86_64 | inline trampoline16字节 | `0x4AF08D0` / `0x4B4E9A0` |
### 通知实现
通过 `osascript` 执行 AppleScript 发送 macOS 本地通知
1. `msg+0x130` XML body 中提取 `<![CDATA[...]]>` 内容包含用户昵称
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('<IIIII', 0xB9400C08, 0x5284E249, 0x6B09011F, 0x1A9F17E0, 0xD65F03C0)
idx = data.find(pattern)
print(f"isRevokeMessage VA: 0x{idx:X}")
```
### Step 4确认函数结构
检查函数是否有 dispatch slot前面有 ADRP+LDR+CBZ+BR
- 使用 slot 方式记录 slot VA
- 使用 inline trampoline 方式
### Step 5确认 wechat.dylib 路径
检查 wechat.dylib 是在 `Contents/Resources/` 还是 `Contents/Frameworks/`
`find_wechat_slide()` 需要优先匹配正确的路径
### Step 6验证消息对象结构
`./patch.sh --debug` + lldb 验证
- `msg+0x0C` 仍然是 msgType 字段
- `msg+0x18` 仍然是 sender IDSSO 字符串
- `msg+0x130` 仍然是 XML bodystd::string 堆分配
```lldb
lldb -p $(pgrep -x WeChat)
image list wechat.dylib
# slide = Resources 行地址
br set -a <slide+新函数VA> -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-savedcontent 字段已就绪
**断点位置选择**:函数入口 + 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-savedhook 时机已是 `mov x19, x1` 之后)取消息对象指针。
### 13.7 文件 IPC 协议
**路径**`/tmp/wechat_msg_cache.tsv`
**行格式**
```
<svrid_decimal>\t<from>\t<content>\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 `<newmsgid>`
- `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
<key>RunAtLoad</key><true/> <!-- 登录后立即拉起 -->
<key>KeepAlive</key><true/> <!-- 异常退出自动重启 -->
<key>ThrottleInterval</key><integer>10</integer> <!-- 重启间隔 ≥10s 防 CPU 占满 -->
```
### 13.10 entitlements 要求
为让 lldb attach 微信签名时必须带 `get-task-allow`
```xml
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.get-task-allow</key><true/> <!-- 新增 -->
```
`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 <addr>` 重新探测 +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.10a244573118 账号登录
```
msg_obj + 0x40 → ptr8字节指针值
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 段全局空 stringdp=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 未运行时 |