Commit 3dfe11b2a49a1409199ec5ee2811229575e93dbb

Authored by yibo
1 parent c7f0d956

research(wechat): 发现贴图号入口是 Flutter/Pigeon 桥接,反编译资产过期教训

用户提议直接读源码而非继续 hook 排除法,引出两条新调查线:

1. 小程序运行时 JS 缓存(jscache 目录,非加密 wxapkg,已解出但是微信
   自己的编译字节码)——strings 扫出完整模块树,确认 utils/massSend/
   massSend.js 是实际提交逻辑所在,但字节码格式读不出具体调用参数。

2. 按"类名含 Publish"批量搜索,发现 com.tencent.pigeon.biz.BizPublishApi
   ——Pigeon 是 Flutter 官方 native↔Dart 桥接代码生成器,证实"我的作品"
   入口(BizMyWorksFlutterViewActivity)是真 Flutter 页面,跟原生通信
   走一条完全独立于此前四条候选的 IPC 机制,解释了为什么 js1.m/m11.l0/
   wxartrappsvr/v70.w 全部落空。

找到实现类 Llx/m5,hook 了 notifyBizPublishResult()/openUrl()(含新观察
到的独立进程 com.tencent.mm:hotpot..,疑似"我的作品"专用 Flutter
隔离进程)。两轮真机验证:hook 全部安装成功,但零触发,发布依旧真实
成功。这是今天第二次"反编译读到的类看起来对,装上却零触发"(第一次是
JsApiOpenBizPublishImage 主类被 R8 优化到连文件都找不到)。

