Commit 67417267d50ebb5885b85ea6223c794dfaffbed0
1 parent
b36fc045
research(wechat): 改用自主 UI 自动化复现发布流程,wxartrappsvr/route 也证伪
改用 adb input tap + screencap 自主驱动"我的作品→贴图号→发表"全流程, 不再需要用户手动操作,缩短每轮迭代成本。真机验证坑:可折叠机 screencap 默认拿错屏幕,需显式 -d <display-id>。 给 hookAnyCgiCapture 加了 r45.zt5(wxartrappsvr/route)专属 dump—— 之前通用扁平字段扫描器看不到它的 LinkedList<re7> payload,误判成 "无内容"。真机确认这条路全是小程序 SDK 埋点上报(页面性能/console log 转发/事件上报),跟提交发布无关,第三个候选证伪。 意外发现:图片编辑步骤本身是个独立小程序(appid wx0b2ec076ec39c786, page_path pages/image_editor/image_editor),此前一直当作纯 H5 webview——这是下次调查该跟进的新方向。核心提交调用仍未定位。
Showing
2 changed files
with
75 additions
and
0 deletions
wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md
| ... | ... | @@ -299,6 +299,38 @@ cw3 { d=key, e=value } |
| 299 | 299 | |
| 300 | 300 | --- |
| 301 | 301 | |
| 302 | +## 五、改用 `am`+UI 自动化自主复现发布流程(2026-07-27 下午续) | |
| 303 | + | |
| 304 | +用户提出关键改进:与其每轮都让用户在手机上手动操作、我在旁边被动等日志,不如**我自己直接用 `adb input tap`/`screencap` 驱动 UI**,全程自主复现"我的作品→贴图号→发表"整个流程,缩短每轮迭代的等待成本,也不再消耗用户注意力。 | |
| 305 | + | |
| 306 | +### 5.1 UI 自动化关键坑:这台设备是可折叠机,`screencap` 默认拿错屏幕 | |
| 307 | + | |
| 308 | +Pixel 10 Pro Fold 有两块屏(outer/cover 屏 + inner 屏),`adb exec-out screencap -p` 不带 `-d` 会警告"multiple displays"并默认拿第一个找到的(`4619827677550801152`,这次测得是纯黑——大概率是没在用的那块屏,或者是折叠态下未激活的屏)。**必须显式指定 `-d <display-id>`**:`dumpsys SurfaceFlinger --display-id` 列出两个 ID,`dumpsys display | grep mPrimaryDisplayDevice` 能看到当前哪个是 "Outer Display";这次机器当时是以 outer/cover 屏(1080×2364,display id `4619827677550801153`)在用,`input tap` 坐标也要按这块屏的分辨率算(截图工具返回图默认会整体缩放显示,需要按标注的缩放比例换算成真实坐标再传给 `input tap`)。 | |
| 309 | + | |
| 310 | +### 5.2 自主复现全流程成功(无需用户参与),确认 `wxartrappsvr/route` 也不是提交点 | |
| 311 | + | |
| 312 | +自主完整跑通"我→作品→(贴图号)发表→选图→下一步→填标题/正文→发表"全程(用中性的 AI 日报图,不涉真人照片),全程无需用户操作。两轮独立发布均确认**服务器真的接受了新文章**(`js1.m.c()` 抓到的预览页 URL 里 `chksm=` 每轮都不同,且发布后能在"作品"列表里看到新帖子),排除了"根本没发出去,我们看错了"这种可能。 | |
| 313 | + | |
| 314 | +针对上一轮(§4)里最高频但内容读不出来的 `wxartrappsvr/route`(`r45.zt5`),本轮解开了它为什么读不出来的谜:**它的 payload 不是扁平字段,是一个 `LinkedList<r45.re7>`**(`zt5.d` 字段),每个 `re7{d=type int, e=protobuf.g 装的 body 字节串, f=route/action 字符串}`——用之前"扫描字段 0-25 的 getString/getInteger/getLong"的通用逻辑天生看不到 List 类型字段,这是之前"内容都是空"的真正原因,不是"这条路真的没数据"。 | |
| 315 | + | |
| 316 | +给 `hookAnyCgiCapture` 加了 `r45.zt5` 专属 dump(读 `d` 字段的 LinkedList,逐个 `re7` 打印 `type(d)`/`route(f)`/`body(e.i())`),真机验证:**这条路的 payload 全是小程序 SDK 的埋点上报**——`type=1000`(页面性能指标:`costtime`/`staytime`/`targetAction`)、`type=1001`(`weapplog`,小程序 console.log 转发到日志系统)、`type=1005`(页面事件上报:`event_name`/`enter_scene`),`route(f)` 恒为 `wx0b2ec076ec39c786`(一个小程序 appid),`page_path` 恒为 `pages/image_editor/image_editor`。**这不是提交发布的调用,是小程序自身运行时的埋点上报,跟贴图号提交内容无关。** | |
| 317 | + | |
| 318 | +### 5.3 意外发现:图片编辑步骤本身是一个小程序(`appid=wx0b2ec076ec39c786`) | |
| 319 | + | |
| 320 | +这是本轮最有价值的新信息:从截图流程看,选完图后有一步"编辑"(`page_path: pages/image_editor/image_editor`)此前一直以为整条"编辑/填标题/发表"UI 都是纯 `TmplWebViewMMUI`(H5 网页),但埋点数据显示图片裁剪/编辑这一步实际上是**一个独立小程序**(appid `wx0b2ec076ec39c786`)渲染出来的,运行在 `appbrand*` 进程里(跟 §3 一开始的假设吻合,只是走了另一条我们没追的分支)。这意味着:贴图号发布流程可能是"原生 Flutter UI(选图) → 小程序(图片编辑) → H5 webview(标题/正文/发表)"三段拼接,每段用不同技术栈、不同网络通道,**"提交"这一步具体发生在哪一段,仍是未知数**——如果发生在小程序段,走的会是小程序专属的另一套 cgi 通道(`k2`/`y7`,回到最早 §一 反编译过的入口,但那条路当时已经确认在 appbrand 进程里挂了钩子却零触发,可能是挂错了具体哪个 appbrand 编号,或者小程序用的是另一套更底层的 native bridge,不经过 k2/y7)。 | |
| 321 | + | |
| 322 | +### 5.4 现状(本轮结束):三个候选全部证伪,第四个新线索(小程序 image_editor)尚未跟进 | |
| 323 | + | |
| 324 | +排除法进展更新: | |
| 325 | +- ❌ `js1.m.c()` / `bizh5webtransfer` —— 只读态 | |
| 326 | +- ❌ `m11.l0.d1()` (CDN 上传服务)—— 零触发 | |
| 327 | +- ❌ `wxartrappsvr/route`(`r45.zt5`/`re7`)—— 确认是小程序埋点上报,无关 | |
| 328 | +- 🆕 图片编辑步骤是小程序 `wx0b2ec076ec39c786`,可能是下一个该深挖的方向,但还没有具体行动 | |
| 329 | + | |
| 330 | +用户配合方式已改进(不再需要用户手动操作,可自主复现),今天已知的三条候选路径均已用真机数据证伪,核心提交调用仍未定位。建议下次接着来的方向:直接搜这个小程序 appid 相关的原生桥接代码(`k2`/`y7` 之外,可能还有别的小程序专属 jsapi 处理提交),或者接受当前"半自动"退路。 | |
| 331 | + | |
| 332 | +--- | |
| 333 | + | |
| 302 | 334 | ## 四、现场状态(写这份记录时) |
| 303 | 335 | |
| 304 | 336 | - 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。 | ... | ... |
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
| ... | ... | @@ -1694,6 +1694,49 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 1694 | 1694 | int idx = 0; |
| 1695 | 1695 | for (Object req : pbs) { |
| 1696 | 1696 | sb.append("\n -- pb#").append(idx++).append(" ").append(req.getClass().getName()).append(" --"); |
| 1697 | + // wxartrappsvr/route (r45.zt5) is a generic RPC multiplexer: field | |
| 1698 | + // `d` is a LinkedList<r45.re7>, each re7{d=type int, e=protobuf.g | |
| 1699 | + // body bytes, f=route/action String}. Flat getString/getInteger | |
| 1700 | + // scanning below never sees this (it's a List, not a scalar field), | |
| 1701 | + // which is why earlier passes thought this call carried no payload. | |
| 1702 | + // Dump it explicitly so we can see what's actually being routed. | |
| 1703 | + if (req.getClass().getName().equals("r45.zt5")) { | |
| 1704 | + try { | |
| 1705 | + java.lang.reflect.Field lf = req.getClass().getDeclaredField("d"); | |
| 1706 | + lf.setAccessible(true); | |
| 1707 | + java.util.LinkedList<?> items = (java.util.LinkedList<?>) lf.get(req); | |
| 1708 | + sb.append("\n zt5.d LinkedList<re7> size=").append(items.size()); | |
| 1709 | + int ridx = 0; | |
| 1710 | + for (Object item : items) { | |
| 1711 | + String typeStr = "?", routeStr = "?", bodyStr = "?"; | |
| 1712 | + try { | |
| 1713 | + java.lang.reflect.Field df = item.getClass().getDeclaredField("d"); | |
| 1714 | + df.setAccessible(true); | |
| 1715 | + typeStr = String.valueOf(df.get(item)); | |
| 1716 | + } catch (Throwable t) {} | |
| 1717 | + try { | |
| 1718 | + java.lang.reflect.Field ff = item.getClass().getDeclaredField("f"); | |
| 1719 | + ff.setAccessible(true); | |
| 1720 | + Object fv = ff.get(item); | |
| 1721 | + routeStr = fv == null ? "null" : fv.toString(); | |
| 1722 | + } catch (Throwable t) {} | |
| 1723 | + try { | |
| 1724 | + java.lang.reflect.Field ef = item.getClass().getDeclaredField("e"); | |
| 1725 | + ef.setAccessible(true); | |
| 1726 | + Object ev = ef.get(item); | |
| 1727 | + if (ev != null) { | |
| 1728 | + Object asStr = XposedHelpers.callMethod(ev, "i"); | |
| 1729 | + bodyStr = asStr == null ? "null" : asStr.toString(); | |
| 1730 | + if (bodyStr.length() > 500) bodyStr = bodyStr.substring(0, 500) + "…"; | |
| 1731 | + } | |
| 1732 | + } catch (Throwable t) { bodyStr = "<err: " + t + ">"; } | |
| 1733 | + sb.append("\n re7#").append(ridx++) | |
| 1734 | + .append(" type(d)=").append(typeStr) | |
| 1735 | + .append(" route(f)=").append(routeStr) | |
| 1736 | + .append(" body(e.i())=").append(bodyStr); | |
| 1737 | + } | |
| 1738 | + } catch (Throwable t) { sb.append("\n zt5 dump err: ").append(t); } | |
| 1739 | + } | |
| 1697 | 1740 | for (int i = 0; i <= 25; i++) { |
| 1698 | 1741 | Object val = null; String kind = ""; |
| 1699 | 1742 | try { Object c = XposedHelpers.callMethod(req, "getCustom", i); | ... | ... |