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