关键教训:~/wechat_apk/decompiled 是历史某个时间点的 dump,可能已经
跟当前真机在跑的版本对不上(Kotlin/R8 生成的短类名对小版本更新敏感)。
下次要精确 hook 前应重新 dump 当前 base.apk,而不是复用旧反编译资产。
wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md
... ... @@ -345,6 +345,71 @@ Pixel 10 Pro Fold 有两块屏(outer/cover 屏 + inner 屏),`adb exec-out
345 345  
346 346 ---
347 347  
  348 +## 六、(次日续)读小程序运行时 JS 缓存 + 发现 Flutter/Pigeon 全新层——两条线都碰到"读得到结构,够不到实现"的墙
  349 +
  350 +用户提出一个更根本的问题:"能不能直接读完整源码,通过源码找到真正调的 API",而不是继续靠运行时 hook 排除法猜。这引出了两条新的调查线。
  351 +
  352 +### 6.1 小程序运行时 JS 缓存:能读到模块名,读不到网络请求逻辑
  353 +
  354 +设备上 `/data/data/com.tencent.mm/cache/appbrand/jscache/` 下缓存着 `wx0b2ec076ec39c786`(图片编辑步骤那个小程序)运行时用的 JS 包,**不是 `.wxapkg` 加密包,是已经解出来准备喂给 JS 引擎的产物**——但格式是微信自己的编译字节码(`__wxAppData`/`__wxAppCode__`/`Component`/`requirePlugin` 等运行时符号能看到,实际代码是二进制操作码,不是明文 JS),需要专门的反编译器才能还原逻辑,工作量远超读 smali。
  355 +
  356 +退而求其次用 `strings` 扫字节码里残留的模块路径清单(这部分是明文),扫出了完整的模块树,其中最相关的:
  357 +```
  358 +pages/appmsg_editor/appmsg_editor.js ← 编辑页
  359 +utils/massSend/massSend.js ← "群发/发表"("发表"公众号文章内部叫 massSend)
  360 +utils/massSend/saveDraft.js
  361 +utils/massSend/deleteArticle.js
  362 +utils/request/request.js ← 通用请求封装
  363 +utils/request/url.js ← 应该存 cgi 路径常量
  364 +utils/uploader/main.js ← 上传器
  365 +utils/cover/uploadCover.js
  366 +utils/wxEventReport/publishReport.js
  367 +utils/account/checkPublishStatus.js
  368 +```
  369 +`chunk_0.appservice.js`(357KB,最大的一个 chunk)反编译字符串里能看到 `checkContentBeforePublish`/`fastMasssendC`/`initPublishSource`/`massSendError` 等函数名残留,**确认 `massSend.js` 就是实际提交逻辑所在**,但 cgi-bin 路径字符串在这个字节码格式里没有以明文形式出现(大概率是运行时用变量拼接的,不是硬编码字面量),静态 strings 扫描到此为止——**知道是哪个模块,读不到它具体调了什么**。
  370 +
  371 +### 6.2 关键架构发现:贴图号"我的作品"入口是 Flutter,用 Pigeon 桥接原生——一条完全独立于此前四条候选的通道
  372 +
  373 +反编译时按"类名含 Publish"批量搜索(不再猜单个类名),在 `com.tencent.pigeon.biz` 包下找到一整套**Pigeon**(Flutter 官方的强类型 native↔Dart 桥接代码生成器)生成的接口:`BizPublishApi`、`BizPublishArticleInfo`、`BizPublishResultPiegonInfo` 等。这证实了一个此前完全没考虑过的事实:**`BizMyWorksFlutterViewActivity`("我的作品"入口)是真 Flutter 页面,跟原生通信走的是 Pigeon MethodChannel,是一套完全独立于此前四条候选(`js1.m`/`m11.l0`/`wxartrappsvr`/`v70.w`,全是标准 Android/Kotlin 世界)的 IPC 机制**。这是今天最大的架构性收获,解释了为什么四条最合理候选全部落空——它们都在 Flutter 这堵墙的另一侧。
  374 +
  375 +`BizPublishApi` 接口方法列表(从 smali 直接读出的完整签名):
  376 +```
  377 +notifyBizPublishResult(BizPublishResultPiegonInfo result, callback) ← Flutter 算完结果后通知原生
  378 +openUrl(BizPublishArticleInfo articleInfo, Boolean shouldPop) ← 原生按 Flutter 指示打开文章 URL
  379 +getBizPublishCacheDirectory()
  380 +compressPublishImage(dstPath, origPath, targetLong, targetShort, comLev, callback)
  381 +requestPublishPicLocationPermission()
  382 +setNativeEditorDebugMode(mode)
  383 +(另外几个跟本次无关:bindPhone/checkWCPayIsReg/launchMapMiniProgramWithCallback/openRedPacketCoverLiteApp)
  384 +```
  385 +`BizPublishResultPiegonInfo` 字段:`success: Boolean, reason: String, title, url, appMsgData, imagesInfo, poiInfo` ——**这明显是"发布结果"的完整载荷**,`success`/`reason` 加上 `appMsgData`(文章数据)几乎可以肯定就是最终提交是否成功的确切信号。
  386 +
  387 +找到唯一实现这个接口的类 `Llx/m5;`(同时 `implements FlutterPlugin, ActivityAware`)。读了 `notifyBizPublishResult()` 的 smali 逻辑:它把 `BizPublishResultPiegonInfo` 的字段(success/reason/title/nickname/...)转成 JSON 转发给旧的 `Ltk/r`/`Lox/z` 相关模块——**读代码本身就能确认这是"接收 Flutter 已经算好的结果,转发给原生 UI 层",不是提交动作本身**,但只要能 hook 到它被调用的那一刻,就能拿到确切的 success/fail + 时间戳,用来跟其它已捕获的网络活动做时间关联。
  388 +
  389 +### 6.3 hook 装上了,但零触发——第二次撞上"静态读到的类,运行时够不到"
  390 +
  391 +给 `lx.m5.notifyBizPublishResult()` + `lx.m5.openUrl()` 都装了被动 dump hook(沿用一直安全的 LSPosda 机制,base+Tinker CL 全覆盖)。真机确认:
  392 +
  393 +- Hook **安装全部成功**(`com.tencent.mm` 主进程、`:push`、`:appbrand0/1`,以及一个新观察到的独立进程 **`com.tencent.mm:hotpot..`**——推测是"我的作品" Flutter 页面专用的隔离进程,在打开"作品"入口时才现身生成,此前四天调查从没见过这个进程名)。
  394 +- 两轮完整走完发布流程(含这次新出现的 `hotpot` 进程),**`notifyBizPublishResult`/`openUrl` 全程零触发**,但 `js1.m.c()`(68 次)等已知路径照常触发,本轮发布依旧真实成功(`chksm=` 再次变化)。
  395 +
  396 +这是今天第二次"静态反编译读到看起来完全对得上的类和方法,装好 hook 却零触发"(第一次是 `JsApiOpenBizPublishImage` 主类在 R8 优化后整个消失、连文件都找不到)。可能原因:①这份反编译产物(`~/wechat_apk/decompiled`)来自某个历史时间点的 base.apk,当前真机跑的版本可能已经把 `lx.m5` 这个具体类名换掉了(Kotlin/R8 生成的短名字对小版本更新很敏感,不像手写类名稳定);②或者 Kotlin 接口方法在真实调用时走的是编译器生成的桥接方法/合成方法,不是我们直接 hook 的那个签名。两次撞墙提示:**这份静态反编译资产可能已经过期,不能再假设"grep 出来的类名 = 当前真机在跑的类名"**。
  397 +
  398 +### 6.4 现状:架构性认知有巨大进展,但排除法本身连续两次因为"反编译资产过期/类被优化"而失手
  399 +
  400 +关键收获(即使没抓到最终调用,这些是扎实的正向进展):
  401 +1. **确认了"我的作品"是 Flutter,用 Pigeon 桥接**——这是理解整个功能为什么这么难逆向的关键:Flutter/Dart 编译产物(`libapp.so`,AOT 编译)对 smali 反编译完全不可见,是比小程序 JS 字节码更彻底的黑盒。
  402 +2. **确认了小程序(`wx0b2ec076ec39c786`)内部有 `massSend.js` 模块专门管发布提交**,但其字节码格式需要专门反编译器才能读出逻辑。
  403 +3. **两次验证"当前反编译资产可能已过期"**——`~/wechat_apk/decompiled` 是某次历史时间点 dump 的,微信自身可能已经因为小版本更新换过具体类名/合并过类。以后再要精确 hook 某个类之前,应该先重新拉一份**当前真机在跑**的 APK 做反编译,而不是复用旧资产。
  404 +
  405 +**如果要继续**,两个可行方向:
  406 +- **重新 dump 当前真机 base.apk 反编译**(`~/wechat_apk/` 里的旧 dump 可能对不上当前版本),确认 `BizPublishApi`/`lx.m5` 的类名有没有变化,再重新 hook。
  407 +- **放弃找 Flutter 原生桥接的确切方法名,改成 hook Pigeon 的 MethodChannel 通用调度层**(Pigeon 生成代码通常有一个统一的 `BasicMessageChannel`/`MethodChannel.setMethodCallHandler` 分发点,如果能找到并 hook 这个更底层、更不容易被改名的分发点,可以拿到所有 Pigeon API 调用的方法名+参数,不用管具体是不是 `lx.m5`)。
  408 +
  409 +鉴于今天已经连续两天、多轮真机验证 + 两次反编译资产过期的教训,这是一个自然的收尾点。
  410 +
  411 +---
  412 +
348 413 ## 四、现场状态(写这份记录时)
349 414  
350 415 - 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。
... ...
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
... ... @@ -201,6 +201,7 @@ public class WeChatHook implements IXposedHookLoadPackage {
201 201 // submit call, traced instead to a separate CDN service (§4 of doc).
202 202 hookBrandserviceCdnUpload(cl, lpparam.processName);
203 203 hookGenericImgCdnSubmit(cl, lpparam.processName);
  204 + hookBizPublishFlutterBridge(cl, lpparam.processName);
204 205 }
205 206  
206 207 // ── WCDB hook ──────────────────────────────────────────────────────────────
... ... @@ -1791,6 +1792,53 @@ public class WeChatHook implements IXposedHookLoadPackage {
1791 1792 }
1792 1793 }
1793 1794  
  1795 + // 贴图号发布 §6:四个原生候选全部证伪后,反编译发现"我的作品"入口
  1796 + // (BizMyWorksFlutterViewActivity) 是 Flutter,用 Pigeon(Flutter 官方 native 桥接代码
  1797 + // 生成器)跟原生通信——这是一条完全独立于此前四条候选(js1.m/m11.l0/wxartrappsvr/v70.w)
  1798 + // 的 IPC 机制,此前逆向全都没碰过。接口 com.tencent.pigeon.biz.BizPublishApi 的具体实现
  1799 + // 是 Llx/m5;(同时 implements FlutterPlugin/ActivityAware)。两个方法最相关:
  1800 + // - notifyBizPublishResult(BizPublishResultPiegonInfo, callback):Flutter side 算完
  1801 + // 结果(success/reason/title/...)后回调原生——从 smali 读到的逻辑是"收结果转发",
  1802 + // 不是提交本身,但能确认结果出现的精确时机 + 完整字段。
  1803 + // - openUrl(BizPublishArticleInfo, shouldPop):Flutter 让原生打开某篇文章的 URL
  1804 + // (大概率是打开 TmplWebViewMMUI),是"进入编辑器"那一步,不是提交。
  1805 + // 两个都挂,看真机发布时到底谁先触发、参数里有没有真正的提交痕迹。
  1806 + private void hookBizPublishFlutterBridge(ClassLoader cl, String procName) {
  1807 + try {
  1808 + Class<?> implCls = XposedHelpers.findClass("lx.m5", cl);
  1809 + XposedHelpers.findAndHookMethod(implCls, "notifyBizPublishResult",
  1810 + XposedHelpers.findClass("com.tencent.pigeon.biz.BizPublishResultPiegonInfo", cl),
  1811 + XposedHelpers.findClass("yz5.l", cl),
  1812 + new XC_MethodHook() {
  1813 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1814 + try {
  1815 + StringBuilder sb = new StringBuilder(
  1816 + "[WeChatHook] BIZPUBLISH-FLUTTER-RESULT notifyBizPublishResult() called in " + procName + "\n");
  1817 + dumpAllFields(p.args[0], sb, " ");
  1818 + XposedBridge.log(sb.toString());
  1819 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] BIZPUBLISH-FLUTTER-RESULT dump err: " + t); }
  1820 + }
  1821 + });
  1822 + XposedHelpers.findAndHookMethod(implCls, "openUrl",
  1823 + XposedHelpers.findClass("com.tencent.pigeon.biz.BizPublishArticleInfo", cl),
  1824 + boolean.class,
  1825 + new XC_MethodHook() {
  1826 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1827 + try {
  1828 + StringBuilder sb = new StringBuilder(
  1829 + "[WeChatHook] BIZPUBLISH-FLUTTER-OPENURL openUrl() called in " + procName
  1830 + + " shouldPop=" + p.args[1] + "\n");
  1831 + dumpAllFields(p.args[0], sb, " ");
  1832 + XposedBridge.log(sb.toString());
  1833 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] BIZPUBLISH-FLUTTER-OPENURL dump err: " + t); }
  1834 + }
  1835 + });
  1836 + XposedBridge.log("[WeChatHook] hookBizPublishFlutterBridge: hooked lx.m5 notifyBizPublishResult+openUrl in " + procName);
  1837 + } catch (Throwable t) {
  1838 + XposedBridge.log("[WeChatHook] hookBizPublishFlutterBridge failed in " + procName + ": " + t);
  1839 + }
  1840 + }
  1841 +
1794 1842 // 贴图号发布图片提交(BRANDSERVICE_PUBLISH_RESEARCH.md §4):js1.m.c()/bizh5webtransfer
1795 1843 // 那条纯文本 transfer 链路里从没见过提交调用——反编译顺着
1796 1844 // LuggageUploadMediaFileManager$UploadMediaFileTask.z() 往下追,图片走的是完全独立的
... ... @@ -3334,6 +3382,7 @@ public class WeChatHook implements IXposedHookLoadPackage {
3334 3382 hookAnyCgiCapture(sTinkerCL, procName + "/tinker");
3335 3383 hookBrandserviceCdnUpload(sTinkerCL, procName + "/tinker");
3336 3384 hookGenericImgCdnSubmit(sTinkerCL, procName + "/tinker");
  3385 + hookBizPublishFlutterBridge(sTinkerCL, procName + "/tinker");
3337 3386 // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/
3338 3387 // LoginUI) + bk run in Tinker CL — the base-CL hooks never
3339 3388 // fire (0 N-VISIBLE). Register them here so the "以平板登录"
... ...