Commit b36fc0454aca51a046a38211a68f71778186a721

Authored by yibo
1 parent 645c4d30

research(wechat): 贴图号发布改用 LSPosed 排查提交调用,两个候选均证伪

改用文字/图片/语音发送同一套 LSPosed 机制(Zygote fork 时挂钩,非 Frida
spawn/attach),彻底避开了此前两次搞坏微信主进程的问题——全程真机验证零
故障。

追踪链路 js1.m.c()(brandservice/webview transfer 汇聚点) → wire cgi
bizh5webtransfer,真机确认全程只有只读态 mp.weixin.qq.com 调用,没有
提交语义。进一步反编译 LuggageUploadMediaFileManager 追出图片走独立 CDN
服务 m11.l0.d1(),同样零触发。两个最有希望的候选均被真机数据证伪,真正
的提交调用点仍未定位,可能是完全独立的第三条路径。

三个新增 hook(hookBrandservicePublishTransfer/hookAnyCgiCapture/
hookBrandserviceCdnUpload)均已装进 WeChatHook.java,base+Tinker CL
全覆盖,可复用于下一轮排查。
wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md
@@ -239,6 +239,66 @@ API 用)。 @@ -239,6 +239,66 @@ API 用)。
239 239
240 --- 240 ---
241 241
  242 +## 四、路线修正:改用 LSPosed(跟文字/图片/语音发送同一套机制),彻底解决主进程注入问题
  243 +
  244 +用户指出关键盲点:文字/图片/语音发送逆向全程用的是 **LSPosed**(`xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java`,Zygote fork 时随进程诞生自动挂钩子),从没出过问题;这次改用 Frida spawn/attach 才两次真实搞坏微信。根因很清楚:**LSPosed 挂钩发生在系统自己的进程创建路径里(Zygote fork),不是外部 ptrace 注入一个已经在跑/刚起来的进程**,没有"抢在类加载膨胀前注入"这种时序竞争,自然不会撞上 Frida 那两次事故的问题。
  245 +
  246 +### 4.1 改造 WeChatHook.java,加 `hookBrandservicePublishTransfer`
  247 +
  248 +在 `handleLoadPackage`(base classloader)和 App.onCreate 的 Tinker 重挂钩块(`sTinkerCL`)**两处都注册**——微信这版本 `js1.m` 类实际加载在 Tinker 补丁里,只挂 base CL 那份**装得上但永远不触发**(跟以前语音/图片踩过的"base CL vs Tinker CL"坑一模一样,见 [[wechat-voice-send-internals]])。两处都挂上后,`js1.m.c(TransferRequestInfo, callback)` 在 base + Tinker CL、主进程 + push + appbrand0/1 全部确认 hook 成功(`hooked js1.m.c x1`),**微信全程运行正常,没有任何"无法连接服务器"/"点不动"问题**——证实了用户的判断:LSPosed 这条路径本身没问题,问题出在 Frida spawn/attach 这个注入方式上。
  249 +
  250 +字段映射(现场验证):
  251 +```
  252 +TransferRequestInfo { d=type(int), e=h5Url, f=scope, h=y24(Lr45/y24;), n=needVerifySignature, i=is_security_check }
  253 +y24 { d=reqUrl, e=reqBody(JSON串), f=cgiCmdId, h=method, g=LinkedList<cw3> headers }
  254 +cw3 { d=key, e=value }
  255 +```
  256 +
  257 +### 4.2 真机捕获:js1.m.c() 全程只看到 webview 支持性调用,没看到"提交发布"
  258 +
  259 +真人走一遍"我的作品"发布流程(POI 选点→编辑→发布→查看结果),`js1.m.c()` 命中 ~58 次,`reqUrl` 全部是明文可读的 `mp.weixin.qq.com/mp/*`:`photo_account_poi_feed`(选点)、`creationcenter`(创作中心)、`appmsg_comment`/`appmsgthank`/`getappmsgext`/`getappmsgad`/`getbizbanner`/`searchwordbaike`/`wapcommreport`(这些全是**发布完之后自动打开文章预览页产生的阅读态/统计上报调用**,含 `publish_time=` 字段证实文章确已发布成功)。**全程没有一次 `reqBody != null` 或看起来像"提交内容"的调用**——`scope=mmbizmp_web` 型调用(reqBody 恒为 null)看起来是把 `reqUrl` 当成普通网页 GET 打,跟真正提交表单/上传图片的调用形态不同。
  260 +
  261 +### 4.3 排查了两个疑似候选,都证伪
  262 +
  263 +加了 `hookAnyCgiCapture`(复用 `hookVoiceCgiCapture` 的通用 cgi 捕获逻辑,去掉 `uploadvoice` 过滤,改成不设限一律记录 `com.tencent.mm.modelbase.l.a()` 的每次调用)验证还有没有其它内部二进制 cgi 调用可能是真正的提交:
  264 +
  265 +- **`/cgi-bin/mmbiz-bin/wxartrappsvr/route`(150 次,同窗口最高频)**:反编译排除——追完整条 `js1.m.c()→js1.h→js1.o(获取 h5auth token)→js1.g.a(回调)→ks1.g.b(真正 dispatch)→ks1.h.c()(动态路由型 requester,构造实际请求用的是 `Lr45/ou3`/`Lr45/su3`,cgi url 来自服务器下发的路由表 `Ljs1/s;->e` 缓存,不是硬编码的 wxartrappsvr)`——js1.m.c() 这条链路真正落地的 wire cgi 是 **`/cgi-bin/micromsg-bin/transfer/bizh5webtransfer`**(`r45.ou3`/`r45.su3`,58 次,跟 js1.m.c() 命中次数基本对上,`ou3.d` 字段直接内嵌整个 `y24` 子消息,我们已经从 js1.m.c() 直接拿到了同样的数据,只是没用扁平字段扫描器去 wxartrappsvr/route 的 `r45.zt5`/`r45.au5` 硬翻,翻了也是空——那是完全不相关的另一个机制,大概率是小程序通用心跳/保活(附近有 `js-operatewxdata-keepalive`),跟贴图号发布无关,是这次会话的干扰项,不是漏网的真身)。
  266 +- **`/cgi-bin/micromsg-bin/newsendmsg`(1 次)**:时间戳对不上(14:18:59,早于 14:59 左右的真实发布窗口),是无关的背景消息活动,不是这次发布触发的。
  267 +- **`getphotoworks` / `get_me_tab_pho_items`(各 1-2 次,用 `w71.o1`/`w71.p1` 通用信封,跟 `mp/profile`/`mp/acct` 共用同一对协议类)**:命名和触发时机(打开"我的作品"入口附近)都像是刷新"作品列表",不是新建。
  268 +- 全程扫了所有 `reqUrl` 里的 `action=` 参数值,只有 `get_*`/`batch_report`/`getcomment`/`send_status` 这类读/上报语义,没有任何 `publish`/`post`/`create`/`submit` 字样。
  269 +
  270 +### 4.4 现状:提交发布的具体调用仍未定位,但基础设施已经完全打通
  271 +
  272 +**没找到"是哪次调用真正创建了这条贴图号动态"**,但已经排除了目前想到的所有候选,且证实:
  273 +1. LSPosed 路线安全可靠(这次全程真机验证零故障),Frida spawn/attach 主进程那条路线(§3.6/3.7)**不再需要**——有更安全的替代方案。
  274 +2. `js1.m.c()` + `hookAnyCgiCapture`(通用 cgi 捕获)两个钩子都已在 `WeChatHook.java` 里长期驻留、可复用,改改过滤条件就能对着任意后续假设重新抓。
  275 +3. 真正的提交调用要么是:①落在我们观察窗口之外(用户操作比预想快,提交发生在按下"发布"瞬间,捕获时机没对齐——需要更精细的时间戳比对,或者干脆常驻抓一整晚不设窗口);②走了一条完全不同、不经过 `TransferRequestInfo`/`modelbase.l` 的原生路径(类似图片消息发送 `b80.u.oj` 那种非 h5transfer 的二进制上传通道,更可能,因为提交要带图片二进制数据,用一个设计给"文本 JSON reqBody"的桥接机制传大图不合常理)。
  276 +
  277 +**下一步(未做,需要用户决策)**:如果要继续,建议方向是①把 `hookAnyCgiCapture` 常驻挂着,不设时间窗口,用户任意时间点发布一次,回头翻完整 log 找提交调用的准确时间戳;②或者反过来想——参考 `wechat-image-send-internals.md` 记录的图片消息发送链路(`b80.u.oj`/`o70.k`/`o70.h`,走的是二进制 CDN 上传+插库,不是 h5transfer),贴图号发布很可能也是类似的"图片走独立 CDN/上传通道,只有文字元数据走 h5transfer"的混合模式,值得直接搜 `plugin/brandservice` 包里有没有专属的图片上传 NetScene/CDN 提交方法,而不是继续在 `js1.m`/`ks1.*` 这条纯文本 transfer 链路里找。
  278 +
  279 +### 4.5 第二个候选也证伪:图片 CDN 服务 `m11.l0.d1()` 没有触发
  280 +
  281 +顺着 `LuggageUploadMediaFileManager$UploadMediaFileTask.z()`(webview 的图片上传 IPC 任务壳)往下追,找到一个跟消息发图(`v70.w.oj`/`b80.u.oj`)完全不同的**独立 CDN 提交服务**:`Lm11/l0;->d1(Lk70/h0;ZJ)Lm11/b0;`(`m11.l0` 单例经 `Lm11/b1;->Di()` 拿到)。加了 `hookBrandserviceCdnUpload`(before+after 都 dump,`k70.h0`=提交参数 ~17 字段,`m11.b0`=CDN 结果 ~40 字段)在 base+Tinker CL、全进程装上,真机确认 hook 安装无误、微信全程正常。
  282 +
  283 +真人再走一遍完整发布流程(POI→编辑→发布→查看结果):**`m11.l0.d1()` 全程零次触发**,`js1.m.c()`/`bizh5webtransfer` 命中数(63 次)和 URL 分布跟上一轮(4.2/4.3)几乎一模一样,仍然全是 `mp.weixin.qq.com` 只读态调用,没有任何 `action=publish/post/create/submit` 语义。**两个最有希望的候选(文本 transfer 桥 + 图片 CDN 服务)都被真机数据证伪**。
  284 +
  285 +### 4.6 现状总结(截至本轮)
  286 +
  287 +排除法进展:
  288 +- ❌ `js1.m.c()`(brandservice/webview JS-API transfer 汇聚点)——只见只读态调用
  289 +- ❌ `Ljs1/m;` 对应的 wire cgi `bizh5webtransfer`——同上,没有提交语义
  290 +- ❌ `wxartrappsvr/route`(表面最高频)——反编译证实是小程序心跳/保活,无关
  291 +- ❌ `newsendmsg`——时间戳对不上,无关背景消息
  292 +- ❌ `m11.l0.d1()`(webview 图片上传 CDN 服务)——零触发
  293 +
  294 +真机验证过程本身完全安全(LSPosed 全程零故障,跟用户此前文字/图片/语音发送逆向经验一致),但**核心问题仍未解决**:提交发布的真实调用点还没找到。可能性:
  295 +1. 提交发生在某个我们还没识别的第三条路径(Flutter 侧直接走 platform channel 调 native,完全绕开 `TransferRequestInfo`/`modelbase.l`/`m11.l0` 这三层已知桥接)。
  296 +2. 或者提交时机跟我们以为的不一致(比如"发布"按钮按下后有明显延迟才真正提交,我们的操作窗口理解有误差)。
  297 +
  298 +本轮结束时决定:鉴于已经连续多轮真机验证都是负结果,且今天会话已经很长(6+ 轮真人发布配合 + 2 次 Frida 主进程事故 + 多轮 LSPosed 验证),暂停在此,留给用户决定是否继续投入,还是改走"半自动"方案(人工点发布,工具只管生成图片+文案)。
  299 +
  300 +---
  301 +
