From a39fbc91b5bfba85a366f57d2bb08b2ffdf43d5b Mon Sep 17 00:00:00 2001 From: zhangyang131 Date: Thu, 11 Jun 2026 10:05:42 +0800 Subject: [PATCH] fix --- doc/reverse-engineering-guide.md | 1137 ------------------------------ patch.sh | 22 +- 2 files changed, 21 insertions(+), 1138 deletions(-) delete mode 100644 doc/reverse-engineering-guide.md mode change 100755 => 100644 patch.sh diff --git a/doc/reverse-engineering-guide.md b/doc/reverse-engineering-guide.md deleted file mode 100644 index 6382b72..0000000 --- a/doc/reverse-engineering-guide.md +++ /dev/null @@ -1,1137 +0,0 @@ -# 微信 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 old mode 100755 new mode 100644 index 8638a7d..21075e4 --- a/patch.sh +++ b/patch.sh @@ -223,7 +223,27 @@ _Bool hook_isRevokeMessage(void *msg) { load_my_user_id(); - const char *sender = (const char *)((uint8_t *)msg + 0x18); + // 动态寻址 sender 字符串:+0x18 在新 build 中可能是标记字节 + // 从 +0x18 开始搜第一个可打印 C 字符串 + int sender_off = 0x18; + { + int found = 0; + for (int off = 0x18; off <= 0x20; off++) { + const unsigned char *p = (const unsigned char *)msg + off; + if (p[0] >= 0x20 && p[0] <= 0x7E && + p[1] >= 0x20 && p[1] <= 0x7E && + p[2] >= 0x20 && p[2] <= 0x7E) { + sender_off = off; + found = 1; + break; + } + } + if (!found) { + // 极端 fallback 仍然用 +0x18 + sender_off = 0x18; + } + } + const char *sender = (const char *)((uint8_t *)msg + sender_off); if (!is_valid_sender(sender)) { ARLOG("WARN: sender 区域非可打印 ASCII,跳过此次调用");