# WeChat 平板登录 LSPosed Hook 经验与教训 目标:在 Pixel 10 Pro Fold (Android 16, WeChat 8.0.74, LSPosed JingMatrix Vector v1.11.0) 上 开启「以平板和手机同时登录」(LoginAsExDeviceUI)。 **结果:成功。** --- ## 🔑 确认根因(2026-07-07 订正):平板登录被拒 = 设备**型号**不在白名单,与账号「体验资格」无关 一度以为「以平板登录」被服务端拒是因为这台微信是测试版、账号**体验资格过期**(见旧记录里的错误结论), 所以把平板 hook 全禁了改用主设备登录。**实测证伪了这个结论**: - 加 `hookDeviceSpoof` 把 `Build`/`ro.product.*` 伪装成三星 **Galaxy Tab S8 (SM-X700)** 平板后, **同一个「小火子」老账号**(就是当初被归咎为体验资格过期那个)**平板登录成功**。 - ⇒ 拦路的一直是 **Pixel 机型过不了 `bk.B/Q` 的设备型号白名单 + 服务端副设备机型校验**, **不是账号体验资格**。伪装成白名单平板机型即可,两个账号都能登。 ### 让平板登录真正可用的 4 个 hook(缺一不可) 1. **`hookDeviceSpoof`**(本仓库 `xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java`): 仅主进程,`XposedHelpers.setStaticObjectField(android.os.Build, ...)` 设 BRAND/MANUFACTURER=samsung、 MODEL=SM-X700、DEVICE=gts8wifi、PRODUCT=gts8wifixx;并 hook `android.os.SystemProperties.get` 返回 `ro.product.*`=同款 + **`ro.build.characteristics=tablet`**(强平板信号)。`DEVICE_SPOOF` 开关。 - ⚠️ 伪装是**微信进程内**的(`getprop` 仍显 Pixel);编译期 XposedHelpers stub 要补 `setStaticObjectField`/`getStaticObjectField`(rebuild.sh 的 stub)。 2. **`hookInTabletEnv`**:`bk.A()`/`bk.B(x0)` → true(inTabletEnv 缓存),兜底。 3. **`hookLoginHistoryUIInitView`**:hook `LoginPasswordUI.onCreate` + `LoginHistoryUI.onCreate`, 把「以平板登录」按钮(字段 `N`)`setVisibility(VISIBLE)` + 挂 `m4` 点击监听 → **按钮才真出现** (只 bk.A/B→true 不够,按钮 gate 在这里)。**按钮只在「已保存账号重登界面」(LoginPasswordUI) 出现。** 4. **`hookLoginUIInitView`**:LoginUI 变体同上。 回退单设备登录:`DEVICE_SPOOF=false` + 注释上面 4 个调用。伪装型号常开对已登录账号无害 (旧记录担心的「伪装重触发体验资格 gate 踢登录」也随本结论作废)。 ### Hermes 修复交接 平板登录以后坏了,让 Pixel 上的 Hermes 先响应:skill **`wechat-pad-login-repair`** (本目录 `HERMES_REPAIR_SKILL.md`,已装到设备 `~/.hermes/skills/`)。它做**诊断**(grep LSPosed log 看 `Injected/hookDeviceSpoof/N VISIBLE`、`dumpsys` 看是否在 LoginPasswordUI) + **no-rebuild 修复**(模块没注入→重启+`pm install-existing com.tencent.mm`;不在登录页→ 导航到 LoginPasswordUI)。**Case B**(微信更新后混淆名变 / 需改源码重编译)Hermes 修不了, 它会收集证据(微信版本+日志+截图)升级给人/Claude,配方就是本文件这套 4-hook + Tinker CL。 --- ## 一、微信内部架构(逆向所得) ### 平板白名单判断链路 ``` LoginHistoryUI.initView() └ if (bk.A() || bk.Q()) ← 两个静态方法 ↓ true → this.N.setVisibility(VISIBLE) this.N.setOnClickListener(new m4(this)) m4.onClick → l.h(activity, "login_exdevice", ".ui.LoginAsExDeviceUI") bk.A() = inTabletEnv() 缓存层,调 bk.B(new x0(new bk$.b())) bk.B() = 实际判断:widthDp >= 600 || OEM 品牌白名单 bk.Q() = isFoldableDevice():Samsung SM-F9*/SM-W* / Huawei HWTAH/HWTET / ZTE / Motorola / Royole → Pixel 全部返回 false ``` ### Activity 层次(返回用户路径) ``` LoginPasswordUI (classes14.dex,密码输入屏:头像+手机号+密码框) extends LoginHistoryUI (classes.dex,平板按钮在此,字段 N) extends MMActivity extends Activity ``` `LoginPasswordUI` 是「已保存账号」的用户重新打开微信时看到的界面, 不是 `LoginUI` 也不是 `MobileInputUI`——这是早期调查走弯路的主因。 ### 各 DEX 分布(多 DEX 查找方法) WeChat 8.0.74 有 classes.dex~classes16.dex 共 16 个 DEX,重要类分布: | 类 | DEX | |---|---| | `LoginHistoryUI` | classes.dex (主 DEX) | | `LoginPasswordUI` | classes14.dex | | `bk` (inTabletEnv) | classes13.dex | | `m4` (click listener) | classes5.dex | 查找命令: ```bash python3 -c " import struct, sys data = open('classes14.dex','rb').read() # 解析 DEX string table,搜 LoginPasswordUI ..." ``` --- ## 二、踩过的坑(按时间顺序) ### 坑 1:误判登录界面是 LoginUI / MobileInputUI **现象**:hook 注册成功,但 afterHookedMethod 从不触发。 **原因**:实际前台 Activity 是 `LoginPasswordUI`,而非 `LoginUI` 或 `MobileInputUI`。 **教训**:**先用 `dumpsys activity top` 确认实际 Activity,再决定 hook 什么。** ```bash adb shell "dumpsys activity top | grep 'ACTIVITY com.tencent' | head -3" ``` ### 坑 2:bk.A() / bk.Q() hook 注册成功但从不在目标调用点触发 **现象**:`[WeChatHook] Hooked bk.A()` 日志出现,但 `bk.A() -> TRUE` 从不出现(在某些调用方)。 **原因**:ART AOT 把 `bk.A()` / `bk.Q()` 这类小静态方法**内联进调用方的 native 代码**。 调用方(`LoginHistoryUI.initView()` 的 AOT 产物)里没有 `invoke-static bk.A()`, 而是直接内联了布尔判断逻辑,完全绕过了 Xposed 替换的 ArtMethod 入口。 **教训**:**AOT 内联对小静态方法(<10 字节码指令)非常激进,不能假设 hook 会在所有调用点生效。** 实测:`bk.A()` hook 在部分调用路径有效(`initView()` 内),在其他调用方可能已被内联。 ### 坑 3:虚方法 hook(initView / onCreate)全部不触发 **尝试过**: - `LoginHistoryUI.initView()` - `LoginPasswordUI.initView()` - `LoginPasswordUI.onCreate(Bundle)` - `LoginHistoryUI.onCreate(Bundle)` **全部注册成功,全部 afterHookedMethod 不触发。** **根本原因(已确认)**: `LoginHistoryUI.onCreate()` 的 AOT 编译代码内含对 `this.initView()` 的调用。 ART AOT 编译器做了 **devirtualize**(去虚化):把 `invoke-virtual this.initView()` 编译成 对 `LoginPasswordUI.initView` native 地址的直接 native branch,跳过了 vtable 查找, 也跳过了 Xposed 替换的 ArtMethod `entry_point_from_quick_compiled_code_` 字段。 同理,`invoke-super` 调用(`LoginPasswordUI.initView()` 里的 `super.initView()`) AOT 后也是直接 native 跳转。 **即使 hook 注册在 Activity 启动之前完成,只要 caller 的 native 代码里嵌了直接地址, hook 就不会被触发。** **教训**: - **在重度 AOT 的 App(WeChat)里,不能依赖对虚方法的 Xposed hook。** - 区分「hook 注册成功」和「hook 实际拦截」——两者完全不同。 - 判断方式:在 `beforeHookedMethod` 加日志,如果从不出现,就是拦截失败。 ### 坑 4:XposedHelpers.newInstance 子类类型不匹配 **现象**:`NoSuchMethodError: m4(com.tencent.mm.plugin.account.ui.LoginPasswordUI)` **原因**:`XposedHelpers.newInstance(m4Class, obj)` 按 `obj` 的**运行时类型**(`LoginPasswordUI`) 查构造函数,但 `m4` 的构造函数签名是 `m4(LoginHistoryUI)`,找不到匹配。 **修复**:显式传 `parameterTypes`: ```java Class loginHistoryUIClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.LoginHistoryUI", obj.getClass().getClassLoader()); XposedHelpers.newInstance(m4Class, new Class[]{loginHistoryUIClass}, obj); ``` **教训**:`XposedHelpers.newInstance` 变参版本按运行时类型匹配,遇到子类实例传父类参数构造函数时必须用显式 `Class[]` 重载。 --- ## 三、最终有效的两层策略 ### 层 1:静态方法 hook(主路径) ```java XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { protected void beforeHookedMethod(MethodHookParam param) { param.setResult(Boolean.TRUE); } }); ``` 静态方法不走 vtable,Xposed 替换 ArtMethod 入口后,**只要 caller 没有内联该方法**, 调用点就能被拦截。实测 `LoginHistoryUI.initView()` 的 AOT 代码里对 `bk.A()` 的调用 没有被内联(方法体不够小),所以 hook 有效。这让 `initView()` 走到了「显示按钮」分支, 按钮获得 VISIBLE + m4 listener,这是主路径成功的原因。 ### 层 2:框架基类方法 hook(保底) ```java XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() { protected void afterHookedMethod(MethodHookParam param) { String cls = param.thisObject.getClass().getName(); if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return; // 强制按钮可见 + 绑 listener } }); ``` **核心原理**:Android framework 的 `Activity.onResume()` 是**跨 APK 的 virtual dispatch**, 由 `ActivityThread`(framework 进程)调用,不可能被 WeChat 自身的 AOT 编译内联。 因此,hook `android.app.Activity` 里的基类方法并用 `classLoader=null`(bootstrap CL), 其 afterHookedMethod **在所有 Activity 子类的 onResume 上必然触发**。 通过 class name 过滤只处理目标 Activity,再访问目标字段/方法。 **此策略适用于任何重度 AOT App 里「hook 不触发」的场景。** --- ## 四、通用原则(可复用) ### 原则 1:先确认当前 Activity ```bash adb shell "dumpsys activity top | grep 'ACTIVITY com.目标包名' | head -3" ``` WeChat 的「已登录账号重新打开」和「首次登录」走完全不同的 Activity 栈。 ### 原则 2:区分「静态方法」和「虚方法」的 hook 可靠性 | 方法类型 | AOT 内联风险 | hook 可靠性 | |---|---|---| | 静态方法(小,< ~15 指令) | 高(被调用方内联) | 不稳定 | | 静态方法(较大) | 低 | 较稳定 | | 虚方法(被 App 内 AOT 调用) | 高(devirtualize) | **不可靠** | | 框架基类方法(跨 APK 调用) | 不可能 | **稳定** | ### 原则 3:添加诊断探针确认 hook 是否真的触发 不要假设 hook 在触发——用 `beforeHookedMethod` 加日志。 如果日志出现,hook 触发;如果不出现,调查内联/devirtualize。 ```java XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() { protected void beforeHookedMethod(MethodHookParam param) { String cls = param.thisObject.getClass().getName(); if (cls.contains("目标包名")) { XposedBridge.log("[Hook] PROBE onResume: " + cls); } } }); ``` ### 原则 4:遇到「虚方法 hook 不触发」时的替代路径 1. **用框架基类方法 hook**:`Activity.onCreate/onResume/onStart`,过滤 class name 2. **改 hook 静态工厂/单例**:有时可以找到不被内联的静态入口 3. **DEX bytecode patch**:直接修改 DEX 字节码绕开 AOT(复杂,需要清 OAT 缓存) 4. **force interpret-only**:`adb shell cmd package compile -m interpret-only `, 解除 AOT 但性能下降明显 ### 原则 5:LSPosed modules_config.db 更新流程 安装新 APK 后,LSPosed 的 SQLite DB 里 `apk_path` 不自动更新,需要手动更新: ```bash # 1. 更新 apk_path adb shell "su -c 'python3 -c \"import sqlite3; db=sqlite3.connect(...); db.execute(...); db.commit()\"'" # 2. WAL checkpoint(让 lspd 进程读到新数据) # 3. 确认 scope 里有目标包名 # 4. reboot ``` --- ## 五、文件说明 | 文件 | 说明 | |---|---| | `WeChatHook.java` | 最终可用的 Xposed 模块源码 | | `rebuild.sh` | 编译 → D8 dex → 打包 → 签名,产物 `wechathook_v2_signed.apk` | | `update_db.sh` | 更新 LSPosed modules_config.db 的 apk_path + scope | | `LESSONS.md` | 本文档 | ### 快速部署 ```bash # 前提:LSPosed legacy JAR 已构建,设备已 root 且已安装 com.example.wechathook bash rebuild.sh ADB=/path/to/adb $ADB install -r --no-incremental wechathook_v2_signed.apk bash update_db.sh adb reboot ``` --- ## 六、WeChat 更新后的维护指引 `bk`、`m4`、`LoginHistoryUI`、`LoginPasswordUI`、`m4` 这些混淆类名在版本更新时可能改变。 排查流程: 1. `adb pull /data/app/.../base.apk`,用 jadx 反编译 2. 搜 `inTabletEnv` / `isFoldableDevice` / `SM-F9` 找新的 `bk` 等价类 3. 搜 `login_exdevice` 找平板登录跳转的 click listener 4. 更新 `WeChatHook.java` 里的类名和字段名