242 ## 四、现场状态(写这份记录时) 302 ## 四、现场状态(写这份记录时)
243 303
244 - 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。 304 - 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
@@ -183,6 +183,23 @@ public class WeChatHook implements IXposedHookLoadPackage { @@ -183,6 +183,23 @@ public class WeChatHook implements IXposedHookLoadPackage {
183 // Capture the real uploadvoice cgi request (this device uses inline-cgi, not CDN). 183 // Capture the real uploadvoice cgi request (this device uses inline-cgi, not CDN).
184 hookVoiceCgiCapture(cl, lpparam.processName); 184 hookVoiceCgiCapture(cl, lpparam.processName);
185 hookVoiceCgiResult(cl, lpparam.processName); 185 hookVoiceCgiResult(cl, lpparam.processName);
  186 +
  187 + // 贴图号发布逆向(wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md §3.5):
  188 + // both the mini-program (k2/y7) path and the WebView "luggage" JS-API path
  189 + // funnel into this one sink before dispatch (local coroutine or IPC to
  190 + // PROCESS_MAIN). Read-only: dumps TransferRequestInfo/y24 fields, does not
  191 + // touch args or return value. LSPosed-installed (Zygote-time), NOT a live
  192 + // Frida spawn/attach — that approach broke WeChat's main-process cold start
  193 + // twice in one session (see doc §3.6/3.7); this hook exists precisely to
  194 + // avoid repeating that mistake.
  195 + hookBrandservicePublishTransfer(cl, lpparam.processName);
  196 + // Broad catch-all: js1.m.c() only ever saw plain webview traffic for this flow
  197 + // (§3.9), so also watch the generic internal-cgi builder unfiltered in case the
  198 + // real publish submission is a native NetScene call instead.
  199 + hookAnyCgiCapture(cl, lpparam.processName);
  200 + // Image submission for brandservice: js1.m.c()/bizh5webtransfer never showed a
  201 + // submit call, traced instead to a separate CDN service (§4 of doc).
  202 + hookBrandserviceCdnUpload(cl, lpparam.processName);
186 } 203 }
187 204
188 // ── WCDB hook ────────────────────────────────────────────────────────────── 205 // ── WCDB hook ──────────────────────────────────────────────────────────────
@@ -1525,6 +1542,79 @@ public class WeChatHook implements IXposedHookLoadPackage { @@ -1525,6 +1542,79 @@ public class WeChatHook implements IXposedHookLoadPackage {
1525 } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiResult failed in " + procName + ": " + t); } 1542 } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiResult failed in " + procName + ": " + t); }
1526 } 1543 }
1527 1544
  1545 + // ── 贴图号发布 sink: js1.m.c(TransferRequestInfo, zq1.g0) ────────────────────
  1546 + //反编译确认(见 BRANDSERVICE_PUBLISH_RESEARCH.md §3.5): brandservice/webview
  1547 + // 的 luggage JS-API 层(com.tencent.mm.plugin.webview.luggage.jsapi.c.d())和
  1548 + // 小程序侧的 k2/y7 都把 reqUrl/reqBody/cgiCmdId 填进 TransferRequestInfo 后调
  1549 + // 这一个方法。字段映射: TransferRequestInfo{d=type, e=h5Url, f=scope, h=y24,
  1550 + // n=needVerifySignature, i=is_security_check}; y24{d=reqUrl, e=reqBody(JSON串),
  1551 + // f=cgiCmdId, h=method, g=LinkedList<cw3> headers(cw3{d=key,e=value})}。
  1552 + private static void dumpFieldsByName(Object obj, String[] names, StringBuilder sb, String indent) {
  1553 + if (obj == null) { sb.append(indent).append("(null)\n"); return; }
  1554 + for (String name : names) {
  1555 + try {
  1556 + java.lang.reflect.Field f = obj.getClass().getDeclaredField(name);
  1557 + f.setAccessible(true);
  1558 + Object v = f.get(obj);
  1559 + String vs = v == null ? "null" : v.toString();
  1560 + if (vs.length() > 4000) vs = vs.substring(0, 4000) + "…(truncated)";
  1561 + sb.append(indent).append(name).append(" = ").append(vs).append("\n");
  1562 + } catch (Throwable t) {
  1563 + sb.append(indent).append(name).append(" = <err: ").append(t).append(">\n");
  1564 + }
  1565 + }
  1566 + }
  1567 +
  1568 + private void hookBrandservicePublishTransfer(ClassLoader cl, String procName) {
  1569 + try {
  1570 + Class<?> sinkCls = XposedHelpers.findClass("js1.m", cl);
  1571 + final Class<?> reqInfoCls = XposedHelpers.findClass(
  1572 + "com.tencent.mm.plugin.brandservice.api.TransferRequestInfo", cl);
  1573 + int hooked = 0;
  1574 + for (java.lang.reflect.Method m : sinkCls.getDeclaredMethods()) {
  1575 + Class<?>[] pt = m.getParameterTypes();
  1576 + if (pt.length != 2 || !reqInfoCls.isAssignableFrom(pt[0])) continue;
  1577 + m.setAccessible(true);
  1578 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1579 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1580 + try {
  1581 + Object reqInfo = p.args[0];
  1582 + StringBuilder sb = new StringBuilder(
  1583 + "[WeChatHook] BRANDSVC-SINK js1.m.c() called in " + procName + "\n");
  1584 + if (reqInfo == null) { sb.append(" reqInfo = null\n"); XposedBridge.log(sb.toString()); return; }
  1585 + dumpFieldsByName(reqInfo, new String[]{"d", "e", "f", "n", "i"}, sb, " ");
  1586 + Object h;
  1587 + try {
  1588 + java.lang.reflect.Field hf = reqInfo.getClass().getDeclaredField("h");
  1589 + hf.setAccessible(true);
  1590 + h = hf.get(reqInfo);
  1591 + } catch (Throwable t) { h = null; }
  1592 + if (h == null) {
  1593 + sb.append(" h (y24, reqUrl/reqBody/cgiCmdId container) = null\n");
  1594 + } else {
  1595 + sb.append(" h (y24):\n");
  1596 + dumpFieldsByName(h, new String[]{"d", "e", "f", "h"}, sb, " ");
  1597 + try {
  1598 + java.lang.reflect.Field gf = h.getClass().getDeclaredField("g");
  1599 + gf.setAccessible(true);
  1600 + Object headers = gf.get(h);
  1601 + sb.append(" g (headers) = ").append(headers == null ? "null" : headers.toString()).append("\n");
  1602 + } catch (Throwable t) { sb.append(" g (headers) = <err: ").append(t).append(">\n"); }
  1603 + }
  1604 + XposedBridge.log(sb.toString());
  1605 + } catch (Throwable t) {
  1606 + XposedBridge.log("[WeChatHook] BRANDSVC-SINK dump err: " + t);
  1607 + }
  1608 + }
  1609 + });
  1610 + hooked++;
  1611 + }
  1612 + XposedBridge.log("[WeChatHook] hookBrandservicePublishTransfer: hooked js1.m.c x" + hooked + " in " + procName);
  1613 + } catch (Throwable t) {
  1614 + XposedBridge.log("[WeChatHook] hookBrandservicePublishTransfer failed in " + procName + ": " + t);
  1615 + }
  1616 + }
  1617 +
