Commit 9efb9656556ad51ee02c52904c3716202ad8d1f0
1 parent
4b6e1572
feat(wechat_pad_login): LSPosed hook 开启微信「以平板登录」选项
目标设备:Pixel 10 Pro Fold / Android 16 / WeChat 8.0.74
- WeChatHook.java:双保险策略
1. hook bk.A()/bk.B() 返回 true(静态方法,AOT 不内联则有效)
2. hook Activity.onResume() 保底:框架跨 APK 调用无法被 WeChat AOT
devirtualize,必然触发,直接强制平板按钮(LoginHistoryUI.N) VISIBLE
- rebuild.sh / update_db.sh:一键编译部署脚本
- LESSONS.md:逆向过程、AOT devirtualize 坑、XposedHelpers.newInstance
子类类型陷阱等经验总结
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
4 changed files
with
617 additions
and
0 deletions
wechat_pad_login/LESSONS.md
0 → 100644
| 1 | +# WeChat 平板登录 LSPosed Hook 经验与教训 | ||
| 2 | + | ||
| 3 | +目标:在 Pixel 10 Pro Fold (Android 16, WeChat 8.0.74, LSPosed JingMatrix Vector v1.11.0) 上 | ||
| 4 | +开启「以平板和手机同时登录」(LoginAsExDeviceUI)。 | ||
| 5 | + | ||
| 6 | +**结果:成功。** | ||
| 7 | + | ||
| 8 | +--- | ||
| 9 | + | ||
| 10 | +## 一、微信内部架构(逆向所得) | ||
| 11 | + | ||
| 12 | +### 平板白名单判断链路 | ||
| 13 | + | ||
| 14 | +``` | ||
| 15 | +LoginHistoryUI.initView() | ||
| 16 | + └ if (bk.A() || bk.Q()) ← 两个静态方法 | ||
| 17 | + ↓ true → this.N.setVisibility(VISIBLE) | ||
| 18 | + this.N.setOnClickListener(new m4(this)) | ||
| 19 | + m4.onClick → l.h(activity, "login_exdevice", ".ui.LoginAsExDeviceUI") | ||
| 20 | + | ||
| 21 | +bk.A() = inTabletEnv() 缓存层,调 bk.B(new x0(new bk$.b())) | ||
| 22 | +bk.B() = 实际判断:widthDp >= 600 || OEM 品牌白名单 | ||
| 23 | +bk.Q() = isFoldableDevice():Samsung SM-F9*/SM-W* / Huawei HWTAH/HWTET / ZTE / Motorola / Royole | ||
| 24 | + → Pixel 全部返回 false | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +### Activity 层次(返回用户路径) | ||
| 28 | + | ||
| 29 | +``` | ||
| 30 | +LoginPasswordUI (classes14.dex,密码输入屏:头像+手机号+密码框) | ||
| 31 | + extends LoginHistoryUI (classes.dex,平板按钮在此,字段 N) | ||
| 32 | + extends MMActivity | ||
| 33 | + extends Activity | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +`LoginPasswordUI` 是「已保存账号」的用户重新打开微信时看到的界面, | ||
| 37 | +不是 `LoginUI` 也不是 `MobileInputUI`——这是早期调查走弯路的主因。 | ||
| 38 | + | ||
| 39 | +### 各 DEX 分布(多 DEX 查找方法) | ||
| 40 | + | ||
| 41 | +WeChat 8.0.74 有 classes.dex~classes16.dex 共 16 个 DEX,重要类分布: | ||
| 42 | + | ||
| 43 | +| 类 | DEX | | ||
| 44 | +|---|---| | ||
| 45 | +| `LoginHistoryUI` | classes.dex (主 DEX) | | ||
| 46 | +| `LoginPasswordUI` | classes14.dex | | ||
| 47 | +| `bk` (inTabletEnv) | classes13.dex | | ||
| 48 | +| `m4` (click listener) | classes5.dex | | ||
| 49 | + | ||
| 50 | +查找命令: | ||
| 51 | +```bash | ||
| 52 | +python3 -c " | ||
| 53 | +import struct, sys | ||
| 54 | +data = open('classes14.dex','rb').read() | ||
| 55 | +# 解析 DEX string table,搜 LoginPasswordUI | ||
| 56 | +..." | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +--- | ||
| 60 | + | ||
| 61 | +## 二、踩过的坑(按时间顺序) | ||
| 62 | + | ||
| 63 | +### 坑 1:误判登录界面是 LoginUI / MobileInputUI | ||
| 64 | + | ||
| 65 | +**现象**:hook 注册成功,但 afterHookedMethod 从不触发。 | ||
| 66 | +**原因**:实际前台 Activity 是 `LoginPasswordUI`,而非 `LoginUI` 或 `MobileInputUI`。 | ||
| 67 | +**教训**:**先用 `dumpsys activity top` 确认实际 Activity,再决定 hook 什么。** | ||
| 68 | + | ||
| 69 | +```bash | ||
| 70 | +adb shell "dumpsys activity top | grep 'ACTIVITY com.tencent' | head -3" | ||
| 71 | +``` | ||
| 72 | + | ||
| 73 | +### 坑 2:bk.A() / bk.Q() hook 注册成功但从不在目标调用点触发 | ||
| 74 | + | ||
| 75 | +**现象**:`[WeChatHook] Hooked bk.A()` 日志出现,但 `bk.A() -> TRUE` 从不出现(在某些调用方)。 | ||
| 76 | +**原因**:ART AOT 把 `bk.A()` / `bk.Q()` 这类小静态方法**内联进调用方的 native 代码**。 | ||
| 77 | +调用方(`LoginHistoryUI.initView()` 的 AOT 产物)里没有 `invoke-static bk.A()`, | ||
| 78 | +而是直接内联了布尔判断逻辑,完全绕过了 Xposed 替换的 ArtMethod 入口。 | ||
| 79 | +**教训**:**AOT 内联对小静态方法(<10 字节码指令)非常激进,不能假设 hook 会在所有调用点生效。** | ||
| 80 | + | ||
| 81 | +实测:`bk.A()` hook 在部分调用路径有效(`initView()` 内),在其他调用方可能已被内联。 | ||
| 82 | + | ||
| 83 | +### 坑 3:虚方法 hook(initView / onCreate)全部不触发 | ||
| 84 | + | ||
| 85 | +**尝试过**: | ||
| 86 | +- `LoginHistoryUI.initView()` | ||
| 87 | +- `LoginPasswordUI.initView()` | ||
| 88 | +- `LoginPasswordUI.onCreate(Bundle)` | ||
| 89 | +- `LoginHistoryUI.onCreate(Bundle)` | ||
| 90 | + | ||
| 91 | +**全部注册成功,全部 afterHookedMethod 不触发。** | ||
| 92 | + | ||
| 93 | +**根本原因(已确认)**: | ||
| 94 | + | ||
| 95 | +`LoginHistoryUI.onCreate()` 的 AOT 编译代码内含对 `this.initView()` 的调用。 | ||
| 96 | +ART AOT 编译器做了 **devirtualize**(去虚化):把 `invoke-virtual this.initView()` 编译成 | ||
| 97 | +对 `LoginPasswordUI.initView` native 地址的直接 native branch,跳过了 vtable 查找, | ||
| 98 | +也跳过了 Xposed 替换的 ArtMethod `entry_point_from_quick_compiled_code_` 字段。 | ||
| 99 | + | ||
| 100 | +同理,`invoke-super` 调用(`LoginPasswordUI.initView()` 里的 `super.initView()`) | ||
| 101 | +AOT 后也是直接 native 跳转。 | ||
| 102 | + | ||
| 103 | +**即使 hook 注册在 Activity 启动之前完成,只要 caller 的 native 代码里嵌了直接地址, | ||
| 104 | +hook 就不会被触发。** | ||
| 105 | + | ||
| 106 | +**教训**: | ||
| 107 | +- **在重度 AOT 的 App(WeChat)里,不能依赖对虚方法的 Xposed hook。** | ||
| 108 | +- 区分「hook 注册成功」和「hook 实际拦截」——两者完全不同。 | ||
| 109 | +- 判断方式:在 `beforeHookedMethod` 加日志,如果从不出现,就是拦截失败。 | ||
| 110 | + | ||
| 111 | +### 坑 4:XposedHelpers.newInstance 子类类型不匹配 | ||
| 112 | + | ||
| 113 | +**现象**:`NoSuchMethodError: m4(com.tencent.mm.plugin.account.ui.LoginPasswordUI)` | ||
| 114 | +**原因**:`XposedHelpers.newInstance(m4Class, obj)` 按 `obj` 的**运行时类型**(`LoginPasswordUI`) | ||
| 115 | +查构造函数,但 `m4` 的构造函数签名是 `m4(LoginHistoryUI)`,找不到匹配。 | ||
| 116 | +**修复**:显式传 `parameterTypes`: | ||
| 117 | + | ||
| 118 | +```java | ||
| 119 | +Class<?> loginHistoryUIClass = XposedHelpers.findClass( | ||
| 120 | + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", | ||
| 121 | + obj.getClass().getClassLoader()); | ||
| 122 | +XposedHelpers.newInstance(m4Class, new Class<?>[]{loginHistoryUIClass}, obj); | ||
| 123 | +``` | ||
| 124 | + | ||
| 125 | +**教训**:`XposedHelpers.newInstance` 变参版本按运行时类型匹配,遇到子类实例传父类参数构造函数时必须用显式 `Class<?>[]` 重载。 | ||
| 126 | + | ||
| 127 | +--- | ||
| 128 | + | ||
| 129 | +## 三、最终有效的两层策略 | ||
| 130 | + | ||
| 131 | +### 层 1:静态方法 hook(主路径) | ||
| 132 | + | ||
| 133 | +```java | ||
| 134 | +XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { | ||
| 135 | + protected void beforeHookedMethod(MethodHookParam param) { | ||
| 136 | + param.setResult(Boolean.TRUE); | ||
| 137 | + } | ||
| 138 | +}); | ||
| 139 | +``` | ||
| 140 | + | ||
| 141 | +静态方法不走 vtable,Xposed 替换 ArtMethod 入口后,**只要 caller 没有内联该方法**, | ||
| 142 | +调用点就能被拦截。实测 `LoginHistoryUI.initView()` 的 AOT 代码里对 `bk.A()` 的调用 | ||
| 143 | +没有被内联(方法体不够小),所以 hook 有效。这让 `initView()` 走到了「显示按钮」分支, | ||
| 144 | +按钮获得 VISIBLE + m4 listener,这是主路径成功的原因。 | ||
| 145 | + | ||
| 146 | +### 层 2:框架基类方法 hook(保底) | ||
| 147 | + | ||
| 148 | +```java | ||
| 149 | +XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() { | ||
| 150 | + protected void afterHookedMethod(MethodHookParam param) { | ||
| 151 | + String cls = param.thisObject.getClass().getName(); | ||
| 152 | + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return; | ||
| 153 | + // 强制按钮可见 + 绑 listener | ||
| 154 | + } | ||
| 155 | +}); | ||
| 156 | +``` | ||
| 157 | + | ||
| 158 | +**核心原理**:Android framework 的 `Activity.onResume()` 是**跨 APK 的 virtual dispatch**, | ||
| 159 | +由 `ActivityThread`(framework 进程)调用,不可能被 WeChat 自身的 AOT 编译内联。 | ||
| 160 | +因此,hook `android.app.Activity` 里的基类方法并用 `classLoader=null`(bootstrap CL), | ||
| 161 | +其 afterHookedMethod **在所有 Activity 子类的 onResume 上必然触发**。 | ||
| 162 | + | ||
| 163 | +通过 class name 过滤只处理目标 Activity,再访问目标字段/方法。 | ||
| 164 | + | ||
| 165 | +**此策略适用于任何重度 AOT App 里「hook 不触发」的场景。** | ||
| 166 | + | ||
| 167 | +--- | ||
| 168 | + | ||
| 169 | +## 四、通用原则(可复用) | ||
| 170 | + | ||
| 171 | +### 原则 1:先确认当前 Activity | ||
| 172 | + | ||
| 173 | +```bash | ||
| 174 | +adb shell "dumpsys activity top | grep 'ACTIVITY com.目标包名' | head -3" | ||
| 175 | +``` | ||
| 176 | + | ||
| 177 | +WeChat 的「已登录账号重新打开」和「首次登录」走完全不同的 Activity 栈。 | ||
| 178 | + | ||
| 179 | +### 原则 2:区分「静态方法」和「虚方法」的 hook 可靠性 | ||
| 180 | + | ||
| 181 | +| 方法类型 | AOT 内联风险 | hook 可靠性 | | ||
| 182 | +|---|---|---| | ||
| 183 | +| 静态方法(小,< ~15 指令) | 高(被调用方内联) | 不稳定 | | ||
| 184 | +| 静态方法(较大) | 低 | 较稳定 | | ||
| 185 | +| 虚方法(被 App 内 AOT 调用) | 高(devirtualize) | **不可靠** | | ||
| 186 | +| 框架基类方法(跨 APK 调用) | 不可能 | **稳定** | | ||
| 187 | + | ||
| 188 | +### 原则 3:添加诊断探针确认 hook 是否真的触发 | ||
| 189 | + | ||
| 190 | +不要假设 hook 在触发——用 `beforeHookedMethod` 加日志。 | ||
| 191 | +如果日志出现,hook 触发;如果不出现,调查内联/devirtualize。 | ||
| 192 | + | ||
| 193 | +```java | ||
| 194 | +XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() { | ||
| 195 | + protected void beforeHookedMethod(MethodHookParam param) { | ||
| 196 | + String cls = param.thisObject.getClass().getName(); | ||
| 197 | + if (cls.contains("目标包名")) { | ||
| 198 | + XposedBridge.log("[Hook] PROBE onResume: " + cls); | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | +}); | ||
| 202 | +``` | ||
| 203 | + | ||
| 204 | +### 原则 4:遇到「虚方法 hook 不触发」时的替代路径 | ||
| 205 | + | ||
| 206 | +1. **用框架基类方法 hook**:`Activity.onCreate/onResume/onStart`,过滤 class name | ||
| 207 | +2. **改 hook 静态工厂/单例**:有时可以找到不被内联的静态入口 | ||
| 208 | +3. **DEX bytecode patch**:直接修改 DEX 字节码绕开 AOT(复杂,需要清 OAT 缓存) | ||
| 209 | +4. **force interpret-only**:`adb shell cmd package compile -m interpret-only <pkg>`, | ||
| 210 | + 解除 AOT 但性能下降明显 | ||
| 211 | + | ||
| 212 | +### 原则 5:LSPosed modules_config.db 更新流程 | ||
| 213 | + | ||
| 214 | +安装新 APK 后,LSPosed 的 SQLite DB 里 `apk_path` 不自动更新,需要手动更新: | ||
| 215 | + | ||
| 216 | +```bash | ||
| 217 | +# 1. 更新 apk_path | ||
| 218 | +adb shell "su -c 'python3 -c \"import sqlite3; db=sqlite3.connect(...); db.execute(...); db.commit()\"'" | ||
| 219 | +# 2. WAL checkpoint(让 lspd 进程读到新数据) | ||
| 220 | +# 3. 确认 scope 里有目标包名 | ||
| 221 | +# 4. reboot | ||
| 222 | +``` | ||
| 223 | + | ||
| 224 | +--- | ||
| 225 | + | ||
| 226 | +## 五、文件说明 | ||
| 227 | + | ||
| 228 | +| 文件 | 说明 | | ||
| 229 | +|---|---| | ||
| 230 | +| `WeChatHook.java` | 最终可用的 Xposed 模块源码 | | ||
| 231 | +| `rebuild.sh` | 编译 → D8 dex → 打包 → 签名,产物 `wechathook_v2_signed.apk` | | ||
| 232 | +| `update_db.sh` | 更新 LSPosed modules_config.db 的 apk_path + scope | | ||
| 233 | +| `LESSONS.md` | 本文档 | | ||
| 234 | + | ||
| 235 | +### 快速部署 | ||
| 236 | + | ||
| 237 | +```bash | ||
| 238 | +# 前提:LSPosed legacy JAR 已构建,设备已 root 且已安装 com.example.wechathook | ||
| 239 | +bash rebuild.sh | ||
| 240 | +ADB=/path/to/adb $ADB install -r --no-incremental wechathook_v2_signed.apk | ||
| 241 | +bash update_db.sh | ||
| 242 | +adb reboot | ||
| 243 | +``` | ||
| 244 | + | ||
| 245 | +--- | ||
| 246 | + | ||
| 247 | +## 六、WeChat 更新后的维护指引 | ||
| 248 | + | ||
| 249 | +`bk`、`m4`、`LoginHistoryUI`、`LoginPasswordUI`、`m4` 这些混淆类名在版本更新时可能改变。 | ||
| 250 | + | ||
| 251 | +排查流程: | ||
| 252 | +1. `adb pull /data/app/.../base.apk`,用 jadx 反编译 | ||
| 253 | +2. 搜 `inTabletEnv` / `isFoldableDevice` / `SM-F9` 找新的 `bk` 等价类 | ||
| 254 | +3. 搜 `login_exdevice` 找平板登录跳转的 click listener | ||
| 255 | +4. 更新 `WeChatHook.java` 里的类名和字段名 |
wechat_pad_login/WeChatHook.java
0 → 100644
| 1 | +package com.example.wechathook; | ||
| 2 | + | ||
| 3 | +import android.app.Activity; | ||
| 4 | +import android.content.Intent; | ||
| 5 | +import android.os.Bundle; | ||
| 6 | +import android.view.View; | ||
| 7 | +import de.robv.android.xposed.IXposedHookLoadPackage; | ||
| 8 | +import de.robv.android.xposed.XC_MethodHook; | ||
| 9 | +import de.robv.android.xposed.XposedBridge; | ||
| 10 | +import de.robv.android.xposed.XposedHelpers; | ||
| 11 | +import de.robv.android.xposed.callbacks.XC_LoadPackage; | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * WeChatHook: 在 Pixel 10 Pro Fold (Android 16 / WeChat 8.0.74) 上开启「以平板登录」选项。 | ||
| 15 | + * | ||
| 16 | + * 目标:LoginPasswordUI(密码登录屏)里有一个隐藏的平板登录按钮(字段 N), | ||
| 17 | + * 由 LoginHistoryUI.initView() 在 `if (bk.A() || bk.Q())` 为 true 时显示。 | ||
| 18 | + * Pixel 的品牌/型号不在微信白名单内,两者均返回 false。 | ||
| 19 | + * | ||
| 20 | + * 双保险策略: | ||
| 21 | + * 1. hookInTabletEnv: 直接 hook bk.A()/bk.B() 返回 true。 | ||
| 22 | + * 静态方法不走 vtable,AOT 编译后仍可被 Xposed 拦截(若调用方没有将其内联)。 | ||
| 23 | + * 实测:bk.A() hook 在 initView() 的 AOT 编译调用路径上有效, | ||
| 24 | + * 使 initView() 自动显示按钮并绑好 m4 click listener。 | ||
| 25 | + * | ||
| 26 | + * 2. hookTabletButtonViaOnResume: hook Android 框架的 Activity.onResume(), | ||
| 27 | + * 过滤 LoginPasswordUI,在 onResume 时强制将按钮设为 VISIBLE。 | ||
| 28 | + * 框架跨 APK 调用不可能被 WeChat 的 AOT 内联,所以这个 hook 必然触发。 | ||
| 29 | + * 兜底:即使 bk.A() hook 在某些 call site 被 AOT 内联绕过,onResume hook 也能补救。 | ||
| 30 | + */ | ||
| 31 | +public class WeChatHook implements IXposedHookLoadPackage { | ||
| 32 | + | ||
| 33 | + @Override | ||
| 34 | + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { | ||
| 35 | + if (!"com.tencent.mm".equals(lpparam.packageName)) return; | ||
| 36 | + XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName); | ||
| 37 | + ClassLoader cl = lpparam.classLoader; | ||
| 38 | + hookInTabletEnv(cl); | ||
| 39 | + hookTabletButtonViaOnResume(cl); | ||
| 40 | + hookMobileInputUI(cl); | ||
| 41 | + hookLoginUI(cl); | ||
| 42 | + hookLoginSelectUICheck(cl); | ||
| 43 | + hookWelcomeButtonClick(cl); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + // 策略 1: hook bk.A()/bk.B() 直接返回 true。 | ||
| 47 | + // 静态方法没有 vtable,Xposed 的 ArtMethod 入口替换对静态调用有效。 | ||
| 48 | + // bk.A() = inTabletEnv() 缓存层;bk.B(x0) = 实际逻辑。 | ||
| 49 | + private void hookInTabletEnv(ClassLoader cl) { | ||
| 50 | + try { | ||
| 51 | + Class<?> bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl); | ||
| 52 | + Class<?> x0Class = XposedHelpers.findClass("bm5.x0", cl); | ||
| 53 | + XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() { | ||
| 54 | + @Override | ||
| 55 | + protected void beforeHookedMethod(MethodHookParam param) { | ||
| 56 | + param.setResult(Boolean.TRUE); | ||
| 57 | + } | ||
| 58 | + }); | ||
| 59 | + XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { | ||
| 60 | + @Override | ||
| 61 | + protected void beforeHookedMethod(MethodHookParam param) { | ||
| 62 | + XposedBridge.log("[WeChatHook] bk.A() -> TRUE"); | ||
| 63 | + param.setResult(Boolean.TRUE); | ||
| 64 | + } | ||
| 65 | + }); | ||
| 66 | + XposedBridge.log("[WeChatHook] Hooked bk.A()/bk.B()"); | ||
| 67 | + } catch (Throwable t) { | ||
| 68 | + XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t); | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + // 策略 2: hook Activity.onResume(),过滤 LoginPasswordUI / LoginHistoryUI, | ||
| 73 | + // 强制 this.N 可见并绑定 m4 click listener。 | ||
| 74 | + // | ||
| 75 | + // 关键发现:WeChat 的虚方法(initView/onCreate)被 AOT devirtualize 成 native 直跳, | ||
| 76 | + // Xposed 的 ArtMethod 入口替换被绕过,hook 注册成功但回调永远不触发。 | ||
| 77 | + // 而 Android 框架调 Activity.onResume() 是跨 APK 的 virtual dispatch, | ||
| 78 | + // 不可能被 WeChat 自身的 AOT 编译内联,所以这里的 hook 稳定触发。 | ||
| 79 | + // | ||
| 80 | + // 类层次:LoginPasswordUI extends LoginHistoryUI extends MMActivity extends Activity | ||
| 81 | + // 平板按钮:LoginHistoryUI.N (Button, resource id 2131312963) | ||
| 82 | + // Click listener:m4(LoginHistoryUI) → l.h(loginUI, "login_exdevice", ".ui.LoginAsExDeviceUI") | ||
| 83 | + private void hookTabletButtonViaOnResume(ClassLoader cl) { | ||
| 84 | + try { | ||
| 85 | + final Class<?> m4Class = XposedHelpers.findClass( | ||
| 86 | + "com.tencent.mm.plugin.account.ui.m4", cl); | ||
| 87 | + | ||
| 88 | + XposedHelpers.findAndHookMethod( | ||
| 89 | + "android.app.Activity", null, "onResume", new XC_MethodHook() { | ||
| 90 | + @Override | ||
| 91 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 92 | + String cls = param.thisObject.getClass().getName(); | ||
| 93 | + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI") | ||
| 94 | + && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) { | ||
| 95 | + return; | ||
| 96 | + } | ||
| 97 | + Object obj = param.thisObject; | ||
| 98 | + View tabletBtn = null; | ||
| 99 | + try { | ||
| 100 | + tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 101 | + } catch (Throwable e) { | ||
| 102 | + XposedBridge.log("[WeChatHook] getField N failed: " + e); | ||
| 103 | + return; | ||
| 104 | + } | ||
| 105 | + if (tabletBtn == null) { | ||
| 106 | + XposedBridge.log("[WeChatHook] N is null for " + cls); | ||
| 107 | + return; | ||
| 108 | + } | ||
| 109 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 110 | + try { | ||
| 111 | + // XposedHelpers.newInstance 按运行时类型查找构造函数, | ||
| 112 | + // obj 运行时类型是 LoginPasswordUI,但 m4 构造函数接收 LoginHistoryUI。 | ||
| 113 | + // 必须显式传 parameterTypes 才能匹配到正确的构造函数。 | ||
| 114 | + Class<?> loginHistoryUIClass = XposedHelpers.findClass( | ||
| 115 | + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", | ||
| 116 | + obj.getClass().getClassLoader()); | ||
| 117 | + tabletBtn.setOnClickListener( | ||
| 118 | + (View.OnClickListener) XposedHelpers.newInstance( | ||
| 119 | + m4Class, new Class<?>[]{loginHistoryUIClass}, obj)); | ||
| 120 | + } catch (Throwable e) { | ||
| 121 | + XposedBridge.log("[WeChatHook] setListener failed: " + e); | ||
| 122 | + } | ||
| 123 | + XposedBridge.log("[WeChatHook] onResume -> N VISIBLE for " + cls); | ||
| 124 | + } | ||
| 125 | + }); | ||
| 126 | + XposedBridge.log("[WeChatHook] Hooked Activity.onResume (tablet button)"); | ||
| 127 | + } catch (Throwable t) { | ||
| 128 | + XposedBridge.log("[WeChatHook] hookTabletButtonViaOnResume failed: " + t); | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + // 兜底:MobileInputUI 是首次登录流程里的手机号输入界面,也有平板按钮(字段 w) | ||
| 133 | + private void hookMobileInputUI(ClassLoader cl) { | ||
| 134 | + try { | ||
| 135 | + final Class<?> wbClass = XposedHelpers.findClass( | ||
| 136 | + "com.tencent.mm.plugin.account.ui.wb", cl); | ||
| 137 | + XposedHelpers.findAndHookMethod( | ||
| 138 | + "com.tencent.mm.plugin.account.ui.MobileInputUI", cl, | ||
| 139 | + "initView", new XC_MethodHook() { | ||
| 140 | + @Override | ||
| 141 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 142 | + Object obj = param.thisObject; | ||
| 143 | + View btn = (View) XposedHelpers.getObjectField(obj, "w"); | ||
| 144 | + if (btn == null) return; | ||
| 145 | + btn.setVisibility(View.VISIBLE); | ||
| 146 | + btn.setOnClickListener( | ||
| 147 | + (View.OnClickListener) XposedHelpers.newInstance(wbClass, obj)); | ||
| 148 | + XposedBridge.log("[WeChatHook] MobileInputUI.w VISIBLE"); | ||
| 149 | + } | ||
| 150 | + }); | ||
| 151 | + } catch (Throwable t) { | ||
| 152 | + XposedBridge.log("[WeChatHook] hookMobileInputUI failed: " + t); | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + // 兜底:LoginUI 是通过 WelcomeActivity → 登录按钮 进入的界面,平板按钮字段 f992u | ||
| 157 | + private void hookLoginUI(ClassLoader cl) { | ||
| 158 | + try { | ||
| 159 | + final Class<?> g7Class = XposedHelpers.findClass( | ||
| 160 | + "com.tencent.mm.plugin.account.ui.g7", cl); | ||
| 161 | + XposedHelpers.findAndHookMethod( | ||
| 162 | + "com.tencent.mm.plugin.account.ui.LoginUI", cl, | ||
| 163 | + "initView", new XC_MethodHook() { | ||
| 164 | + @Override | ||
| 165 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 166 | + Object obj = param.thisObject; | ||
| 167 | + View btn = (View) XposedHelpers.getObjectField(obj, "f992u"); | ||
| 168 | + if (btn == null) return; | ||
| 169 | + btn.setVisibility(View.VISIBLE); | ||
| 170 | + btn.setOnClickListener( | ||
| 171 | + (View.OnClickListener) XposedHelpers.newInstance(g7Class, obj)); | ||
| 172 | + XposedBridge.log("[WeChatHook] LoginUI.f992u VISIBLE"); | ||
| 173 | + } | ||
| 174 | + }); | ||
| 175 | + } catch (Throwable t) { | ||
| 176 | + XposedBridge.log("[WeChatHook] hookLoginUI failed: " + t); | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + // 拦截 LoginSelectUI 的服务端品牌校验回调,阻止服务端隐藏「phone_and_pad」选项 | ||
| 181 | + private void hookLoginSelectUICheck(ClassLoader cl) { | ||
| 182 | + try { | ||
| 183 | + Class<?> callbackClass = XposedHelpers.findClass( | ||
| 184 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl); | ||
| 185 | + XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() { | ||
| 186 | + @Override | ||
| 187 | + protected void beforeHookedMethod(MethodHookParam param) { | ||
| 188 | + param.args[0] = false; | ||
| 189 | + XposedBridge.log("[WeChatHook] LoginSelectUI$a.a -> false"); | ||
| 190 | + } | ||
| 191 | + }); | ||
| 192 | + XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()"); | ||
| 193 | + } catch (Throwable t) { | ||
| 194 | + XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t); | ||
| 195 | + } | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + // 兜底:WelcomeActivity 的登录按钮(lj)直接跳 LoginSelectUI 而非 LoginPasswordUI | ||
| 199 | + private void hookWelcomeButtonClick(ClassLoader cl) { | ||
| 200 | + try { | ||
| 201 | + Class<?> ljClass = XposedHelpers.findClass( | ||
| 202 | + "com.tencent.mm.plugin.account.ui.lj", cl); | ||
| 203 | + final Class<?> loginSelectUIClass = XposedHelpers.findClass( | ||
| 204 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl); | ||
| 205 | + XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() { | ||
| 206 | + @Override | ||
| 207 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 208 | + Activity welcome = (Activity) XposedHelpers.getObjectField( | ||
| 209 | + param.thisObject, "d"); | ||
| 210 | + Intent intent = new Intent(welcome, loginSelectUIClass); | ||
| 211 | + intent.putExtra("mobile_input_purpose", 1); | ||
| 212 | + welcome.startActivity(intent); | ||
| 213 | + param.setResult(null); | ||
| 214 | + XposedBridge.log("[WeChatHook] lj.onClick -> LoginSelectUI"); | ||
| 215 | + } | ||
| 216 | + }); | ||
| 217 | + XposedBridge.log("[WeChatHook] Hooked lj.onClick()"); | ||
| 218 | + } catch (Throwable t) { | ||
| 219 | + XposedBridge.log("[WeChatHook] hookWelcomeButtonClick failed: " + t); | ||
| 220 | + } | ||
| 221 | + } | ||
| 222 | +} |
wechat_pad_login/rebuild.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.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" |
wechat_pad_login/update_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." |