Commit b4a635a1a730ee2ec24960fb28f15e2b5aa56a2f
1 parent
9efb9656
feat(wechathook): 修复 Hermes 破坏的平板登录 + 入库 do_not_break 防护文档
Hermes 重编 APK 时连踩三坑导致平板登录失效: 1. 用 XposedBridgeApi.jar 而非 LSPosed legacy JAR 编译 → 所有 hook NoSuchMethodError 2. xposed_init 放在 APK 根目录而非 assets/ → daemon 验证失败每次重启自动删模块 DB 注册 3. 换了 keystore → INSTALL_FAILED_UPDATE_INCOMPATIBLE 修复:从 wechathook_v2_signed.apk(正确 legacy JAR 编译)提取 DEX 复用,重打包含正确 assets/xposed_init,debug.keystore 签名,fix_lspd_final.py 重建 DB 注册。 2026-07-04 重启后验证平板登录恢复正常,所有 tablet hook 日志无 NoSuchMethodError。 新增文件: - wechathook_v2_signed.apk: 黄金基准 APK(legacy JAR 编译,DEX 可复用) - do_not_break.md: 给 Hermes 的禁止事项清单 - hermes_task.md: 已在顶部加 DO NOT BREAK 警告 banner - analyze_wechathook.py: DEX 字节码分析工具(Tinker patch) - rebuild_wechathook.sh / update_wechathook_db.sh: Mac 侧重建脚本 - termux_build.sh: Hermes 的手机侧 build script(有编译 JAR bug,留作参考) - wechat_send_internal/: Hermes 对 WeChat 内部发送 API 的研究产出(进行中) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
11 changed files
with
1680 additions
and
0 deletions
wechat_send_internal/RESEARCH.md
0 → 100644
| 1 | +# WeChat 非 A11y 发送消息 — 逆向研究报告 | ||
| 2 | + | ||
| 3 | +目标:WeChat 8.0.74 / Android 16 / Pixel 10 Pro Fold (rooted + LSPosed JingMatrix Vector v1.11.0) | ||
| 4 | +约束:不封号,Research only,未触发真实发送 | ||
| 5 | + | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +## 一、三条路线结论 | ||
| 9 | + | ||
| 10 | +| 路线 | 原理 | 可行性 | 理由 | | ||
| 11 | +|---|---|---|---| | ||
| 12 | +| **Route A** | LSPosed hook 调用 WeChat 内部 `NetSceneSendMsg` API | ✅ **可行** | 已找到完整调用链,同正常用户发送路径完全一致 | | ||
| 13 | +| **Route B** | Hook 网络层(OkHttp/SSL 拦截) | ❌ **不可行** | 消息发送走 MMTLS(WeChat 自定义加密 TCP),不经 OkHttp/HTTPS | | ||
| 14 | +| **Route C** | 直写 SQLite `message` 表 | ❌ **不可行** | DB 写入只是本地状态,不触发网络发送,消息对方收不到 | | ||
| 15 | + | ||
| 16 | +--- | ||
| 17 | + | ||
| 18 | +## 二、Route A — 完整调用链(逆向结果) | ||
| 19 | + | ||
| 20 | +### 2.1 关键类(DEX 位置) | ||
| 21 | + | ||
| 22 | +| 类名 | DEX | 用途 | | ||
| 23 | +|---|---|---| | ||
| 24 | +| `w11.s1` | classes9.dex | `SendMsgCgiFactory` 工厂入口(static `a(talker)`)| | ||
| 25 | +| `w11.r1` | classes11.dex | Builder —— 设置 talker/content/type/pipeline | | ||
| 26 | +| `w11.p1` | classes9.dex | PPC Pipeline 枚举:`TEXT(d)` / `IMAGE(e)` / `VIDEO(f)` / ... | | ||
| 27 | +| `w11.r0` | classes12.dex | `NetSceneSendMsg` —— 写 DB + 触发网络发送 | | ||
| 28 | +| `w11.c1` | classes5.dex | `RevokeMsgManager` —— 发送前的撤回状态检查 | | ||
| 29 | +| `dk5.s5` | classes9.dex | `SendMsgMgr` —— 高层 API(`Ni(content,talker,isGroup,kl)`)| | ||
| 30 | +| `tg3.t1` | classes.dex | 服务定位器入口 —— `t1.a()` 返回 `dk5.s5` 单例 | | ||
| 31 | +| `com.tencent.mm.storage.f9` | - | 消息存储对象(写入 `message` 表的实体)| | ||
| 32 | + | ||
| 33 | +### 2.2 调用链(文本消息) | ||
| 34 | + | ||
| 35 | +``` | ||
| 36 | +触发方(BroadcastReceiver in WeChat process) | ||
| 37 | + │ | ||
| 38 | + ├─ [方案 A1 - 直接 Factory] | ||
| 39 | + │ w11.s1.a(talker) → 创建 w11.r1(l=p1.TEXT) | ||
| 40 | + │ .e(content) → 设置文本内容(fields: d) | ||
| 41 | + │ .b() → executeByPPC,无回调 | ||
| 42 | + │ | ||
| 43 | + ├─ [方案 A2 - 高层 API] | ||
| 44 | + │ (dk5.s5) tg3.t1.a() | ||
| 45 | + │ .Ni(content, talker, false, null) → 1 对 1 文本 | ||
| 46 | + │ .Ni(content, talker, true, null) → 群聊文本 | ||
| 47 | + │ | ||
| 48 | + └─ 两种方案最终都走: | ||
| 49 | + w11.r0(talker, content, type=1, ...) | ||
| 50 | + ├─ f9.setType(1) → message.type = 1(文本) | ||
| 51 | + ├─ x3.bj().N9(f9, true) → 写入 message 表(status=SENDING=1) | ||
| 52 | + └─ r0.H() → r0.doScene() | ||
| 53 | + ├─ lVar.c = "/cgi-bin/micromsg-bin/newsendmsg" | ||
| 54 | + ├─ lVar.d = 522 (cmdId) | ||
| 55 | + └─ dispatch() → MMTLS TCP 二进制 protobuf | ||
| 56 | +``` | ||
| 57 | + | ||
| 58 | +### 2.3 `w11.r1` Builder 字段映射 | ||
| 59 | + | ||
| 60 | +```java | ||
| 61 | +r1.a = "" // 未知(sender相关?) | ||
| 62 | +r1.b = "" // talker(通过 g(talker) 设置) | ||
| 63 | +r1.d = "" // content(通过 e(content) 设置) | ||
| 64 | +r1.e = 1 // 消息类型(默认=1文本;通过 h(type) 设置) | ||
| 65 | +r1.j = new h7() // 消息附加信息 | ||
| 66 | +r1.l = p1.TEXT // PPC Pipeline 类型(s1.a() 默认设为 TEXT) | ||
| 67 | +``` | ||
| 68 | + | ||
| 69 | +### 2.4 `doScene()` CGI 参数(在 w11.r0 中) | ||
| 70 | + | ||
| 71 | +```java | ||
| 72 | +lVar.c = "/cgi-bin/micromsg-bin/newsendmsg"; // CGI 路径 | ||
| 73 | +lVar.d = 522; // CmdId | ||
| 74 | +lVar.e = 237; // SubCmdId | ||
| 75 | +lVar.f = 1000000237; // FullCmdId | ||
| 76 | +``` | ||
| 77 | + | ||
| 78 | +--- | ||
| 79 | + | ||
| 80 | +## 三、Route B 为何不可行 | ||
| 81 | + | ||
| 82 | +WeChat 手机端消息**不走 HTTPS/OkHttp**。发送路径: | ||
| 83 | +- 持久 TCP 长连接 → MMTLS 加密(WeChat 自研,类似 TLS 但不是 TLS)→ 二进制 protobuf | ||
| 84 | +- OkHttp 只处理微信里的 Finder(视频号)等 HTTP CGI 业务,不处理 IM 消息 | ||
| 85 | +- SSL Unpinning(Frida/LSPosed)只能解 TLS,对 MMTLS 无效 | ||
| 86 | +- 如要 hook 网络层,需要逆向 `libtencent_mars.so`(Mars 框架的 MMTLS native 实现)——极复杂,不在此路线 | ||
| 87 | + | ||
| 88 | +--- | ||
| 89 | + | ||
| 90 | +## 四、Route C 为何不可行 | ||
| 91 | + | ||
| 92 | +从 `w11.r0` 构造函数逆向结果: | ||
| 93 | +1. 构造时写入 `message` 表(status = SENDING = 1) | ||
| 94 | +2. 然后调用 `doScene()` 发起 MMTLS 网络请求 | ||
| 95 | +3. 成功回调后更新 status = SENT = 2 | ||
| 96 | + | ||
| 97 | +如果直接写 SQLite: | ||
| 98 | +- 写 status=SENT(2):本地显示已发,但对方服务器从未收到,**对方收不到** | ||
| 99 | +- 写 status=SENDING(1):消息卡在"发送中",WeChat 会尝试重发,但重发路径依赖正常 send 逻辑,**不一定触发** | ||
| 100 | +- 结论:直写 DB 无法完成实际消息投递 | ||
| 101 | + | ||
| 102 | +--- | ||
| 103 | + | ||
| 104 | +## 五、Route A 实现方案 | ||
| 105 | + | ||
| 106 | +### 5.1 架构 | ||
| 107 | + | ||
| 108 | +在 LSPosed 模块中,于 `handleLoadPackage` 里注册一个 `BroadcastReceiver`(在 WeChat 进程内), | ||
| 109 | +监听 `com.hermes.wechat.SEND_MSG_INTERNAL`。外部通过 `adb shell am broadcast` 触发。 | ||
| 110 | + | ||
| 111 | +```java | ||
| 112 | +// 在 WeChat 进程内注册 receiver | ||
| 113 | +context.registerReceiver(new BroadcastReceiver() { | ||
| 114 | + public void onReceive(Context ctx, Intent intent) { | ||
| 115 | + String talker = intent.getStringExtra("to"); // wxid_xxx 或 xxx@chatroom | ||
| 116 | + String content = intent.getStringExtra("text"); | ||
| 117 | + sendTextViaInternalApi(talker, content); | ||
| 118 | + } | ||
| 119 | +}, new IntentFilter("com.hermes.wechat.SEND_MSG_INTERNAL"), | ||
| 120 | + Context.RECEIVER_NOT_EXPORTED); | ||
| 121 | +``` | ||
| 122 | + | ||
| 123 | +### 5.2 发送逻辑(Xposed 内) | ||
| 124 | + | ||
| 125 | +```java | ||
| 126 | +private static void sendTextViaInternalApi(String talker, String content) { | ||
| 127 | + // 必须在主线程执行(WeChat 的 DB/网络操作绑定主线程) | ||
| 128 | + new Handler(Looper.getMainLooper()).post(() -> { | ||
| 129 | + try { | ||
| 130 | + // 方案 A1(直接 Factory,更底层) | ||
| 131 | + ClassLoader cl = /* lpparam.classLoader */; | ||
| 132 | + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl); | ||
| 133 | + Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker); | ||
| 134 | + XposedHelpers.callMethod(r1, "e", content); // set content | ||
| 135 | + XposedHelpers.callMethod(r1, "b"); // execute | ||
| 136 | + XposedBridge.log("[WeChatHook] Internal send queued: " + talker); | ||
| 137 | + } catch (Throwable t) { | ||
| 138 | + XposedBridge.log("[WeChatHook] Internal send failed: " + t); | ||
| 139 | + } | ||
| 140 | + }); | ||
| 141 | +} | ||
| 142 | +``` | ||
| 143 | + | ||
| 144 | +### 5.3 Talker ID 格式 | ||
| 145 | + | ||
| 146 | +| 场景 | talker 格式 | 示例 | | ||
| 147 | +|---|---|---| | ||
| 148 | +| 1 对 1 | `wxid_` 开头或 `<phone>@stranger` | `wxid_abc123` | | ||
| 149 | +| 群聊 | `<数字>@chatroom` | `12345678901@chatroom` | | ||
| 150 | + | ||
| 151 | +从 `EnMicroMsg.db` 查询: | ||
| 152 | +```sql | ||
| 153 | +-- 查 1 对 1 的 talker | ||
| 154 | +SELECT username FROM rcontact WHERE nickname = '目标昵称' LIMIT 1; | ||
| 155 | +-- 查群 talker | ||
| 156 | +SELECT roomname FROM chatroom WHERE roomdisplayname = '群名' LIMIT 1; | ||
| 157 | +``` | ||
| 158 | + | ||
| 159 | +### 5.4 封号风险评估 | ||
| 160 | + | ||
| 161 | +- **Route A 的发送路径与正常用户操作完全一致**:经过同样的 DB 写入、同样的 CGI 请求、同样的 MMTLS 加密 | ||
| 162 | +- 风险主要来自行为模式(频率、时间、内容),而非技术路径 | ||
| 163 | +- 建议:单次发送、不高频、不夜间大量发送 | ||
| 164 | + | ||
| 165 | +--- | ||
| 166 | + | ||
| 167 | +## 六、AOT devirtualization 风险(来自平板登录 hook 经验) | ||
| 168 | + | ||
| 169 | +WeChat 自身的虚方法(如 `onCreate`/`initView`)在 AOT 编译后绑死直接跳转,Xposed hook 注册成功但不触发。 | ||
| 170 | + | ||
| 171 | +Route A 不受此影响,因为: | ||
| 172 | +- `w11.s1.a()` 是**静态方法**,无 vtable 查找 | ||
| 173 | +- `w11.r1.e()/.b()` 是在 r1 新建对象上调用,引用明确,不经 vtable | ||
| 174 | +- 触发 Receiver 的 `android.content.BroadcastReceiver.onReceive()` 是跨 APK 调用,框架层无法被 WeChat AOT 内联 | ||
| 175 | + | ||
| 176 | +即:Route A 的 hook 链路天然对 AOT devirtualization 免疫。 | ||
| 177 | + | ||
| 178 | +--- | ||
| 179 | + | ||
| 180 | +## 七、后续:图片发送 | ||
| 181 | + | ||
| 182 | +从 `p1` 枚举可知 pipeline 类型还有 `IMAGE(e)` / `VIDEO(f)` / `CDN_IMAGE(g)`。 | ||
| 183 | +图片发送需先上传图片到 WeChat CDN 获取 `attachid`,然后再构造 app 消息(type=3/43)。 | ||
| 184 | + | ||
| 185 | +已验证路径(来自 `wechat-image-sender` Hermes skill,见 CLAUDE.md): | ||
| 186 | +`am start -a android.intent.action.SEND -t image/png` → WeChat UI 选择联系人 → 固定坐标点发送 | ||
| 187 | + | ||
| 188 | +Route A 图片发送尚未逆向,留待后续。 |
wechat_send_internal/WeChatSendHook.java
0 → 100644
| 1 | +package com.example.wechathook; | ||
| 2 | + | ||
| 3 | +import android.content.BroadcastReceiver; | ||
| 4 | +import android.content.Context; | ||
| 5 | +import android.content.Intent; | ||
| 6 | +import android.content.IntentFilter; | ||
| 7 | +import android.os.Handler; | ||
| 8 | +import android.os.Looper; | ||
| 9 | +import de.robv.android.xposed.IXposedHookLoadPackage; | ||
| 10 | +import de.robv.android.xposed.XposedBridge; | ||
| 11 | +import de.robv.android.xposed.XposedHelpers; | ||
| 12 | +import de.robv.android.xposed.callbacks.XC_LoadPackage; | ||
| 13 | + | ||
| 14 | +/** | ||
| 15 | + * WeChatSendHook: Route A — 通过 WeChat 内部 API 发送文本消息。 | ||
| 16 | + * | ||
| 17 | + * 调用链(逆向 WeChat 8.0.74): | ||
| 18 | + * 外部 am broadcast → BroadcastReceiver (WeChat 进程内) | ||
| 19 | + * → w11.s1.a(talker) → w11.r1 (SendMsgCgiFactory, p1.TEXT pipeline) | ||
| 20 | + * → r1.e(content) → 设置文本 | ||
| 21 | + * → r1.b() → executeByPPC | ||
| 22 | + * → w11.r0(talker, content, type=1, ...) | ||
| 23 | + * → message 表写入 (status=SENDING) | ||
| 24 | + * → /cgi-bin/micromsg-bin/newsendmsg over MMTLS | ||
| 25 | + * | ||
| 26 | + * 触发: | ||
| 27 | + * adb shell am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL \ | ||
| 28 | + * --es to "wxid_xxx" --es text "Hello from hook" | ||
| 29 | + * | ||
| 30 | + * Talker 格式: | ||
| 31 | + * 1 对 1: wxid_xxx (对方 WeChat ID) | ||
| 32 | + * 群聊: 12345678901@chatroom | ||
| 33 | + * | ||
| 34 | + * 注意:发送在 WeChat 主线程执行,与用户正常发送路径完全一致。 | ||
| 35 | + * 不触发 A11y,不走 UI 自动化,消息经 MMTLS 加密发出。 | ||
| 36 | + * | ||
| 37 | + * 类位置(WeChat 8.0.74 多 DEX): | ||
| 38 | + * w11.s1 — classes9.dex | ||
| 39 | + * w11.r1 — classes11.dex | ||
| 40 | + * w11.r0 — classes12.dex | ||
| 41 | + * p1.TEXT — classes9.dex (enum value 'd') | ||
| 42 | + */ | ||
| 43 | +public class WeChatSendHook implements IXposedHookLoadPackage { | ||
| 44 | + | ||
| 45 | + private static final String ACTION = "com.hermes.wechat.SEND_MSG_INTERNAL"; | ||
| 46 | + private static ClassLoader sWeChat; | ||
| 47 | + | ||
| 48 | + @Override | ||
| 49 | + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { | ||
| 50 | + if (!"com.tencent.mm".equals(lpparam.packageName)) return; | ||
| 51 | + // Only hook the main process (not :push/:tools sub-processes) | ||
| 52 | + if (!lpparam.processName.equals("com.tencent.mm")) return; | ||
| 53 | + | ||
| 54 | + sWeChat = lpparam.classLoader; | ||
| 55 | + XposedBridge.log("[WeChatSendHook] Injected, registering receiver"); | ||
| 56 | + | ||
| 57 | + // Hook Application.onCreate to get Context for receiver registration. | ||
| 58 | + // android.app.Application.onCreate() is a framework call — never AOT-inlined. | ||
| 59 | + XposedHelpers.findAndHookMethod( | ||
| 60 | + "android.app.Application", null, | ||
| 61 | + "onCreate", | ||
| 62 | + new de.robv.android.xposed.XC_MethodHook() { | ||
| 63 | + @Override | ||
| 64 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 65 | + Context ctx = (Context) param.thisObject; | ||
| 66 | + if (ctx.getPackageName().equals("com.tencent.mm")) { | ||
| 67 | + registerSendReceiver(ctx); | ||
| 68 | + } | ||
| 69 | + } | ||
| 70 | + }); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + private static void registerSendReceiver(Context ctx) { | ||
| 74 | + BroadcastReceiver receiver = new BroadcastReceiver() { | ||
| 75 | + @Override | ||
| 76 | + public void onReceive(Context context, Intent intent) { | ||
| 77 | + String talker = intent.getStringExtra("to"); | ||
| 78 | + String text = intent.getStringExtra("text"); | ||
| 79 | + if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { | ||
| 80 | + XposedBridge.log("[WeChatSendHook] Received intent missing 'to' or 'text'"); | ||
| 81 | + return; | ||
| 82 | + } | ||
| 83 | + XposedBridge.log("[WeChatSendHook] Queuing send to=" + talker + " len=" + text.length()); | ||
| 84 | + sendOnMainThread(talker, text); | ||
| 85 | + } | ||
| 86 | + }; | ||
| 87 | + IntentFilter filter = new IntentFilter(ACTION); | ||
| 88 | + // NOT_EXPORTED (0x4) — only accept from root/same-uid callers via am broadcast | ||
| 89 | + ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); | ||
| 90 | + XposedBridge.log("[WeChatSendHook] Receiver registered for " + ACTION); | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + private static void sendOnMainThread(String talker, String text) { | ||
| 94 | + // WeChat's send path touches SQLite and the network dispatcher from the main thread. | ||
| 95 | + new Handler(Looper.getMainLooper()).post(() -> { | ||
| 96 | + try { | ||
| 97 | + sendTextInternal(talker, text); | ||
| 98 | + } catch (Throwable t) { | ||
| 99 | + XposedBridge.log("[WeChatSendHook] Send failed: " + t); | ||
| 100 | + } | ||
| 101 | + }); | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + /** | ||
| 105 | + * Core send via w11.s1 factory (SendMsgCgiFactory): | ||
| 106 | + * s1.a(talker) → r1 with pipeline=p1.TEXT, talker pre-set | ||
| 107 | + * r1.e(content) → set field d (content) | ||
| 108 | + * r1.b() → executeByPPC (no callback) | ||
| 109 | + * | ||
| 110 | + * r1 field mapping (WeChat 8.0.74): | ||
| 111 | + * field b = talker (set by g()) | ||
| 112 | + * field d = content (set by e()) | ||
| 113 | + * field e = type (default 1=text, set by h()) | ||
| 114 | + * field l = pipeline (set to p1.TEXT by s1.a()) | ||
| 115 | + */ | ||
| 116 | + private static void sendTextInternal(String talker, String text) throws Throwable { | ||
| 117 | + Class<?> s1 = XposedHelpers.findClass("w11.s1", sWeChat); | ||
| 118 | + // s1.a(talker) → r1 with g(talker) pre-called and l=p1.TEXT | ||
| 119 | + Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker); | ||
| 120 | + // r1.e(content) → sets field d | ||
| 121 | + XposedHelpers.callMethod(r1, "e", text); | ||
| 122 | + // r1.b() → d(this,null,1,null) → c(q1.d) → executeByPPC | ||
| 123 | + XposedHelpers.callMethod(r1, "b"); | ||
| 124 | + XposedBridge.log("[WeChatSendHook] Sent: to=" + talker); | ||
| 125 | + } | ||
| 126 | +} |
wechat_send_internal/test_send.sh
0 → 100644
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Test Route A send hook via adb | ||
| 3 | +# Usage: bash test_send.sh <wxid> "<text>" | ||
| 4 | +# Example: bash test_send.sh wxid_abc123 "Hello from hook" | ||
| 5 | +# | ||
| 6 | +# NOTE: This is a TEST SCRIPT — read-only / dry-run by default. | ||
| 7 | +# Set DRY_RUN=0 to actually trigger the broadcast. | ||
| 8 | + | ||
| 9 | +set -euo pipefail | ||
| 10 | + | ||
| 11 | +TALKER="${1:-}" | ||
| 12 | +TEXT="${2:-}" | ||
| 13 | +DRY_RUN="${DRY_RUN:-1}" # Default: print only, don't fire | ||
| 14 | + | ||
| 15 | +if [ -z "$TALKER" ] || [ -z "$TEXT" ]; then | ||
| 16 | + echo "Usage: DRY_RUN=0 bash test_send.sh <talker> '<text>'" | ||
| 17 | + echo " talker examples:" | ||
| 18 | + echo " wxid_abc123 (1-on-1)" | ||
| 19 | + echo " 12345678901@chatroom (group)" | ||
| 20 | + exit 1 | ||
| 21 | +fi | ||
| 22 | + | ||
| 23 | +CMD="adb shell am broadcast \ | ||
| 24 | + -a com.hermes.wechat.SEND_MSG_INTERNAL \ | ||
| 25 | + --es to \"$TALKER\" \ | ||
| 26 | + --es text \"$TEXT\"" | ||
| 27 | + | ||
| 28 | +echo "Command to run:" | ||
| 29 | +echo " $CMD" | ||
| 30 | +echo "" | ||
| 31 | + | ||
| 32 | +if [ "$DRY_RUN" = "1" ]; then | ||
| 33 | + echo "[DRY RUN] Not executing. Set DRY_RUN=0 to actually send." | ||
| 34 | +else | ||
| 35 | + echo "[SENDING] Executing broadcast..." | ||
| 36 | + eval "$CMD" | ||
| 37 | + echo "" | ||
| 38 | + echo "Check logcat for: [WeChatSendHook]" | ||
| 39 | + echo " adb logcat -s Xposed:D | grep WeChatSendHook" | ||
| 40 | +fi |
xhs_lsposed_a16_fix/analyze_wechathook.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +analyze_wechathook.py — 从 Tinker patch DEX 分析 WeChat 8.0.74 消息发送路径 | ||
| 4 | +在 Termux 上运行: python3 /sdcard/wechathook/analyze_wechathook.py | ||
| 5 | + | ||
| 6 | +目标: | ||
| 7 | +1. 解码 w11.r0 构造器字节码,看它调了什么 | ||
| 8 | +2. 解码 gm0.j1.i() 字节码,确认它检查的是字段 m | ||
| 9 | +3. 检查 gm0.j1.m 在运行时是否 null(通过分析 gm0.j1.g() 是否被调用) | ||
| 10 | +""" | ||
| 11 | +import struct, os, subprocess | ||
| 12 | + | ||
| 13 | +# Tinker patch DEX (需要有 root 才能访问) | ||
| 14 | +TINKER_APK = "/data/user/0/com.tencent.mm/tinker/patch-cf87f687/dex/tinker_classN.apk" | ||
| 15 | +WORK_DIR = "/data/local/tmp/wh_dex_analysis" | ||
| 16 | + | ||
| 17 | +def ensure_dex(): | ||
| 18 | + os.makedirs(WORK_DIR, exist_ok=True) | ||
| 19 | + dex = f"{WORK_DIR}/classes12.dex" | ||
| 20 | + if not os.path.exists(dex): | ||
| 21 | + # 需要 root 拷贝 | ||
| 22 | + r = subprocess.run(["su", "-c", f"cp {TINKER_APK} /data/local/tmp/_tinker.apk && chmod 644 /data/local/tmp/_tinker.apk"], | ||
| 23 | + capture_output=True, text=True) | ||
| 24 | + if r.returncode != 0: | ||
| 25 | + print("需要 root 权限:", r.stderr) | ||
| 26 | + return None | ||
| 27 | + import zipfile | ||
| 28 | + with zipfile.ZipFile("/data/local/tmp/_tinker.apk") as z: | ||
| 29 | + z.extract("classes12.dex", WORK_DIR) | ||
| 30 | + print(f"Extracted classes12.dex ({os.path.getsize(dex)} bytes)") | ||
| 31 | + return dex | ||
| 32 | + | ||
| 33 | +def uleb128(data, off): | ||
| 34 | + r, s = 0, 0 | ||
| 35 | + while True: | ||
| 36 | + b = data[off]; off += 1 | ||
| 37 | + r |= (b & 0x7F) << s | ||
| 38 | + if not (b & 0x80): break | ||
| 39 | + s += 7 | ||
| 40 | + return r, off | ||
| 41 | + | ||
| 42 | +def parse_dex(path): | ||
| 43 | + data = open(path, "rb").read() | ||
| 44 | + h = lambda o: struct.unpack_from('<I', data, o)[0] | ||
| 45 | + ss, so = h(56), h(60) | ||
| 46 | + ts, to = h(64), h(68) | ||
| 47 | + fs, fo = h(80), h(84) | ||
| 48 | + ms, mo = h(88), h(92) | ||
| 49 | + cs, co = h(96), h(100) | ||
| 50 | + ps, po = h(72), h(76) | ||
| 51 | + | ||
| 52 | + def gs(i): | ||
| 53 | + if i >= ss: return f"?s{i}" | ||
| 54 | + off = h(so + i*4); _, o2 = uleb128(data, off) | ||
| 55 | + return data[o2:data.index(b'\x00', o2)].decode('utf-8', errors='replace') | ||
| 56 | + def gt(i): | ||
| 57 | + if i >= ts: return f"?t{i}" | ||
| 58 | + return gs(h(to + i*4)) | ||
| 59 | + def gp(pi): | ||
| 60 | + if pi >= ps: return "?proto" | ||
| 61 | + pb = po + pi*12 | ||
| 62 | + ret = gt(h(pb+4)); poff = h(pb+8); params = [] | ||
| 63 | + if poff: | ||
| 64 | + n = h(poff) | ||
| 65 | + for k in range(n): params.append(gt(struct.unpack_from('<H', data, poff+4+k*2)[0])) | ||
| 66 | + return f"({','.join(params)}){ret}" | ||
| 67 | + | ||
| 68 | + classes = {} | ||
| 69 | + for i in range(cs): | ||
| 70 | + base = co + i*32 | ||
| 71 | + ci = h(base) | ||
| 72 | + if ci >= ts: continue | ||
| 73 | + name = gt(ci) | ||
| 74 | + cdo = h(base+24) | ||
| 75 | + if cdo == 0: continue | ||
| 76 | + off = cdo | ||
| 77 | + sf, off = uleb128(data, off) | ||
| 78 | + inf, off = uleb128(data, off) | ||
| 79 | + dm, off = uleb128(data, off) | ||
| 80 | + vm, off = uleb128(data, off) | ||
| 81 | + | ||
| 82 | + fields, methods = [], [] | ||
| 83 | + fi = 0 | ||
| 84 | + for _ in range(sf+inf): | ||
| 85 | + d, off = uleb128(data, off); fi += d | ||
| 86 | + fl, off = uleb128(data, off) | ||
| 87 | + if fi < fs: | ||
| 88 | + fb = fo+fi*8 | ||
| 89 | + ft = struct.unpack_from('<H', data, fb+2)[0] | ||
| 90 | + fn = struct.unpack_from('<I', data, fb+4)[0] | ||
| 91 | + fields.append((gs(fn), gt(ft), fl)) | ||
| 92 | + | ||
| 93 | + mi = 0 | ||
| 94 | + for _ in range(dm+vm): | ||
| 95 | + d, off = uleb128(data, off); mi += d | ||
| 96 | + fl, off = uleb128(data, off) | ||
| 97 | + co2, off = uleb128(data, off) | ||
| 98 | + if mi < ms: | ||
| 99 | + mb = mo+mi*8 | ||
| 100 | + pi = struct.unpack_from('<H', data, mb+2)[0] | ||
| 101 | + mn = struct.unpack_from('<I', data, mb+4)[0] | ||
| 102 | + methods.append((gs(mn), gp(pi), fl, co2)) | ||
| 103 | + classes[name] = {'fields': fields, 'methods': methods} | ||
| 104 | + return classes, data, to, ts, fo, fs, mo, ms, po, ps, so, ss | ||
| 105 | + | ||
| 106 | +def dump_bytecode(data, code_off, max_insns=80): | ||
| 107 | + """简单 Dalvik 字节码 dump(只看调用指令)""" | ||
| 108 | + if code_off == 0: return " (abstract)" | ||
| 109 | + # code_item header: registers_size(2) ins_size(2) outs_size(2) tries_size(2) debug_info_off(4) insns_size(4) | ||
| 110 | + insns_size = struct.unpack_from('<I', data, code_off+12)[0] | ||
| 111 | + insns_off = code_off + 16 | ||
| 112 | + lines = [] | ||
| 113 | + i = 0 | ||
| 114 | + while i < insns_size and len(lines) < max_insns: | ||
| 115 | + op = data[insns_off + i*2] | ||
| 116 | + # Only show invoke-* and sget/sput/iget/iput | ||
| 117 | + if op in (0x6e, 0x6f, 0x70, 0x71, 0x72, # invoke-virtual, super, direct, static, interface | ||
| 118 | + 0x74, 0x75, 0x76, 0x77, 0x78, # invoke-virtual/range etc | ||
| 119 | + 0x60, 0x61, 0x62, 0x63, 0x64, # iget, iget-wide, iget-object, iget-bool, iget-byte | ||
| 120 | + 0x68, 0x69, 0x6a, 0x6b, 0x6c, # sget, sget-wide, sget-object, sget-bool, sget-byte | ||
| 121 | + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, # iput, iput-wide, iput-object, iput-bool, iput-byte | ||
| 122 | + 0x67, 0x69, 0x6b): # sput, sput-object, sput-bool | ||
| 123 | + ref = struct.unpack_from('<H', data, insns_off + i*2 + 2)[0] | ||
| 124 | + lines.append(f" +{i*2:04x} op=0x{op:02x} ref={ref}") | ||
| 125 | + i += 3 | ||
| 126 | + elif op in (0x0e, 0x0f, 0x10, 0x11): # return-void, return, return-wide, return-object | ||
| 127 | + lines.append(f" +{i*2:04x} return") | ||
| 128 | + break | ||
| 129 | + elif op == 0x27: # throw | ||
| 130 | + lines.append(f" +{i*2:04x} throw") | ||
| 131 | + i += 2 | ||
| 132 | + else: | ||
| 133 | + # Rough skip — most insns are 1-2 units | ||
| 134 | + fmt = (op >> 4) & 0xf | ||
| 135 | + i += 2 if op in range(0x02, 0x0e) or op in range(0x1a, 0x22) or op >= 0x26 else 1 | ||
| 136 | + return '\n'.join(lines) if lines else " (no invoke/field ops)" | ||
| 137 | + | ||
| 138 | +def main(): | ||
| 139 | + dex = ensure_dex() | ||
| 140 | + if not dex: | ||
| 141 | + return | ||
| 142 | + | ||
| 143 | + print("Parsing DEX...") | ||
| 144 | + classes, data, to, ts, fo, fs, mo, ms, po, ps, so, ss = parse_dex(dex) | ||
| 145 | + | ||
| 146 | + # ── 1. gm0.j1 structure ────────────────────────────────────────── | ||
| 147 | + print("\n" + "="*60) | ||
| 148 | + print("1. gm0.j1 class") | ||
| 149 | + print("="*60) | ||
| 150 | + j1 = classes.get("Lgm0/j1;") | ||
| 151 | + if j1: | ||
| 152 | + print("Static fields:") | ||
| 153 | + for name, typ, flags in j1['fields']: | ||
| 154 | + print(f" {name}: {typ} flags=0x{flags:x}") | ||
| 155 | + print("\nMethods with signatures:") | ||
| 156 | + for name, proto, flags, co2 in j1['methods']: | ||
| 157 | + flag_s = '+'.join(f for f, b in [('public',1),('static',8),('sync',0x20),('ctor',0x10000)] if flags&b) | ||
| 158 | + print(f" {name}: {proto} [{flag_s}]") | ||
| 159 | + else: | ||
| 160 | + print("NOT FOUND") | ||
| 161 | + | ||
| 162 | + # ── 2. w11.r0 structure ────────────────────────────────────────── | ||
| 163 | + print("\n" + "="*60) | ||
| 164 | + print("2. w11.r0 class") | ||
| 165 | + print("="*60) | ||
| 166 | + r0 = classes.get("Lw11/r0;") | ||
| 167 | + if r0: | ||
| 168 | + print("Static fields:") | ||
| 169 | + for name, typ, flags in r0['fields']: | ||
| 170 | + print(f" {name}: {typ}") | ||
| 171 | + print(f"\nConstructors (looking for (String,String,int,int,long)):") | ||
| 172 | + for name, proto, flags, co2 in r0['methods']: | ||
| 173 | + if name == "<init>": | ||
| 174 | + print(f" <init>{proto} code_off=0x{co2:x}") | ||
| 175 | + else: | ||
| 176 | + print("NOT FOUND") | ||
| 177 | + | ||
| 178 | + # ── 3. Decode gm0.j1.i() bytecode ─────────────────────────────── | ||
| 179 | + print("\n" + "="*60) | ||
| 180 | + print("3. gm0.j1.i() bytecode (key ops)") | ||
| 181 | + print("="*60) | ||
| 182 | + if j1: | ||
| 183 | + for name, proto, flags, co2 in j1['methods']: | ||
| 184 | + if name == "i" and "()Lgm0/j1;" in proto: | ||
| 185 | + print(f"gm0.j1.i() code_off=0x{co2:x}") | ||
| 186 | + print(dump_bytecode(data, co2)) | ||
| 187 | + break | ||
| 188 | + | ||
| 189 | + # ── 4. Decode w11.r0 (String,String,int,int,long) ctor ────────── | ||
| 190 | + print("\n" + "="*60) | ||
| 191 | + print("4. w11.r0 constructor bytecode (key invoke ops)") | ||
| 192 | + print("="*60) | ||
| 193 | + if r0: | ||
| 194 | + for name, proto, flags, co2 in r0['methods']: | ||
| 195 | + if name == "<init>" and "Ljava/lang/String;Ljava/lang/String;IIJ" in proto: | ||
| 196 | + print(f"<init>(String,String,int,int,long) code_off=0x{co2:x}") | ||
| 197 | + print(dump_bytecode(data, co2, max_insns=40)) | ||
| 198 | + break | ||
| 199 | + | ||
| 200 | + # ── 5. Who initializes gm0.j1.m? Search for sput-object on field m ─ | ||
| 201 | + print("\n" + "="*60) | ||
| 202 | + print("5. Searching gm0.j1.g() bytecode") | ||
| 203 | + print("="*60) | ||
| 204 | + if j1: | ||
| 205 | + for name, proto, flags, co2 in j1['methods']: | ||
| 206 | + if name == "g": | ||
| 207 | + print(f"gm0.j1.g{proto} code_off=0x{co2:x}") | ||
| 208 | + print(dump_bytecode(data, co2)) | ||
| 209 | + break | ||
| 210 | + | ||
| 211 | + print("\n== Done ==") | ||
| 212 | + | ||
| 213 | +if __name__ == "__main__": | ||
| 214 | + main() |
xhs_lsposed_a16_fix/do_not_break.md
0 → 100644
| 1 | +# WeChatHook — DO NOT BREAK 清单 | ||
| 2 | + | ||
| 3 | +## 当前状态(2026-07-04 已验证) | ||
| 4 | + | ||
| 5 | +已安装 APK: `com.example.wechathook` (versionCode=1) | ||
| 6 | +签名: `~/.android/debug.keystore` (CN=Android Debug,O=Android,C=US) | ||
| 7 | +APK 路径: `/data/app/~~UUcd24uZ9ch3W7ZmJpIxlQ==/com.example.wechathook-Jns-UMdjPSZgIGeeIqFAwQ==/base.apk` | ||
| 8 | +LSPosed DB: mid=42, scope=(42, com.tencent.mm, 0), enabled=1 | ||
| 9 | +功能: 微信平板登录 + WCDB hook + gm0.j1.g hook + w11.r0 hook | ||
| 10 | + | ||
| 11 | +## 绝对禁止事项 | ||
| 12 | + | ||
| 13 | +### 1. 不要用 XposedBridgeApi.jar 重新编译 | ||
| 14 | +LSPosed Vector 运行时用的是自己混淆的 XposedHelpers,标准 XposedBridgeApi.jar 编译出来的 | ||
| 15 | +APK 所有 findAndHookMethod 调用全部 NoSuchMethodError。 | ||
| 16 | + | ||
| 17 | +**必须用的 JAR**: `/tmp/LSPosed/legacy/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar` | ||
| 18 | +(在 Mac 上,Pixel 上没有这个 JAR) | ||
| 19 | + | ||
| 20 | +如果 Mac 上没有这个 JAR,**不要重新编译**。只改 Java 源码而不重编是可以的(改 /sdcard/wechathook/WeChatHook.java),但必须用正确 JAR 编译才能安装新版本。 | ||
| 21 | + | ||
| 22 | +### 2. APK 必须包含 assets/xposed_init | ||
| 23 | +打包时必须: `zip APK.apk assets/xposed_init ...`(注意 assets/ 子目录层级) | ||
| 24 | +文件内容: `com.example.wechathook.WeChatHook` | ||
| 25 | + | ||
| 26 | +LSPosed daemon 每次启动都验证这个文件。缺失 → 自动从 DB 删除模块 → 平板登录消失。 | ||
| 27 | + | ||
| 28 | +根目录的 `xposed_init`(不在 assets/ 下)**不够,daemon 不认**。 | ||
| 29 | + | ||
| 30 | +### 3. 不要换 keystore 签名 | ||
| 31 | +当前已安装版本用 `~/.android/debug.keystore`。 | ||
| 32 | +如果用不同 keystore 重签 → INSTALL_FAILED_UPDATE_INCOMPATIBLE → 必须先 uninstall(会丢失 LSPosed DB 注册)。 | ||
| 33 | + | ||
| 34 | +### 4. 不要直接改 LSPosed DB 而不做 WAL checkpoint | ||
| 35 | +改完必须: `PRAGMA wal_checkpoint(TRUNCATE)` + 把主 DB 拷覆盖 .bak .bak2 .bak3 .bak4 四个文件。 | ||
| 36 | + | ||
| 37 | +## 安全测试约束(严禁违反) | ||
| 38 | + | ||
| 39 | +**只用 filehelper(文件传输助手)测试发消息,绝对不能发给真实联系人。** | ||
| 40 | +微信对异常行为会封号,一旦封号无法找回。 | ||
| 41 | + | ||
| 42 | +## 如果你需要重新安装 APK | ||
| 43 | + | ||
| 44 | +```bash | ||
| 45 | +# 1. 备份 DB(万一要恢复) | ||
| 46 | +adb shell su -c 'cp /data/adb/lspd/config/modules_config.db /data/local/tmp/modules_backup.db' | ||
| 47 | + | ||
| 48 | +# 2. 卸载旧版本 | ||
| 49 | +adb shell pm uninstall com.example.wechathook | ||
| 50 | + | ||
| 51 | +# 3. 安装新版本(必须已按上面规则编译 + 打包 + 签名) | ||
| 52 | +adb install --no-incremental /path/to/new.apk | ||
| 53 | + | ||
| 54 | +# 4. 修复 DB(参考 /sdcard/wechathook/fix_lspd_final.py) | ||
| 55 | +adb shell su -c '/path/to/python3 /sdcard/wechathook/fix_lspd_final.py' | ||
| 56 | + | ||
| 57 | +# 5. 重启 | ||
| 58 | +adb shell su -c reboot | ||
| 59 | + | ||
| 60 | +# 6. 验证 | ||
| 61 | +adb shell su -c 'grep -i "WeChatHook\|hookInTablet\|NoSuchMethod" /data/adb/lspd/log/modules_*.log' | ||
| 62 | +``` | ||
| 63 | + | ||
| 64 | +## 验证钩子是否正常的命令 | ||
| 65 | + | ||
| 66 | +```bash | ||
| 67 | +adb shell su -c 'grep "\[WeChatHook\]" /data/adb/lspd/log/modules_*.log | head -30' | ||
| 68 | +``` | ||
| 69 | + | ||
| 70 | +正常输出应包含(无 NoSuchMethodError): | ||
| 71 | +- Hooked bk.B(x0) + bk.A() | ||
| 72 | +- Hooked Activity.onResume for tablet button | ||
| 73 | +- Hooked LoginPasswordUI.onCreate() | ||
| 74 | +- Hooked MobileInputUI.initView() / LoginUI.initView() | ||
| 75 | +- WCDB hook OK | ||
| 76 | +- gm0.j1.g hook OK |
xhs_lsposed_a16_fix/hermes_task.md
0 → 100644
| 1 | +# 任务:修通 WeChat Xposed 内部发送接口 | ||
| 2 | + | ||
| 3 | +## 🔒 安全约束(不得违反) | ||
| 4 | + | ||
| 5 | +**只能用 `filehelper`(文件传输助手)测试,绝对不能给真实联系人发消息。** | ||
| 6 | +测试命令固定格式: | ||
| 7 | +```bash | ||
| 8 | +su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "filehelper" --es text "测试"' | ||
| 9 | +``` | ||
| 10 | + | ||
| 11 | +--- | ||
| 12 | + | ||
| 13 | +## 目标 | ||
| 14 | + | ||
| 15 | +让 LSPosed 模块 `com.example.wechathook` 能通过 broadcast 真正把消息发进微信 message 数据库(EnMicroMsg.db),在文件传输助手收到消息。 | ||
| 16 | + | ||
| 17 | +设备:Pixel 10 Pro Fold,Android 16,WeChat 8.0.74,LSPosed JingMatrix Vector v1.11.0。 | ||
| 18 | + | ||
| 19 | +--- | ||
| 20 | + | ||
| 21 | +## 工作目录 | ||
| 22 | + | ||
| 23 | +``` | ||
| 24 | +/sdcard/wechathook/ | ||
| 25 | + WeChatHook.java ← 当前源码(已是最新版) | ||
| 26 | + termux_build.sh ← Termux 上编译+安装脚本 | ||
| 27 | + analyze_wechathook.py ← DEX 字节码分析工具(Python,读 Tinker patch) | ||
| 28 | + XposedBridgeApi.jar ← 编译依赖 | ||
| 29 | + android.jar ← 编译依赖(API 36) | ||
| 30 | + d8.jar ← DEX 转换工具 | ||
| 31 | + wechathook_current.apk ← 当前安装版本 | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +--- | ||
| 35 | + | ||
| 36 | +## 当前状态(已做了什么、失败在哪) | ||
| 37 | + | ||
| 38 | +### 已实现 | ||
| 39 | + | ||
| 40 | +模块已注入所有 WeChat 进程(main / push / appbrand0 / appbrand1)。每个进程里: | ||
| 41 | +1. 注册了 `com.hermes.wechat.SEND_MSG_INTERNAL` broadcast receiver | ||
| 42 | +2. 挂钩了 `w11.r0` 的 4 种构造器((String,String,int,int,long) 等) | ||
| 43 | +3. 挂钩了 `w11.r0.H(String)`(发送 dispatch 方法) | ||
| 44 | +4. 挂钩了 `gm0.j1.g(km0.c)`(DI 内核初始化检测) | ||
| 45 | +5. 挂钩了 WCDB `insertWithOnConflict`(诊断用) | ||
| 46 | + | ||
| 47 | +### 关键发现(失败原因) | ||
| 48 | + | ||
| 49 | +**发现 1:`w11.r0` 构造器永远不被 WeChat 自己调用。** | ||
| 50 | +用户在 UI 点发送时,`w11.r0` 的构造器 hook 从未触发过。说明 WeChat 8.0.74(Tinker patch 版)的文字发送路径**根本不经过 `w11.r0` 这个类**,或走的是完全不同的入口(native / 另一个类名)。 | ||
| 51 | + | ||
| 52 | +**发现 2:WCDB Java hook 从未触发。** | ||
| 53 | +`com.tencent.wcdb.database.SQLiteDatabase.insertWithOnConflict` 在发消息时从未被调用。说明消息写库走的是**纯 native 路径**(JNI),绕开了 Java 层的 WCDB wrapper。 | ||
| 54 | + | ||
| 55 | +**发现 3:`gm0.j1.g(km0.c)` hook 注册成功但从不触发。** | ||
| 56 | +WeChat 正常运行(包括收发消息)期间,`gm0.j1.g()` 这个我们以为是 DI 初始化点的方法**从未被调用过**。说明 WeChat 用了另一条路初始化 DI 内核(或者混淆后方法名已变)。 | ||
| 57 | + | ||
| 58 | +**发现 4:我们调用 `w11.r0(String,String,int,int,long)` 时:** | ||
| 59 | +- main 进程:`NullPointerException`(某个 Context 对象是 null) | ||
| 60 | +- 其他进程:`iz5.b: Kernel not initialized by MMApplication!`(`gm0.j1.i()` 抛出,说明 `gm0.j1.m` 静态字段是 null) | ||
| 61 | + | ||
| 62 | +--- | ||
| 63 | + | ||
| 64 | +## 需要你做什么 | ||
| 65 | + | ||
| 66 | +### 第 1 步:用 Frida 找 WeChat 真正的发送路径(最重要) | ||
| 67 | + | ||
| 68 | +我们不知道 WeChat 8.0.74 Tinker 版实际用哪个 Java 方法发文字消息。用 Frida 在用户点「发送」按钮时抓调用栈: | ||
| 69 | + | ||
| 70 | +```python | ||
| 71 | +# 在 Mac 端 Frida bridge 调用,或者直接在设备上用 frida-ps / frida-trace | ||
| 72 | +# 目标:com.tencent.mm(主进程) | ||
| 73 | +# 找到发送文字消息时调用的 Java 方法 | ||
| 74 | +``` | ||
| 75 | + | ||
| 76 | +具体建议:hook `com.tencent.wcdb.database.SQLiteDatabase` 的 native 层,或者用 Frida 的 Java.enumerateMethods 搜含 "sendMsg" / "sendTextMsg" 关键词的方法,在用户点发送时看 logcat。 | ||
| 77 | + | ||
| 78 | +如果 Frida 不方便,另一条路是用 Xposed hook `android.os.MessageQueue.next()` + 过滤调用栈,找 WeChat 发消息时进入的第一个 WeChat 内部类。 | ||
| 79 | + | ||
| 80 | +### 第 2 步:运行 DEX 分析脚本 | ||
| 81 | + | ||
| 82 | +```bash | ||
| 83 | +# 在 Termux 里(需要 root) | ||
| 84 | +python3 /sdcard/wechathook/analyze_wechathook.py | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +这个脚本会从 Tinker patch 的 classes12.dex 里提取: | ||
| 88 | +- `gm0.j1` 的所有字段和方法签名 | ||
| 89 | +- `w11.r0` 的所有构造器签名 | ||
| 90 | +- `gm0.j1.i()` 的字节码(关键调用) | ||
| 91 | +- `gm0.j1.g()` 的字节码(看它究竟做了什么) | ||
| 92 | + | ||
| 93 | +看输出里 `gm0.j1.i()` 的字节码,找它检查的是哪个字段(sget-object 指令);找谁会真正设置那个字段(sput-object 指令在哪个方法里)。 | ||
| 94 | + | ||
| 95 | +### 第 3 步:根据发现修改 WeChatHook.java | ||
| 96 | + | ||
| 97 | +修改路径两选一: | ||
| 98 | + | ||
| 99 | +**路径 A(推荐):换用 WeChat 真实的发送方法** | ||
| 100 | +如果 Frida 或字节码分析找到了真实发送入口(比如 `xxx.sendTextMessage(talker, text)` 这种),直接在 hook 里反射调用它,完全绕开 `w11.r0`。 | ||
| 101 | + | ||
| 102 | +**路径 B:修复 DI 初始化** | ||
| 103 | +找到真正初始化 `gm0.j1.m` 的方法(替代 `gm0.j1.g()`),hook 那个方法,在它完成后触发发送。 | ||
| 104 | + | ||
| 105 | +### 第 4 步:编译 + 安装 + 测试 | ||
| 106 | + | ||
| 107 | +```bash | ||
| 108 | +# 修改 WeChatHook.java 后,在 Termux 里: | ||
| 109 | +cd /sdcard/wechathook | ||
| 110 | +bash termux_build.sh | ||
| 111 | + | ||
| 112 | +# 脚本会自动:编译 → DEX → 签名 → pm install -r → 更新 LSPosed DB | ||
| 113 | +# 完成后重启 WeChat(脚本末尾会提示命令) | ||
| 114 | + | ||
| 115 | +# 然后测试(只用 filehelper): | ||
| 116 | +su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "filehelper" --es text "测试123"' | ||
| 117 | + | ||
| 118 | +# 看 LSPosed 日志确认有没有走到 Send OK: | ||
| 119 | +su -c 'logcat -d -s WeChatHook:* WeChatHookEarly:* | tail -50' | ||
| 120 | +``` | ||
| 121 | + | ||
| 122 | +--- | ||
| 123 | + | ||
| 124 | +## 关键类名参考(WeChat 8.0.74 Tinker patch,classes12.dex) | ||
| 125 | + | ||
| 126 | +| 类 | 作用 | | ||
| 127 | +|---|---| | ||
| 128 | +| `gm0.j1` | DI 容器单例;`i()→Lgm0/j1;` 取单例;`m` 是静态字段 | | ||
| 129 | +| `km0.c` | WeChat kernel/application 对象,传给 `gm0.j1.g()` | | ||
| 130 | +| `gm0.b0` | mCoreStorage,从 `gm0.j1.u()` 取 | | ||
| 131 | +| `w11.r0` | 疑似 NetSceneSendMsg,但 WeChat 自己不走这里 | | ||
| 132 | +| `iz5.b` | 异常类:Kernel not initialized by MMApplication! | | ||
| 133 | +| `fs.g` | DI enum;`a[0]` 是当前实现 | | ||
| 134 | +| `fs.k2` | DI 实现枚举;`e` 是目标值 | | ||
| 135 | + | ||
| 136 | +Tinker patch APK(root 权限才能访问): | ||
| 137 | +``` | ||
| 138 | +/data/user/0/com.tencent.mm/tinker/patch-cf87f687/dex/tinker_classN.apk | ||
| 139 | +``` | ||
| 140 | +包含 classes2.dex ~ classes17.dex,关键类在 classes12.dex。 | ||
| 141 | + | ||
| 142 | +--- | ||
| 143 | + | ||
| 144 | +## 当前 WeChatHook.java 的核心逻辑(方便你快速理解) | ||
| 145 | + | ||
| 146 | +``` | ||
| 147 | +broadcast 到达 | ||
| 148 | + → trySendWithRetry(talker, text, 0, procName) | ||
| 149 | + → isDIReady()? 调 gm0.j1.i() 看是否抛异常 | ||
| 150 | + → 如果不 ready:挂 pending,等 gm0.j1.g() hook 触发(但 g() 从没触发过!) | ||
| 151 | + → 如果 ready:sendViaR0(talker, text) | ||
| 152 | + → new w11.r0(talker, text, 1, 0, ts) ← 在所有进程都失败 | ||
| 153 | + → r0.H("") ← 从没执行到 | ||
| 154 | +``` | ||
| 155 | + | ||
| 156 | +**关键 bug**:整条路的前提假设(`gm0.j1.g()` 是初始化点,`w11.r0` 是发送入口)可能都是错的。需要从真实调用栈重新定位。 | ||
| 157 | + | ||
| 158 | +--- | ||
| 159 | + | ||
| 160 | +## 输出期望 | ||
| 161 | + | ||
| 162 | +完成后,以下命令应该能让文件传输助手收到消息: | ||
| 163 | +```bash | ||
| 164 | +su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "filehelper" --es text "hello from hermes"' | ||
| 165 | +``` | ||
| 166 | + | ||
| 167 | +LSPosed 日志里应该看到 `[WeChatHook] Send OK to=filehelper`。 | ||
| 168 | + | ||
| 169 | +把修改后的 `WeChatHook.java` 回写到 `/sdcard/wechathook/WeChatHook.java`,方便 Mac 端同步。 |
xhs_lsposed_a16_fix/rebuild_wechathook.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# rebuild_wechathook.sh — compile WeChatHook, repackage into com.example.wechathook APK | ||
| 3 | +# Uses the existing APK from device as a base (preserves binary AndroidManifest). | ||
| 4 | +set -e | ||
| 5 | + | ||
| 6 | +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
| 7 | +ADB="${ADB:-$SCRIPT_DIR/../platform-tools/adb}" | ||
| 8 | +[ ! -f "$ADB" ] && ADB=adb | ||
| 9 | + | ||
| 10 | +ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" | ||
| 11 | +ANDROID_JAR="$ANDROID_HOME/platforms/android-36/android.jar" | ||
| 12 | +D8="$ANDROID_HOME/build-tools/36.0.0/d8" | ||
| 13 | +APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner" | ||
| 14 | +LSPOSED_DIR="${LSPOSED_DIR:-/tmp/LSPosed}" | ||
| 15 | +LEGACY_JAR="$LSPOSED_DIR/legacy/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar" | ||
| 16 | + | ||
| 17 | +if [ ! -f "$LEGACY_JAR" ]; then | ||
| 18 | + echo "LSPosed legacy JAR not found at $LEGACY_JAR" | ||
| 19 | + echo "Run: git clone --depth 1 --recursive https://github.com/JingMatrix/LSPosed.git $LSPOSED_DIR && cd $LSPOSED_DIR && ./gradlew :legacy:assembleRelease" | ||
| 20 | + exit 1 | ||
| 21 | +fi | ||
| 22 | + | ||
| 23 | +BUILD_DIR=/tmp/wechathook_rebuild | ||
| 24 | +rm -rf "$BUILD_DIR" | ||
| 25 | +mkdir -p "$BUILD_DIR/compiled" "$BUILD_DIR/dex" "$BUILD_DIR/pack/assets" | ||
| 26 | + | ||
| 27 | +# Pull current APK from device to reuse its binary AndroidManifest and resources.arsc | ||
| 28 | +echo "== Pulling current wechathook APK from device ==" | ||
| 29 | +WH_PATH=$($ADB shell "pm path com.example.wechathook 2>/dev/null | tr -d '\r' | sed 's/package://'" 2>/dev/null) | ||
| 30 | +if [ -z "$WH_PATH" ]; then | ||
| 31 | + echo "ERROR: com.example.wechathook not installed on device" | ||
| 32 | + exit 1 | ||
| 33 | +fi | ||
| 34 | +$ADB shell "su -c \"cp $WH_PATH /data/local/tmp/wh_base.apk && chmod 644 /data/local/tmp/wh_base.apk\"" | ||
| 35 | +$ADB pull /data/local/tmp/wh_base.apk "$BUILD_DIR/pack/base.apk" 2>/dev/null | ||
| 36 | + | ||
| 37 | +# Extract AndroidManifest and resources from existing APK | ||
| 38 | +cd "$BUILD_DIR/pack" && unzip -o base.apk AndroidManifest.xml resources.arsc -d . > /dev/null 2>&1 | ||
| 39 | +rm -f base.apk | ||
| 40 | + | ||
| 41 | +echo "== Compiling WeChatHook.java ==" | ||
| 42 | +javac --release 11 \ | ||
| 43 | + -classpath "$ANDROID_JAR:$LEGACY_JAR" \ | ||
| 44 | + -d "$BUILD_DIR/compiled" \ | ||
| 45 | + "$SCRIPT_DIR/wechathook_src/WeChatHook.java" | ||
| 46 | + | ||
| 47 | +echo "== Converting to DEX ==" | ||
| 48 | +$D8 \ | ||
| 49 | + --classpath "$ANDROID_JAR" \ | ||
| 50 | + --classpath "$LEGACY_JAR" \ | ||
| 51 | + --min-api 27 \ | ||
| 52 | + --output "$BUILD_DIR/dex" \ | ||
| 53 | + $(find "$BUILD_DIR/compiled/com/example/wechathook" -name "*.class") | ||
| 54 | + | ||
| 55 | +echo "== Packing APK ==" | ||
| 56 | +cp "$BUILD_DIR/dex/classes.dex" "$BUILD_DIR/pack/" | ||
| 57 | +# Put xposed_init in assets/ (required by lspd) AND root (belt+suspenders) | ||
| 58 | +echo "com.example.wechathook.WeChatHook" > "$BUILD_DIR/pack/assets/xposed_init" | ||
| 59 | +echo "com.example.wechathook.WeChatHook" > "$BUILD_DIR/pack/xposed_init" | ||
| 60 | + | ||
| 61 | +cd "$BUILD_DIR/pack" | ||
| 62 | +zip -0 wechathook.apk AndroidManifest.xml resources.arsc classes.dex xposed_init 2>/dev/null || true | ||
| 63 | +zip -0 wechathook.apk assets/xposed_init | ||
| 64 | + | ||
| 65 | +# Sign | ||
| 66 | +if [ ! -f /tmp/debug.keystore ]; then | ||
| 67 | + keytool -genkey -v -keystore /tmp/debug.keystore -alias androiddebugkey \ | ||
| 68 | + -keyalg RSA -keysize 2048 -validity 10000 \ | ||
| 69 | + -storepass android -keypass android \ | ||
| 70 | + -dname "CN=Android Debug,O=Android,C=US" 2>/dev/null | ||
| 71 | +fi | ||
| 72 | + | ||
| 73 | +echo "== Signing ==" | ||
| 74 | +OUT="$SCRIPT_DIR/wechathook_v2_signed.apk" | ||
| 75 | +$APKSIGNER sign \ | ||
| 76 | + --ks /tmp/debug.keystore \ | ||
| 77 | + --ks-key-alias androiddebugkey \ | ||
| 78 | + --ks-pass pass:android \ | ||
| 79 | + --key-pass pass:android \ | ||
| 80 | + --out "$OUT" \ | ||
| 81 | + "$BUILD_DIR/pack/wechathook.apk" 2>/dev/null | ||
| 82 | + | ||
| 83 | +echo "== Done: $OUT ==" | ||
| 84 | +echo "APK size: $(wc -c < "$OUT") bytes" | ||
| 85 | +echo "" | ||
| 86 | +echo "Next steps:" | ||
| 87 | +echo " adb install -r --no-incremental $OUT" | ||
| 88 | +echo " bash $SCRIPT_DIR/update_wechathook_db.sh" | ||
| 89 | +echo " adb reboot" |
xhs_lsposed_a16_fix/termux_build.sh
0 → 100644
| 1 | +#!/data/data/com.termux/files/usr/bin/bash | ||
| 2 | +# termux_build.sh — 在 Termux 上编译 WeChatHook 并安装 | ||
| 3 | +# 所有依赖已在 /sdcard/wechathook/ 准备好 | ||
| 4 | +set -e | ||
| 5 | + | ||
| 6 | +BASE=/sdcard/wechathook | ||
| 7 | +SRC=$BASE/WeChatHook.java | ||
| 8 | +XPOSED_JAR=$BASE/XposedBridgeApi.jar | ||
| 9 | +ANDROID_JAR=$BASE/android.jar | ||
| 10 | +D8_JAR=$BASE/d8.jar | ||
| 11 | + | ||
| 12 | +BUILD=/data/data/com.termux/files/home/wh_build | ||
| 13 | +rm -rf "$BUILD" | ||
| 14 | +mkdir -p "$BUILD/compiled" "$BUILD/dex" "$BUILD/pack/assets" | ||
| 15 | + | ||
| 16 | +# 1. 取当前 APK 的 AndroidManifest + resources(二进制,不重新生成) | ||
| 17 | +WH_PATH=$(pm path com.example.wechathook | tr -d '\r' | sed 's/package://') | ||
| 18 | +echo "Base APK: $WH_PATH" | ||
| 19 | +su -c "cp '$WH_PATH' /data/local/tmp/wh_base.apk && chmod 644 /data/local/tmp/wh_base.apk" | ||
| 20 | +cp /data/local/tmp/wh_base.apk "$BUILD/pack/base.apk" | ||
| 21 | +cd "$BUILD/pack" && unzip -o base.apk AndroidManifest.xml resources.arsc -d . > /dev/null | ||
| 22 | +rm -f base.apk | ||
| 23 | + | ||
| 24 | +# 2. 编译 | ||
| 25 | +echo "== javac ==" | ||
| 26 | +javac --release 11 \ | ||
| 27 | + -classpath "$ANDROID_JAR:$XPOSED_JAR" \ | ||
| 28 | + -d "$BUILD/compiled" \ | ||
| 29 | + "$SRC" | ||
| 30 | + | ||
| 31 | +# 3. DEX | ||
| 32 | +echo "== d8 ==" | ||
| 33 | +java -cp "$D8_JAR" com.android.tools.r8.D8 \ | ||
| 34 | + --classpath "$ANDROID_JAR" \ | ||
| 35 | + --classpath "$XPOSED_JAR" \ | ||
| 36 | + --min-api 27 \ | ||
| 37 | + --output "$BUILD/dex" \ | ||
| 38 | + $(find "$BUILD/compiled" -name "*.class") | ||
| 39 | + | ||
| 40 | +# 4. 打包 | ||
| 41 | +echo "== pack ==" | ||
| 42 | +cp "$BUILD/dex/classes.dex" "$BUILD/pack/" | ||
| 43 | +echo "com.example.wechathook.WeChatHook" > "$BUILD/pack/assets/xposed_init" | ||
| 44 | +echo "com.example.wechathook.WeChatHook" > "$BUILD/pack/xposed_init" | ||
| 45 | +cd "$BUILD/pack" | ||
| 46 | +zip -0 wechathook.apk AndroidManifest.xml resources.arsc classes.dex xposed_init > /dev/null | ||
| 47 | +zip -0 wechathook.apk assets/xposed_init > /dev/null | ||
| 48 | + | ||
| 49 | +# 5. 签名(用 jarsigner,Termux JDK 自带) | ||
| 50 | +KEYSTORE=/data/data/com.termux/files/home/.wh_debug.keystore | ||
| 51 | +if [ ! -f "$KEYSTORE" ]; then | ||
| 52 | + echo "== 生成 keystore ==" | ||
| 53 | + keytool -genkey -v -keystore "$KEYSTORE" -alias androiddebugkey \ | ||
| 54 | + -keyalg RSA -keysize 2048 -validity 10000 \ | ||
| 55 | + -storepass android -keypass android \ | ||
| 56 | + -dname "CN=Android Debug,O=Android,C=US" 2>/dev/null | ||
| 57 | +fi | ||
| 58 | +echo "== jarsigner ==" | ||
| 59 | +jarsigner -keystore "$KEYSTORE" -storepass android -keypass android \ | ||
| 60 | + -signedjar "$BUILD/pack/wechathook_signed.apk" \ | ||
| 61 | + "$BUILD/pack/wechathook.apk" androiddebugkey 2>/dev/null | ||
| 62 | + | ||
| 63 | +OUT=$BASE/wechathook_new.apk | ||
| 64 | +cp "$BUILD/pack/wechathook_signed.apk" "$OUT" | ||
| 65 | +echo "== APK ready: $OUT ($(wc -c < "$OUT") bytes) ==" | ||
| 66 | + | ||
| 67 | +# 6. 安装 | ||
| 68 | +echo "== pm install ==" | ||
| 69 | +su -c "pm install -r '$OUT'" | ||
| 70 | + | ||
| 71 | +# 7. 更新 LSPosed DB(apk_path 变了需要更新) | ||
| 72 | +NEW_PATH=$(pm path com.example.wechathook | tr -d '\r' | sed 's/package://') | ||
| 73 | +echo "== 更新 LSPosed DB: $NEW_PATH ==" | ||
| 74 | +su -c "python3 -c \" | ||
| 75 | +import sqlite3 | ||
| 76 | +db = sqlite3.connect('/data/adb/lspd/config/modules_config.db') | ||
| 77 | +db.execute(\\\"UPDATE modules SET apk_path='$NEW_PATH' WHERE module_pkg_name='com.example.wechathook'\\\") | ||
| 78 | +db.commit() | ||
| 79 | +db.close() | ||
| 80 | +print('LSPosed DB updated') | ||
| 81 | +\"" | ||
| 82 | + | ||
| 83 | +echo "" | ||
| 84 | +echo "== 完成,需要重启 WeChat 进程(或 reboot)使新模块生效 ==" | ||
| 85 | +echo " su -c 'killall com.tencent.mm' && am start -n com.tencent.mm/.ui.LauncherUI" |
xhs_lsposed_a16_fix/update_wechathook_db.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# update_wechathook_db.sh — 重装 wechathook APK 后更新 LSPosed modules_config.db | ||
| 3 | +set -e | ||
| 4 | + | ||
| 5 | +ADB="${ADB:-$(dirname "$0")/../platform-tools/adb}" | ||
| 6 | +[ ! -f "$ADB" ] && ADB=adb | ||
| 7 | + | ||
| 8 | +TERMUX_PY=/data/data/com.termux/files/usr/bin/python3 | ||
| 9 | +NEW_PATH=$($ADB shell 'pm path com.example.wechathook' | tr -d '\r' | sed 's/package://') | ||
| 10 | +echo "New APK path: $NEW_PATH" | ||
| 11 | + | ||
| 12 | +# Phase 1: in-place UPDATE via Python (WAL commit visible to running lspd) | ||
| 13 | +$ADB shell "su -c \"$TERMUX_PY -c \\\" | ||
| 14 | +import sqlite3 | ||
| 15 | +db = sqlite3.connect('/data/adb/lspd/config/modules_config.db') | ||
| 16 | +db.execute(\\\\\\\"UPDATE modules SET apk_path='${NEW_PATH}' WHERE module_pkg_name='com.example.wechathook'\\\\\\\") | ||
| 17 | +db.commit() | ||
| 18 | +try: | ||
| 19 | + db.execute('PRAGMA wal_checkpoint(TRUNCATE)') | ||
| 20 | +except: | ||
| 21 | + pass | ||
| 22 | +db.close() | ||
| 23 | +print('phase1 done') | ||
| 24 | +\\\"\"" 2>&1 | ||
| 25 | + | ||
| 26 | +# Phase 2: pull + checkpoint locally + push clean main file | ||
| 27 | +$ADB shell 'su -c "cp /data/adb/lspd/config/modules_config.db /data/local/tmp/mc_wh.db && cp /data/adb/lspd/config/modules_config.db-wal /data/local/tmp/mc_wh.db-wal 2>/dev/null; chmod 644 /data/local/tmp/mc_wh.db /data/local/tmp/mc_wh.db-wal 2>/dev/null; true"' | ||
| 28 | +$ADB pull /data/local/tmp/mc_wh.db /tmp/mc_wh.db 2>/dev/null | ||
| 29 | +$ADB pull /data/local/tmp/mc_wh.db-wal /tmp/mc_wh.db-wal 2>/dev/null || true | ||
| 30 | +sqlite3 /tmp/mc_wh.db "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true | ||
| 31 | + | ||
| 32 | +ACTUAL=$(sqlite3 /tmp/mc_wh.db "SELECT apk_path FROM modules WHERE module_pkg_name='com.example.wechathook';") | ||
| 33 | +echo "Verified path: $ACTUAL" | ||
| 34 | + | ||
| 35 | +$ADB push /tmp/mc_wh.db /data/local/tmp/mc_wh_clean.db | ||
| 36 | +$ADB shell 'su -c "cp /data/local/tmp/mc_wh_clean.db /data/adb/lspd/config/modules_config.db && rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm && echo phase2 done"' | ||
| 37 | + | ||
| 38 | +# Phase 3: verify scope (mid=18, com.tencent.mm) | ||
| 39 | +MID=$(sqlite3 /tmp/mc_wh.db "SELECT mid FROM modules WHERE module_pkg_name='com.example.wechathook';") | ||
| 40 | +echo "mid: $MID" | ||
| 41 | +SCOPE=$(sqlite3 /tmp/mc_wh.db "SELECT count(*) FROM scope WHERE mid=$MID AND app_pkg_name='com.tencent.mm';") | ||
| 42 | +if [ "$SCOPE" -eq 0 ]; then | ||
| 43 | + echo "scope missing — inserting mid=$MID → com.tencent.mm" | ||
| 44 | + sqlite3 /tmp/mc_wh.db "INSERT OR REPLACE INTO scope (mid, app_pkg_name, user_id) VALUES ($MID, 'com.tencent.mm', 0);" | ||
| 45 | + $ADB push /tmp/mc_wh.db /data/local/tmp/mc_wh_scope.db | ||
| 46 | + $ADB shell "su -c 'cp /data/local/tmp/mc_wh_scope.db /data/adb/lspd/config/modules_config.db && rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm && echo scope fixed'" | ||
| 47 | +else | ||
| 48 | + echo "scope OK (mid=$MID, com.tencent.mm exists)" | ||
| 49 | +fi | ||
| 50 | + | ||
| 51 | +echo "DB updated. Reboot required." |
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
0 → 100644
| 1 | +package com.example.wechathook; | ||
| 2 | + | ||
| 3 | +import android.app.Activity; | ||
| 4 | +import android.content.BroadcastReceiver; | ||
| 5 | +import android.content.Context; | ||
| 6 | +import android.content.Intent; | ||
| 7 | +import android.content.IntentFilter; | ||
| 8 | +import android.os.Bundle; | ||
| 9 | +import android.os.Handler; | ||
| 10 | +import android.os.Looper; | ||
| 11 | +import android.view.View; | ||
| 12 | +import java.lang.reflect.Proxy; | ||
| 13 | +import de.robv.android.xposed.IXposedHookLoadPackage; | ||
| 14 | +import de.robv.android.xposed.XC_MethodHook; | ||
| 15 | +import de.robv.android.xposed.XposedBridge; | ||
| 16 | +import de.robv.android.xposed.XposedHelpers; | ||
| 17 | +import de.robv.android.xposed.callbacks.XC_LoadPackage; | ||
| 18 | + | ||
| 19 | +public class WeChatHook implements IXposedHookLoadPackage { | ||
| 20 | + | ||
| 21 | + private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL"; | ||
| 22 | + private static ClassLoader sWeChatCL; | ||
| 23 | + private static volatile Context sAppContext; | ||
| 24 | + private static volatile boolean sFsGReady = false; | ||
| 25 | + // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat | ||
| 26 | + private static volatile boolean sCalledByUs = false; | ||
| 27 | + // Pending send: stored when broadcast arrives before gm0.j1 is initialized | ||
| 28 | + private static volatile String sPendingTalker = null; | ||
| 29 | + private static volatile String sPendingText = null; | ||
| 30 | + private static volatile String sPendingProc = null; | ||
| 31 | + | ||
| 32 | + @Override | ||
| 33 | + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { | ||
| 34 | + if (!"com.tencent.mm".equals(lpparam.packageName)) return; | ||
| 35 | + // Use android.util.Log directly so it appears even before VectorLegacyBridge is ready | ||
| 36 | + android.util.Log.i("WeChatHookEarly", "INIT proc=" + lpparam.processName | ||
| 37 | + + " pid=" + android.os.Process.myPid()); | ||
| 38 | + XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName | ||
| 39 | + + " pid=" + android.os.Process.myPid()); | ||
| 40 | + ClassLoader cl = lpparam.classLoader; | ||
| 41 | + sWeChatCL = cl; | ||
| 42 | + | ||
| 43 | + // Tablet login hooks (main process + any process) | ||
| 44 | + hookInTabletEnv(cl); | ||
| 45 | + hookProbe(cl); | ||
| 46 | + hookLoginHistoryUIInitView(cl); | ||
| 47 | + hookLoginUIInitView(cl); | ||
| 48 | + hookLoginSelectUICheck(cl); | ||
| 49 | + hookLoginButtonClick(cl); | ||
| 50 | + | ||
| 51 | + // WCDB diagnostic: which process writes to WeChat message table? | ||
| 52 | + hookWCDB(cl, lpparam.processName); | ||
| 53 | + | ||
| 54 | + // Hook gm0.j1.g() to detect when DI kernel is ready — runs in ALL processes. | ||
| 55 | + // This enables the pending-send mechanism: if broadcast arrives before DI init, | ||
| 56 | + // we store the args and retry once g() fires. | ||
| 57 | + hookDIInit(cl, lpparam.processName); | ||
| 58 | + | ||
| 59 | + // Send hook in ALL WeChat processes — we need to find which one can actually send. | ||
| 60 | + // The broadcast receiver registered in each process will all receive the intent; | ||
| 61 | + // whichever process has gm0.j1 initialized will succeed. | ||
| 62 | + hookSendMessage(cl, lpparam.processName); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + // Diagnostic: hook WeChat's WCDB to find which process writes to the message table. | ||
| 66 | + // com.tencent.wcdb.database.SQLiteDatabase is WeChat's encrypted SQLite wrapper. | ||
| 67 | + private void hookWCDB(ClassLoader cl, String procName) { | ||
| 68 | + try { | ||
| 69 | + XposedHelpers.findAndHookMethod( | ||
| 70 | + "com.tencent.wcdb.database.SQLiteDatabase", cl, | ||
| 71 | + "insertWithOnConflict", | ||
| 72 | + String.class, String.class, android.content.ContentValues.class, int.class, | ||
| 73 | + new XC_MethodHook() { | ||
| 74 | + private boolean logged = false; | ||
| 75 | + @Override | ||
| 76 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 77 | + String table = (String) param.args[0]; | ||
| 78 | + if (table == null || (!table.contains("message") && !table.equals("msg"))) return; | ||
| 79 | + if (!logged) { | ||
| 80 | + logged = true; | ||
| 81 | + String stack = android.util.Log.getStackTraceString(new Throwable()); | ||
| 82 | + // Truncate to keep logcat readable | ||
| 83 | + if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…"; | ||
| 84 | + XposedBridge.log("[WeChatHook] WCDB INSERT table=" + table | ||
| 85 | + + " proc=" + procName + " pid=" + android.os.Process.myPid() | ||
| 86 | + + "\n" + stack); | ||
| 87 | + } else { | ||
| 88 | + XposedBridge.log("[WeChatHook] WCDB INSERT table=" + table | ||
| 89 | + + " proc=" + procName); | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + }); | ||
| 93 | + XposedBridge.log("[WeChatHook] WCDB hook OK in " + procName); | ||
| 94 | + } catch (Throwable t) { | ||
| 95 | + XposedBridge.log("[WeChatHook] WCDB hook failed in " + procName + ": " + t); | ||
| 96 | + } | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + // PRIMARY FIX: WeChat's own methods (LoginPasswordUI.onCreate etc.) are AOT-compiled | ||
| 100 | + // in a way that bypasses ArtMethod entry point replacement. But framework-dispatched | ||
| 101 | + // base class methods like Activity.onResume() always go through the entry point. | ||
| 102 | + // We hook onResume, filter for the login screens, and force the tablet button visible. | ||
| 103 | + // By onResume time, all views are initialized (onCreate + initView have run). | ||
| 104 | + private void hookProbe(ClassLoader cl) { | ||
| 105 | + try { | ||
| 106 | + final Class<?> m4Class = XposedHelpers.findClass( | ||
| 107 | + "com.tencent.mm.plugin.account.ui.m4", cl); | ||
| 108 | + | ||
| 109 | + XposedHelpers.findAndHookMethod( | ||
| 110 | + "android.app.Activity", null, | ||
| 111 | + "onResume", new XC_MethodHook() { | ||
| 112 | + @Override | ||
| 113 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 114 | + String cls = param.thisObject.getClass().getName(); | ||
| 115 | + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI") | ||
| 116 | + && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) { | ||
| 117 | + return; | ||
| 118 | + } | ||
| 119 | + Object obj = param.thisObject; | ||
| 120 | + View tabletBtn = null; | ||
| 121 | + try { | ||
| 122 | + tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 123 | + } catch (Throwable e) { | ||
| 124 | + XposedBridge.log("[WeChatHook] onResume getField N failed: " + e); | ||
| 125 | + } | ||
| 126 | + if (tabletBtn == null) { | ||
| 127 | + XposedBridge.log("[WeChatHook] onResume: N is null for " + cls); | ||
| 128 | + return; | ||
| 129 | + } | ||
| 130 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 131 | + try { | ||
| 132 | + // m4 constructor is m4(LoginHistoryUI), but obj is LoginPasswordUI | ||
| 133 | + // (a subclass). XposedHelpers.newInstance infers types from runtime | ||
| 134 | + // class which is LoginPasswordUI, not LoginHistoryUI — no match. | ||
| 135 | + // Use explicit param type to force the right constructor lookup. | ||
| 136 | + Class<?> loginHistoryUIClass = XposedHelpers.findClass( | ||
| 137 | + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", | ||
| 138 | + obj.getClass().getClassLoader()); | ||
| 139 | + tabletBtn.setOnClickListener( | ||
| 140 | + (View.OnClickListener) XposedHelpers.newInstance( | ||
| 141 | + m4Class, new Class<?>[]{loginHistoryUIClass}, obj)); | ||
| 142 | + } catch (Throwable e) { | ||
| 143 | + XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); | ||
| 144 | + } | ||
| 145 | + XposedBridge.log("[WeChatHook] onResume -> N VISIBLE + m4 attached for " + cls); | ||
| 146 | + } | ||
| 147 | + }); | ||
| 148 | + XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); | ||
| 149 | + } catch (Throwable t) { | ||
| 150 | + XposedBridge.log("[WeChatHook] hookProbe/onResume failed: " + t); | ||
| 151 | + } | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + private void hookInTabletEnv(ClassLoader cl) { | ||
| 155 | + try { | ||
| 156 | + Class<?> bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl); | ||
| 157 | + Class<?> x0Class = XposedHelpers.findClass("bm5.x0", cl); | ||
| 158 | + XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() { | ||
| 159 | + @Override | ||
| 160 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 161 | + param.setResult(Boolean.TRUE); | ||
| 162 | + } | ||
| 163 | + }); | ||
| 164 | + XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { | ||
| 165 | + @Override | ||
| 166 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 167 | + XposedBridge.log("[WeChatHook] bk.A() called -> TRUE"); | ||
| 168 | + param.setResult(Boolean.TRUE); | ||
| 169 | + } | ||
| 170 | + }); | ||
| 171 | + XposedBridge.log("[WeChatHook] Hooked bk.B(x0) + bk.A()"); | ||
| 172 | + } catch (Throwable t) { | ||
| 173 | + XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t); | ||
| 174 | + } | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + // PRIMARY FIX: Hook LoginPasswordUI.onCreate(Bundle) afterHookedMethod. | ||
| 178 | + // | ||
| 179 | + // Previous attempts to hook initView() and LoginHistoryUI.initView() both failed | ||
| 180 | + // because ART AOT devirtualizes the call to this.initView() inside | ||
| 181 | + // LoginHistoryUI.onCreate() — the compiled native code jumps directly past the | ||
| 182 | + // Xposed entry point replacement. | ||
| 183 | + // | ||
| 184 | + // onCreate() itself is called by the Android framework via the ArtMethod pointer | ||
| 185 | + // directly (no vtable devirtualization possible), so this hook fires reliably. | ||
| 186 | + // By the time afterHookedMethod runs, super.onCreate() has completed which means | ||
| 187 | + // LoginHistoryUI.initView() has already run and this.N is assigned. | ||
| 188 | + private void hookLoginHistoryUIInitView(ClassLoader cl) { | ||
| 189 | + final Class<?> m4Class; | ||
| 190 | + try { | ||
| 191 | + m4Class = XposedHelpers.findClass("com.tencent.mm.plugin.account.ui.m4", cl); | ||
| 192 | + } catch (Throwable t) { | ||
| 193 | + XposedBridge.log("[WeChatHook] m4 class not found: " + t); | ||
| 194 | + return; | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + try { | ||
| 198 | + XposedHelpers.findAndHookMethod( | ||
| 199 | + "com.tencent.mm.plugin.account.ui.LoginPasswordUI", cl, | ||
| 200 | + "onCreate", Bundle.class, new XC_MethodHook() { | ||
| 201 | + @Override | ||
| 202 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 203 | + Object obj = param.thisObject; | ||
| 204 | + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 205 | + if (tabletBtn == null) { | ||
| 206 | + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate: N is null"); | ||
| 207 | + return; | ||
| 208 | + } | ||
| 209 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 210 | + tabletBtn.setOnClickListener( | ||
| 211 | + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); | ||
| 212 | + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate() -> N VISIBLE + m4 attached"); | ||
| 213 | + } | ||
| 214 | + }); | ||
| 215 | + XposedBridge.log("[WeChatHook] Hooked LoginPasswordUI.onCreate()"); | ||
| 216 | + } catch (Throwable t) { | ||
| 217 | + XposedBridge.log("[WeChatHook] hookLoginPasswordUI.onCreate failed: " + t); | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + // Belt-and-suspenders: also hook LoginHistoryUI.onCreate() for any subclass | ||
| 221 | + // that doesn't override onCreate but does have the N button. | ||
| 222 | + try { | ||
| 223 | + XposedHelpers.findAndHookMethod( | ||
| 224 | + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", cl, | ||
| 225 | + "onCreate", Bundle.class, new XC_MethodHook() { | ||
| 226 | + @Override | ||
| 227 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 228 | + Object obj = param.thisObject; | ||
| 229 | + if (obj.getClass().getName().equals( | ||
| 230 | + "com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return; | ||
| 231 | + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 232 | + if (tabletBtn == null) { | ||
| 233 | + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate: N is null"); | ||
| 234 | + return; | ||
| 235 | + } | ||
| 236 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 237 | + tabletBtn.setOnClickListener( | ||
| 238 | + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); | ||
| 239 | + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate() -> N VISIBLE + m4 attached"); | ||
| 240 | + } | ||
| 241 | + }); | ||
| 242 | + XposedBridge.log("[WeChatHook] Hooked LoginHistoryUI.onCreate()"); | ||
| 243 | + } catch (Throwable t) { | ||
| 244 | + XposedBridge.log("[WeChatHook] hookLoginHistoryUI.onCreate failed: " + t); | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + private void hookLoginUIInitView(ClassLoader cl) { | ||
| 249 | + try { | ||
| 250 | + final Class<?> wbClass = XposedHelpers.findClass( | ||
| 251 | + "com.tencent.mm.plugin.account.ui.wb", cl); | ||
| 252 | + XposedHelpers.findAndHookMethod( | ||
| 253 | + "com.tencent.mm.plugin.account.ui.MobileInputUI", cl, | ||
| 254 | + "initView", new XC_MethodHook() { | ||
| 255 | + @Override | ||
| 256 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 257 | + Object mobileInputUI = param.thisObject; | ||
| 258 | + View tabletBtn = (View) XposedHelpers.getObjectField(mobileInputUI, "w"); | ||
| 259 | + if (tabletBtn == null) { | ||
| 260 | + XposedBridge.log("[WeChatHook] MobileInputUI.w is null"); | ||
| 261 | + return; | ||
| 262 | + } | ||
| 263 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 264 | + tabletBtn.setOnClickListener( | ||
| 265 | + (View.OnClickListener) XposedHelpers.newInstance(wbClass, mobileInputUI)); | ||
| 266 | + XposedBridge.log("[WeChatHook] MobileInputUI.initView() -> w VISIBLE + wb attached"); | ||
| 267 | + } | ||
| 268 | + }); | ||
| 269 | + XposedBridge.log("[WeChatHook] Hooked MobileInputUI.initView()"); | ||
| 270 | + } catch (Throwable t) { | ||
| 271 | + XposedBridge.log("[WeChatHook] hookLoginUIInitView(MobileInputUI) failed: " + t); | ||
| 272 | + } | ||
| 273 | + try { | ||
| 274 | + final Class<?> g7Class = XposedHelpers.findClass( | ||
| 275 | + "com.tencent.mm.plugin.account.ui.g7", cl); | ||
| 276 | + XposedHelpers.findAndHookMethod( | ||
| 277 | + "com.tencent.mm.plugin.account.ui.LoginUI", cl, | ||
| 278 | + "initView", new XC_MethodHook() { | ||
| 279 | + @Override | ||
| 280 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 281 | + Object loginUI = param.thisObject; | ||
| 282 | + View tabletBtn = (View) XposedHelpers.getObjectField(loginUI, "f992u"); | ||
| 283 | + if (tabletBtn == null) return; | ||
| 284 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 285 | + tabletBtn.setOnClickListener( | ||
| 286 | + (View.OnClickListener) XposedHelpers.newInstance(g7Class, loginUI)); | ||
| 287 | + XposedBridge.log("[WeChatHook] LoginUI.initView() -> f992u VISIBLE + g7 attached"); | ||
| 288 | + } | ||
| 289 | + }); | ||
| 290 | + XposedBridge.log("[WeChatHook] Hooked LoginUI.initView()"); | ||
| 291 | + } catch (Throwable t) { | ||
| 292 | + XposedBridge.log("[WeChatHook] hookLoginUIInitView(LoginUI) failed: " + t); | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + private void hookLoginSelectUICheck(ClassLoader cl) { | ||
| 297 | + try { | ||
| 298 | + Class<?> callbackClass = XposedHelpers.findClass( | ||
| 299 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl); | ||
| 300 | + XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() { | ||
| 301 | + @Override | ||
| 302 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 303 | + XposedBridge.log("[WeChatHook] LoginSelectUI$a.a(" + param.args[0] + ") -> false"); | ||
| 304 | + param.args[0] = false; | ||
| 305 | + } | ||
| 306 | + }); | ||
| 307 | + XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()"); | ||
| 308 | + } catch (Throwable t) { | ||
| 309 | + XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t); | ||
| 310 | + } | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + private void hookLoginButtonClick(ClassLoader cl) { | ||
| 314 | + try { | ||
| 315 | + Class<?> ljClass = XposedHelpers.findClass( | ||
| 316 | + "com.tencent.mm.plugin.account.ui.lj", cl); | ||
| 317 | + final Class<?> loginSelectUIClass = XposedHelpers.findClass( | ||
| 318 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl); | ||
| 319 | + XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() { | ||
| 320 | + @Override | ||
| 321 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 322 | + XposedBridge.log("[WeChatHook] lj.onClick() -> forcing LoginSelectUI"); | ||
| 323 | + Activity welcomeActivity = (Activity) XposedHelpers.getObjectField( | ||
| 324 | + param.thisObject, "d"); | ||
| 325 | + Intent intent = new Intent(welcomeActivity, loginSelectUIClass); | ||
| 326 | + intent.putExtra("mobile_input_purpose", 1); | ||
| 327 | + welcomeActivity.startActivity(intent); | ||
| 328 | + param.setResult(null); | ||
| 329 | + } | ||
| 330 | + }); | ||
| 331 | + XposedBridge.log("[WeChatHook] Hooked lj.onClick() -> LoginSelectUI"); | ||
| 332 | + } catch (Throwable t) { | ||
| 333 | + XposedBridge.log("[WeChatHook] hookLoginButtonClick failed: " + t); | ||
| 334 | + } | ||
| 335 | + } | ||
| 336 | + | ||
| 337 | + // ── Send message via internal API ──────────────────────────────────────── | ||
| 338 | + // | ||
| 339 | + // Registered in ALL WeChat processes. We don't yet know which process has gm0.j1 | ||
| 340 | + // initialized — the first process where the receiver fires and succeeds wins. | ||
| 341 | + // | ||
| 342 | + // Trigger from ADB (must run as root — RECEIVER_NOT_EXPORTED): | ||
| 343 | + // adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL \ | ||
| 344 | + // --es to "filehelper" --es text "hello"' | ||
| 345 | + private void hookSendMessage(ClassLoader cl, String procName) { | ||
| 346 | + try { | ||
| 347 | + XposedHelpers.findAndHookMethod( | ||
| 348 | + "android.app.Application", null, "onCreate", | ||
| 349 | + new XC_MethodHook() { | ||
| 350 | + @Override | ||
| 351 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 352 | + Context ctx = (Context) param.thisObject; | ||
| 353 | + if (!"com.tencent.mm".equals(ctx.getPackageName())) return; | ||
| 354 | + sAppContext = ctx; | ||
| 355 | + XposedBridge.log("[WeChatHook] App.onCreate proc=" + procName | ||
| 356 | + + " pid=" + android.os.Process.myPid()); | ||
| 357 | + registerSendReceiver(ctx, procName); | ||
| 358 | + } | ||
| 359 | + }); | ||
| 360 | + XposedBridge.log("[WeChatHook] Send hook registered in " + procName); | ||
| 361 | + } catch (Throwable t) { | ||
| 362 | + XposedBridge.log("[WeChatHook] hookSendMessage failed in " + procName + ": " + t); | ||
| 363 | + } | ||
| 364 | + | ||
| 365 | + // ── w11.r0 constructor hooks ─────────────────────────────────────────── | ||
| 366 | + // Log "by=US" vs "by=WeChat" to tell if WeChat's own UI send uses this ctor. | ||
| 367 | + XC_MethodHook r0Hook = new XC_MethodHook() { | ||
| 368 | + @Override | ||
| 369 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 370 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 371 | + XposedBridge.log("[WeChatHook] w11.r0 ctor by=" + by | ||
| 372 | + + " args=" + param.args.length + " proc=" + procName | ||
| 373 | + + " pid=" + android.os.Process.myPid()); | ||
| 374 | + if (sCalledByUs) { | ||
| 375 | + forceInitDI(); | ||
| 376 | + diagMCoreStorage("before-r0-ctor"); | ||
| 377 | + } | ||
| 378 | + } | ||
| 379 | + @Override | ||
| 380 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 381 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 382 | + if (param.hasThrowable()) { | ||
| 383 | + Throwable t = param.getThrowable(); | ||
| 384 | + XposedBridge.log("[WeChatHook] w11.r0 ctor THREW by=" + by | ||
| 385 | + + " proc=" + procName + ": " + t); | ||
| 386 | + if (sCalledByUs) | ||
| 387 | + XposedBridge.log("[WeChatHook] r0-ctor-stack: " | ||
| 388 | + + android.util.Log.getStackTraceString(t)); | ||
| 389 | + return; | ||
| 390 | + } | ||
| 391 | + sFsGReady = true; | ||
| 392 | + XposedBridge.log("[WeChatHook] w11.r0 ctor OK by=" + by + " proc=" + procName); | ||
| 393 | + } | ||
| 394 | + }; | ||
| 395 | + for (Class<?>[] sig : new Class<?>[][]{ | ||
| 396 | + {String.class, String.class, int.class, int.class, long.class}, | ||
| 397 | + {String.class, String.class, int.class, int.class, Object.class}, | ||
| 398 | + {long.class, int.class, String.class}, | ||
| 399 | + {}}) { | ||
| 400 | + try { | ||
| 401 | + XposedHelpers.findAndHookConstructor("w11.r0", cl, | ||
| 402 | + appendHook(sig, r0Hook)); | ||
| 403 | + XposedBridge.log("[WeChatHook] Hooked w11.r0(" + sig.length + " params)"); | ||
| 404 | + } catch (Throwable t) { | ||
| 405 | + XposedBridge.log("[WeChatHook] w11.r0(" + sig.length + "p) hook failed: " + t); | ||
| 406 | + } | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + // ── w11.r0.H(String) — the actual dispatch method ───────────────────── | ||
| 410 | + // Fires for EVERY send (ours AND WeChat UI sends). Shows if WeChat uses r0. | ||
| 411 | + try { | ||
| 412 | + XposedHelpers.findAndHookMethod("w11.r0", cl, "H", String.class, | ||
| 413 | + new XC_MethodHook() { | ||
| 414 | + @Override | ||
| 415 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 416 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 417 | + XposedBridge.log("[WeChatHook] w11.r0.H() called by=" + by | ||
| 418 | + + " proc=" + procName + " pid=" + android.os.Process.myPid()); | ||
| 419 | + } | ||
| 420 | + @Override | ||
| 421 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 422 | + if (param.hasThrowable()) | ||
| 423 | + XposedBridge.log("[WeChatHook] w11.r0.H() threw: " + param.getThrowable()); | ||
| 424 | + else | ||
| 425 | + XposedBridge.log("[WeChatHook] w11.r0.H() OK proc=" + procName); | ||
| 426 | + } | ||
| 427 | + }); | ||
| 428 | + XposedBridge.log("[WeChatHook] Hooked w11.r0.H(String) in " + procName); | ||
| 429 | + } catch (Throwable t) { | ||
| 430 | + XposedBridge.log("[WeChatHook] w11.r0.H hook failed in " + procName + ": " + t); | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + // ── Diagnostic probes on the constructor call chain ─────────────────── | ||
| 434 | + // Each probe fires when the method is called from inside the r0 constructor | ||
| 435 | + // (by WeChat OR by us). They trace which methods succeed before the NPE. | ||
| 436 | + hookProbeMethod(cl, "gm0.j1", "u", "gm0.j1.u"); | ||
| 437 | + hookProbeMethod(cl, "c01.z1", "r", "c01.z1.r"); | ||
| 438 | + // gm0.b0.c() is a virtual method — probe it too | ||
| 439 | + try { | ||
| 440 | + XposedHelpers.findAndHookMethod("gm0.b0", cl, "c", | ||
| 441 | + new XC_MethodHook() { | ||
| 442 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 443 | + XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() called"); | ||
| 444 | + } | ||
| 445 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 446 | + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() THREW: " + p.getThrowable()); | ||
| 447 | + else XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() → " + p.getResult()); | ||
| 448 | + } | ||
| 449 | + }); | ||
| 450 | + } catch (Throwable t) { | ||
| 451 | + XposedBridge.log("[WeChatHook] gm0.b0.c probe failed: " + t); | ||
| 452 | + } | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + private static Object[] appendHook(Class<?>[] params, XC_MethodHook hook) { | ||
| 456 | + Object[] result = new Object[params.length + 1]; | ||
| 457 | + System.arraycopy(params, 0, result, 0, params.length); | ||
| 458 | + result[params.length] = hook; | ||
| 459 | + return result; | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + private void hookProbeMethod(ClassLoader cl, String cls, String method, String tag) { | ||
| 463 | + try { | ||
| 464 | + XposedHelpers.findAndHookMethod(cls, cl, method, | ||
| 465 | + new XC_MethodHook() { | ||
| 466 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 467 | + XposedBridge.log("[WeChatHook] PROBE " + tag + "() called"); | ||
| 468 | + } | ||
| 469 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 470 | + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE " + tag + "() THREW: " + p.getThrowable()); | ||
| 471 | + else XposedBridge.log("[WeChatHook] PROBE " + tag + "() → " + p.getResult()); | ||
| 472 | + } | ||
| 473 | + }); | ||
| 474 | + XposedBridge.log("[WeChatHook] Probe hooked: " + tag); | ||
| 475 | + } catch (Throwable t) { | ||
| 476 | + XposedBridge.log("[WeChatHook] Probe " + tag + " failed: " + t); | ||
| 477 | + } | ||
| 478 | + } | ||
| 479 | + | ||
| 480 | + private static boolean isDIReady() { | ||
| 481 | + if (sWeChatCL == null) return false; | ||
| 482 | + try { | ||
| 483 | + XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", sWeChatCL), "i"); | ||
| 484 | + return true; | ||
| 485 | + } catch (Throwable e) { return false; } | ||
| 486 | + } | ||
| 487 | + | ||
| 488 | + // Hook gm0.j1.g(km0.c) to detect when WeChat DI kernel is initialized. | ||
| 489 | + // gm0.j1.g() sets gm0.j1.m (the singleton) and is called lazily — not during | ||
| 490 | + // Application.onCreate, so our broadcast often arrives first. After g() fires, | ||
| 491 | + // gm0.j1.i() will succeed and w11.r0 can be constructed. | ||
| 492 | + private static void hookDIInit(ClassLoader cl, String procName) { | ||
| 493 | + try { | ||
| 494 | + Class<?> km0cCls = XposedHelpers.findClass("km0.c", cl); | ||
| 495 | + XposedHelpers.findAndHookMethod("gm0.j1", cl, "g", km0cCls, | ||
| 496 | + new XC_MethodHook() { | ||
| 497 | + @Override | ||
| 498 | + protected void afterHookedMethod(MethodHookParam p) throws Throwable { | ||
| 499 | + XposedBridge.log("[WeChatHook] gm0.j1.g() fired — DI ready in " + procName); | ||
| 500 | + String t = sPendingTalker, x = sPendingText, pr = sPendingProc; | ||
| 501 | + if (t != null && x != null && procName.equals(pr)) { | ||
| 502 | + sPendingTalker = null; sPendingText = null; sPendingProc = null; | ||
| 503 | + XposedBridge.log("[WeChatHook] resuming pending send to=" + t + " proc=" + procName); | ||
| 504 | + new Handler(Looper.getMainLooper()).postDelayed( | ||
| 505 | + () -> trySendWithRetry(t, x, 0, procName), 50); | ||
| 506 | + } | ||
| 507 | + } | ||
| 508 | + }); | ||
| 509 | + XposedBridge.log("[WeChatHook] gm0.j1.g hook OK in " + procName); | ||
| 510 | + } catch (Throwable t) { | ||
| 511 | + XposedBridge.log("[WeChatHook] gm0.j1.g hook failed in " + procName + ": " + t); | ||
| 512 | + } | ||
| 513 | + } | ||
| 514 | + | ||
| 515 | + private static void diagMCoreStorage(String where) { | ||
| 516 | + if (sWeChatCL == null) return; | ||
| 517 | + try { | ||
| 518 | + Class<?> j1Cls = XposedHelpers.findClass("gm0.j1", sWeChatCL); | ||
| 519 | + Object j1 = XposedHelpers.callStaticMethod(j1Cls, "i"); | ||
| 520 | + Object mCS = (j1 != null) ? XposedHelpers.getObjectField(j1, "d") : null; | ||
| 521 | + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage=" + mCS); | ||
| 522 | + } catch (Throwable e) { | ||
| 523 | + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage-check: " + e); | ||
| 524 | + } | ||
| 525 | + } | ||
| 526 | + | ||
| 527 | + private static void forceInitDI() { | ||
| 528 | + if (sWeChatCL == null) return; | ||
| 529 | + try { | ||
| 530 | + Class<?> fsgCls = XposedHelpers.findClass("fs.g", sWeChatCL); | ||
| 531 | + java.lang.reflect.Field faFld = fsgCls.getDeclaredField("a"); | ||
| 532 | + faFld.setAccessible(true); | ||
| 533 | + Object[] arr = (Object[]) faFld.get(null); | ||
| 534 | + | ||
| 535 | + if (arr == null || arr.length == 0 || arr[0] == null) { | ||
| 536 | + Class<?> k2Cls = XposedHelpers.findClass("fs.k2", sWeChatCL); | ||
| 537 | + java.lang.reflect.Field k2eFld = k2Cls.getDeclaredField("e"); | ||
| 538 | + k2eFld.setAccessible(true); | ||
| 539 | + Object k2e = k2eFld.get(null); | ||
| 540 | + if (k2e == null) { | ||
| 541 | + XposedBridge.log("[WeChatHook] forceInitDI: fs.k2.e null"); | ||
| 542 | + return; | ||
| 543 | + } | ||
| 544 | + if (arr == null || arr.length == 0) { | ||
| 545 | + arr = new Object[1]; | ||
| 546 | + faFld.set(null, arr); | ||
| 547 | + } | ||
| 548 | + arr[0] = k2e; | ||
| 549 | + XposedBridge.log("[WeChatHook] forceInitDI: fs.g.a[0]=" + k2e.getClass().getName()); | ||
| 550 | + } | ||
| 551 | + } catch (Throwable e) { | ||
| 552 | + XposedBridge.log("[WeChatHook] forceInitDI: " + e); | ||
| 553 | + } | ||
| 554 | + } | ||
| 555 | + | ||
| 556 | + private static void trySendWithRetry(String talker, String text, int attempt, String procName) { | ||
| 557 | + // If DI not ready: park the args and wait for gm0.j1.g() hook to trigger retry. | ||
| 558 | + // Only the first process to check parks the pending (others may overwrite, which is fine | ||
| 559 | + // — the last write wins and gm0.j1.g() fires per-process anyway). | ||
| 560 | + if (!isDIReady()) { | ||
| 561 | + if (attempt == 0) { | ||
| 562 | + XposedBridge.log("[WeChatHook] DI not ready proc=" + procName | ||
| 563 | + + " — parking pending send to=" + talker); | ||
| 564 | + sPendingTalker = talker; | ||
| 565 | + sPendingText = text; | ||
| 566 | + sPendingProc = procName; | ||
| 567 | + } else { | ||
| 568 | + XposedBridge.log("[WeChatHook] DI still not ready proc=" + procName + " attempt=" + attempt); | ||
| 569 | + } | ||
| 570 | + return; | ||
| 571 | + } | ||
| 572 | + try { | ||
| 573 | + sendViaR0(talker, text); | ||
| 574 | + XposedBridge.log("[WeChatHook] Send OK to=" + talker + " proc=" + procName); | ||
| 575 | + } catch (Throwable t) { | ||
| 576 | + String tMsg = t.getMessage() != null ? t.getMessage() : t.toString(); | ||
| 577 | + XposedBridge.log("[WeChatHook] Send FAILED proc=" + procName + " to=" + talker + ": " + tMsg); | ||
| 578 | + if (attempt < 3) { | ||
| 579 | + long delay = 1000L << attempt; | ||
| 580 | + new Handler(Looper.getMainLooper()).postDelayed( | ||
| 581 | + () -> trySendWithRetry(talker, text, attempt + 1, procName), delay); | ||
| 582 | + } | ||
| 583 | + } | ||
| 584 | + } | ||
| 585 | + | ||
| 586 | + private static void registerSendReceiver(Context ctx, String procName) { | ||
| 587 | + BroadcastReceiver recv = new BroadcastReceiver() { | ||
| 588 | + @Override | ||
| 589 | + public void onReceive(Context context, Intent intent) { | ||
| 590 | + String talker = intent.getStringExtra("to"); | ||
| 591 | + String text = intent.getStringExtra("text"); | ||
| 592 | + if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { | ||
| 593 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'"); | ||
| 594 | + return; | ||
| 595 | + } | ||
| 596 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName | ||
| 597 | + + " pid=" + android.os.Process.myPid() | ||
| 598 | + + " to=" + talker + " len=" + text.length()); | ||
| 599 | + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName)); | ||
| 600 | + } | ||
| 601 | + }; | ||
| 602 | + ctx.registerReceiver(recv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED); | ||
| 603 | + XposedBridge.log("[WeChatHook] BroadcastReceiver registered in " + procName); | ||
| 604 | + } | ||
| 605 | + | ||
| 606 | + private static void sendViaR0(String talker, String text) throws Throwable { | ||
| 607 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", sWeChatCL); | ||
| 608 | + long ts = System.currentTimeMillis(); | ||
| 609 | + Object msg; | ||
| 610 | + sCalledByUs = true; | ||
| 611 | + try { | ||
| 612 | + try { | ||
| 613 | + msg = XposedHelpers.newInstance(r0Class, | ||
| 614 | + new Class<?>[]{String.class, String.class, int.class, int.class, long.class}, | ||
| 615 | + talker, text, 1, 0, ts); | ||
| 616 | + } catch (Throwable t) { | ||
| 617 | + msg = XposedHelpers.newInstance(r0Class, | ||
| 618 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 619 | + talker, text, 1, 0, (Object) ts); | ||
| 620 | + } | ||
| 621 | + } finally { | ||
| 622 | + sCalledByUs = false; | ||
| 623 | + } | ||
| 624 | + | ||
| 625 | + try { | ||
| 626 | + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", sWeChatCL); | ||
| 627 | + Object stub = Proxy.newProxyInstance(sWeChatCL, new Class<?>[]{u0}, | ||
| 628 | + (proxy, method, args) -> { | ||
| 629 | + XposedBridge.log("[WeChatHook] send-cb: " + method.getName()); | ||
| 630 | + return null; | ||
| 631 | + }); | ||
| 632 | + XposedHelpers.setObjectField(msg, "d", stub); | ||
| 633 | + } catch (Throwable ignored) {} | ||
| 634 | + | ||
| 635 | + sCalledByUs = true; | ||
| 636 | + try { | ||
| 637 | + XposedHelpers.callMethod(msg, "H", ""); | ||
| 638 | + } finally { | ||
| 639 | + sCalledByUs = false; | ||
| 640 | + } | ||
| 641 | + } | ||
| 642 | +} |
xhs_lsposed_a16_fix/wechathook_v2_signed.apk
0 → 100644
No preview for this file type