1528 private void hookVoiceCgiCapture(ClassLoader cl, String procName) { 1618 private void hookVoiceCgiCapture(ClassLoader cl, String procName) {
1529 try { 1619 try {
1530 Class<?> lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl); 1620 Class<?> lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl);
@@ -1573,6 +1663,118 @@ public class WeChatHook implements IXposedHookLoadPackage { @@ -1573,6 +1663,118 @@ public class WeChatHook implements IXposedHookLoadPackage {
1573 } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiCapture failed in " + procName + ": " + t); } 1663 } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiCapture failed in " + procName + ": " + t); }
1574 } 1664 }
1575 1665
  1666 + // 贴图号发布探测: js1.m.c() 抓到的全是 mp.weixin.qq.com 网页态调用(真实测过,见
  1667 + // BRANDSERVICE_PUBLISH_RESEARCH.md §3.9),没有一次带 protobuf reqBody/cgiCmdId 的——
  1668 + // 说明真正的"提交发布"根本不走 brandservice/webview 那条 JS-API 桥,大概率是原生侧
  1669 + // (Flutter platform channel 之后的 Kotlin/Java 代码)直接建的内部 cgi 调用,类似文字/
  1670 + // 语音发送那条 NetSceneQueue 路子。与其继续在混淆的 Flutter 桥接代码里找具体是哪个类,
  1671 + // 直接照抄 hookVoiceCgiCapture 但去掉 "uploadvoice" 过滤——modelbase.l.a() 是所有内部
  1672 + // 二进制 cgi 调用的公共构造入口,不管发起方是谁,只要真是这条路子就跑不掉这个方法。
  1673 + private void hookAnyCgiCapture(ClassLoader cl, String procName) {
  1674 + try {
  1675 + Class<?> lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl);
  1676 + final Class<?> pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl);
  1677 + XposedBridge.hookMethod(lCls.getDeclaredMethod("a"), new XC_MethodHook() {
  1678 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1679 + try {
  1680 + Object self = p.thisObject;
  1681 + String url = null;
  1682 + java.util.List<Object> pbs = new java.util.ArrayList<>();
  1683 + for (Class<?> c = self.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1684 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1685 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1686 + f.setAccessible(true);
  1687 + Object v; try { v = f.get(self); } catch (Throwable t) { continue; }
  1688 + if (v instanceof String && ((String) v).contains("cgi-bin")) url = (String) v;
  1689 + else if (v != null && pbCls.isInstance(v)) pbs.add(v);
  1690 + }
  1691 + }
  1692 + if (url == null) return; // no gate otherwise — log every internal cgi call
  1693 + StringBuilder sb = new StringBuilder("[WeChatHook] ANYCGI url=" + url + " pbCount=" + pbs.size());
  1694 + int idx = 0;
  1695 + for (Object req : pbs) {
  1696 + sb.append("\n -- pb#").append(idx++).append(" ").append(req.getClass().getName()).append(" --");
  1697 + for (int i = 0; i <= 25; i++) {
  1698 + Object val = null; String kind = "";
  1699 + try { Object c = XposedHelpers.callMethod(req, "getCustom", i);
  1700 + if (c != null) { val = "custom:" + c.getClass().getName(); kind = "C"; } } catch (Throwable t) {}
  1701 + if (val == null) { try { Object s = XposedHelpers.callMethod(req, "getString", i); if (s != null) { val = s; kind = "S"; } } catch (Throwable t) {} }
  1702 + if (val == null) { try { Object iv = XposedHelpers.callMethod(req, "getInteger", i); if (iv != null && ((Integer) iv) != 0) { val = iv; kind = "I"; } } catch (Throwable t) {} }
  1703 + if (val == null) { try { Object lv = XposedHelpers.callMethod(req, "getLong", i); if (lv != null && ((Long) lv) != 0L) { val = lv; kind = "L"; } } catch (Throwable t) {} }
  1704 + if (val != null) {
  1705 + String vs = String.valueOf(val);
  1706 + if (vs.length() > 200) vs = vs.substring(0, 200) + "…";
  1707 + sb.append("\n [").append(i).append("](").append(kind).append(")=").append(vs);
  1708 + }
  1709 + }
  1710 + }
  1711 + XposedBridge.log(sb.toString());
  1712 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] ANYCGI err: " + t); }
  1713 + }
  1714 + });
  1715 + XposedBridge.log("[WeChatHook] hookAnyCgiCapture: hooked modelbase.l.a() (unfiltered) in " + procName);
  1716 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookAnyCgiCapture failed in " + procName + ": " + t); }
  1717 + }
  1718 +
  1719 + // 贴图号发布图片提交(BRANDSERVICE_PUBLISH_RESEARCH.md §4):js1.m.c()/bizh5webtransfer
  1720 + // 那条纯文本 transfer 链路里从没见过提交调用——反编译顺着
  1721 + // LuggageUploadMediaFileManager$UploadMediaFileTask.z() 往下追,图片走的是完全独立的
  1722 + // CDN 提交服务:Lm11/l0;->d1(Lk70/h0;ZJ)Lm11/b0;(m11.l0 单例经 Lm11/b1;->Di() 拿到,
  1723 + // 跟消息发图 v70.w.oj/b80.u.oj 是两套不同的服务)。k70.h0 = 提交参数(本地文件路径等),
  1724 + // m11.b0 = CDN 提交结果(约 40 个混淆字段,字段语义未知,先全量 dump 出来看)。
  1725 + private static void dumpAllFields(Object obj, StringBuilder sb, String indent) {
  1726 + if (obj == null) { sb.append(indent).append("(null)\n"); return; }
  1727 + for (java.lang.reflect.Field f : obj.getClass().getDeclaredFields()) {
  1728 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1729 + try {
  1730 + f.setAccessible(true);
  1731 + Object v = f.get(obj);
  1732 + String vs = v == null ? "null" : v.toString();
  1733 + if (vs.length() > 300) vs = vs.substring(0, 300) + "…";
  1734 + sb.append(indent).append(f.getName()).append(" (").append(f.getType().getSimpleName())
  1735 + .append(") = ").append(vs).append("\n");
  1736 + } catch (Throwable t) {
  1737 + sb.append(indent).append(f.getName()).append(" = <err: ").append(t).append(">\n");
  1738 + }
  1739 + }
  1740 + }
  1741 +
  1742 + private void hookBrandserviceCdnUpload(ClassLoader cl, String procName) {
  1743 + try {
  1744 + Class<?> l0Cls = XposedHelpers.findClass("m11.l0", cl);
  1745 + final Class<?> h0Cls = XposedHelpers.findClass("k70.h0", cl);
  1746 + int hooked = 0;
  1747 + for (java.lang.reflect.Method m : l0Cls.getDeclaredMethods()) {
  1748 + if (!"d1".equals(m.getName())) continue;
  1749 + Class<?>[] pt = m.getParameterTypes();
  1750 + if (pt.length == 0 || !h0Cls.isAssignableFrom(pt[0])) continue;
  1751 + m.setAccessible(true);
  1752 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1753 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1754 + try {
  1755 + StringBuilder sb = new StringBuilder(
  1756 + "[WeChatHook] BRANDSVC-CDN m11.l0.d1() called in " + procName + "\n k70.h0 params:\n");
  1757 + dumpAllFields(p.args[0], sb, " ");
  1758 + XposedBridge.log(sb.toString());
  1759 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] BRANDSVC-CDN before err: " + t); }
  1760 + }
  1761 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1762 + try {
  1763 + StringBuilder sb = new StringBuilder(
  1764 + "[WeChatHook] BRANDSVC-CDN m11.l0.d1() returned in " + procName + "\n m11.b0 result:\n");
  1765 + dumpAllFields(p.getResult(), sb, " ");
  1766 + XposedBridge.log(sb.toString());
  1767 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] BRANDSVC-CDN after err: " + t); }
  1768 + }
  1769 + });
  1770 + hooked++;
  1771 + }
  1772 + XposedBridge.log("[WeChatHook] hookBrandserviceCdnUpload: hooked m11.l0.d1 x" + hooked + " in " + procName);
  1773 + } catch (Throwable t) {
  1774 + XposedBridge.log("[WeChatHook] hookBrandserviceCdnUpload failed in " + procName + ": " + t);
  1775 + }
  1776 + }
  1777 +
