Commit 3dfe11b2a49a1409199ec5ee2811229575e93dbb
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,而不是复用旧反编译资产。
Showing
2 changed files
with
114 additions
and
0 deletions
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 "以平板登录" | ... | ... |