LESSONS.md 12.5 KB

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

查找命令:

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 什么。

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 内联对小静态方法(

实测: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:

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(主路径)

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(保底)

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

adb shell "dumpsys activity top | grep 'ACTIVITY com.目标包名' | head -3"

WeChat 的「已登录账号重新打开」和「首次登录」走完全不同的 Activity 栈。

原则 2:区分「静态方法」和「虚方法」的 hook 可靠性

方法类型 AOT 内联风险 hook 可靠性
静态方法(小, 高(被调用方内联) 不稳定
静态方法(较大) 低 较稳定
虚方法(被 App 内 AOT 调用) 高(devirtualize) 不可靠
框架基类方法(跨 APK 调用) 不可能 稳定

原则 3:添加诊断探针确认 hook 是否真的触发

不要假设 hook 在触发——用 beforeHookedMethod 加日志。 如果日志出现,hook 触发;如果不出现,调查内联/devirtualize。

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 <pkg>, 解除 AOT 但性能下降明显

原则 5:LSPosed modules_config.db 更新流程

安装新 APK 后,LSPosed 的 SQLite DB 里 apk_path 不自动更新,需要手动更新:

# 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 本文档

快速部署

# 前提: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 里的类名和字段名