1576 private void hookVoiceUpload(ClassLoader cl, String procName) { 1778 private void hookVoiceUpload(ClassLoader cl, String procName) {
1577 try { 1779 try {
1578 Class<?> g1 = XposedHelpers.findClass("w21.g1", cl); 1780 Class<?> g1 = XposedHelpers.findClass("w21.g1", cl);
@@ -3050,6 +3252,12 @@ public class WeChatHook implements IXposedHookLoadPackage { @@ -3050,6 +3252,12 @@ public class WeChatHook implements IXposedHookLoadPackage {
3050 // Capture the real uploadvoice cgi request in Tinker CL. 3252 // Capture the real uploadvoice cgi request in Tinker CL.
3051 hookVoiceCgiCapture(sTinkerCL, procName + "/tinker"); 3253 hookVoiceCgiCapture(sTinkerCL, procName + "/tinker");
3052 hookVoiceCgiResult(sTinkerCL, procName + "/tinker"); 3254 hookVoiceCgiResult(sTinkerCL, procName + "/tinker");
  3255 + // js1.m may live in a Tinker DEX too (base-CL hook installed
  3256 + // but confirmed it never fires — same "base CL vs Tinker CL"
  3257 + // gotcha as f9/w11.s1/jg0.x above). Re-hook here.
  3258 + hookBrandservicePublishTransfer(sTinkerCL, procName + "/tinker");
  3259 + hookAnyCgiCapture(sTinkerCL, procName + "/tinker");
  3260 + hookBrandserviceCdnUpload(sTinkerCL, procName + "/tinker");
3053 // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/ 3261 // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/
3054 // LoginUI) + bk run in Tinker CL — the base-CL hooks never 3262 // LoginUI) + bk run in Tinker CL — the base-CL hooks never
3055 // fire (0 N-VISIBLE). Register them here so the "以平板登录" 3263 // fire (0 N-VISIBLE). Register them here so the "以平板登录"