# 贴图号自动发布日报 — 逆向进度记录(进行中) 目标:把 lifelog/wechat-viewer 生成的每日日报(图片 + 文字)自动发到微信"贴图号" (一种手机端管理的品牌服务号内容发布功能,入口在"我的作品")。 约束:跟 `RESEARCH.md`(消息发送)同一台设备(Pixel 10 Pro Fold, root + Magisk + frida-server 17.9.10 常驻 :27042),WeChat 8.0.74。 --- ## 一、已验证结论 ### 1. 网络层 MITM 是死路(别再试) 用 `phoneuse/mitm_proxy.py`(已在设备装 CA + iptables NAT 重定向 uid 10336 的 80/443 到本地代理) 抓包,贴图号发布 + 定位请求全部是 `POST szextshort.weixin.qq.com:443 /mmtls/`—— 外层 TLS 解密后 payload 还是微信自己的 MMTLS 二进制包,内容不可读。跟 `RESEARCH.md` 当年 "IM 消息走 MMTLS 不走 OkHttp"的结论一致,贴图号也一样,不是 Finder 那种明文 CGI。 **⚠️ 事故记录**:第一次全量拦截微信 uid 的 80/443 时,真实打断了用户当场发布(提示"上传图片失败", 后来确认是定位请求被搅坏)。所有 CA/iptables/mitm_proxy 改动已完全清理干净 (CA 从 `/system/etc/security/cacerts/` 删除,iptables 规则已 flush,进程已杀)。 **以后不要再用全量网络重定向的方式碰这个设备的微信流量。** ### 2. Activity 调用链(logcat 抓的,可信) ``` BizMyWorksFlutterViewActivity (plugin.brandservice.ui.flutter,Flutter 写的"我的作品"列表) → MediaTabPickerUI (plugin.gallery.picker.tab,原生选图) → AppBrandPluginUI (plugin.appbrand.ui,交给小程序宿主进程) → TmplWebViewMMUI (plugin.brandservice.ui.timeline.preload.ui,WebView 编辑器) → 回到 BizMyWorksFlutterViewActivity ``` ### 3. 反编译找到的关键 hook 点(重大进展) APK 反编译位置(设备上,Termux):`~/wechat_apk/decompiled`(apktool 3.0.2 反编译 `~/wechat_apk/base.apk`,从 `/data/app/~~xb1vAMX_YH46_JsgviBuTw==/com.tencent.mm-3EBNVMamk3aQotXae5mBQg==/base.apk` 拷贝,261MB,17 个 dex)。**这份反编译产物很大,没有也不应该进 git,留在设备上按需重新生成。** 搜索 `com/tencent/mm/plugin/brandservice/api/TransferRequestInfo` 的引用方,定位到: - **`com/tencent/mm/plugin/lite/jsapi/comms/k2.smali`** —— `LiteAppJsApiExtTransfer` (`lite` = 微信小程序框架内部代号)。方法 `a(String, JSONObject, boolean)` 是小程序 JS 调 `wx.xxx()` 通用透传 CGI 的原生实现:从传入的 `JSONObject` 里取 **`reqUrl`(明文 CGI 地址) / `cgiCmdId` / `reqBody`(明文请求体)/ `method`**,塞进 `TransferRequestInfo`, 再交给 `Ljs1/m;->c(TransferRequestInfo, callback)` 发出去(发出去之后才会被包进 MMTLS)。 - **`com/tencent/mm/plugin/lite/jsapi/comms/y7.smali`** —— `LiteAppJsApiWebTransfer`, 结构几乎一样,服务 WebView 侧(对应 `TmplWebViewMMUI`)。 **这意味着:任何小程序(包括贴图号背后那个)要发后端请求,都要先把明文 reqUrl/reqBody 过这两个方法之一。Hook 这两个方法 = 不用管 MMTLS,直接看到发布请求的明文内容,且只读打印 不改行为,不会像网络层 MITM 那样有破坏性副作用。** Hook 脚本已写好并验证能用:`wechat_send_internal/hook_lite_transfer.js` (`Java.perform` 里对 `k2`/`y7` 的 `a(String,JSONObject,boolean)` 做 overload hook, 打印三个参数后照常调用原方法,纯观察不拦截)。 --- ## 二、Frida attach 的坑(重要,省得下次重新踩) 微信这种巨型 App,`Java.perform` 第一次要枚举当前进程里所有已加载的类, 如果对着一个**已经跑了一段时间、加载了很多 class 的进程**去 attach, 枚举耗时会超过 frida CLI 脚本加载的默认超时(`Failed to load script: timeout was reached`)。 已经在这台设备上反复验证: - 对存量的 `com.tencent.mm:appbrand0/1/2`(不管是空闲的还是当前 TOP 前台的)attach,**全部超时**。 - 只有用 `frida -U -W '.*appbrand.*' -l script.js`(等一个**全新** spawn 的匹配进程, 从它刚诞生、类还没加载多少的时候就注入)才成功过一次: ``` Handling: Spawn(pid=22388, identifier="com.tencent.mm:appbrand1") Spawned `com.tencent.mm:appbrand1`. Resuming main thread! [hook] hooked com.tencent.mm.plugin.lite.jsapi.comms.k2.a(...) [hook] hooked com.tencent.mm.plugin.lite.jsapi.comms.y7.a(...) ``` - **`-W PATTERN` 的 PATTERN 是正则不是 glob**(`.*appbrand.*`,不能写 `*appbrand*`)。 - **`-W` 只逮一次**:撞见第一个匹配的 spawn 就处理,之后新冒出来的同名进程一律 "Ignoring", 不会连续抓多个。 微信这次的坑:**贴图号这个功能背后同时用了不止一个 appbrand 进程**(这次看到 appbrand0/1/2 三个都在跑),`-W` 只抓住了其中一个(appbrand1),但实际处理这次发布 JS 调用的 很可能是另外两个之一——它们当时已经不是"刚诞生"状态,attach 全部超时,没抓到任何 `reqUrl`/`reqBody` 输出。 --- ## 三、2026-07-27 新一轮验证结果(4 轮真机实测,关键发现:appbrand2 才是真正宿主) ### 3.1 三开 `-W` 会撞车导致目标进程崩溃(别再三开) 按原计划同时开 3 个 `frida -U -W '.*appbrand.*' -l hook_lite_transfer.js` 监听器,结果 这次发布流程**只**冒出了 1 个新 appbrand 进程(不是 3 个),3 个监听器同时抢着 `Handling`/`resume()` 同一个 spawn(pid 10481, appbrand0),导致目标进程真的崩了 (`Process crashed: Bad access due to invalid address`),钩子都没来得及装上。 **教训:一次只开 1 个监听器**,"多开几个提高命中率"这个假设本身是错的——是否有新进程 冒出跟开几个监听器无关,同一个 spawn 事件会被所有监听器同时抓到并竞争 resume, 多开只会增加竞争崩溃概率。所幸崩溃只杀掉 appbrand 子进程,不影响微信主进程/登录态, 用户当次发布仍然成功(微信自己重新拉起了 appbrand0)。 `yes | frida` 这个 keep-alive 技巧也有副作用:`y` 换行会被 frida REPL 当命令求值, 狂刷 `ReferenceError: 'y' is not defined`(无害但刷屏)。改用 `yes '' | frida ...` (空行)更干净。 ### 3.2 单开监听器成功装上钩子,但从未触发——因为挂错了进程 单开、清空 appbrand 后重试:`k2.a()`/`y7.a()` 都成功 hook 上(`appbrand1`, pid 21256), 但整场发布流程走完,**hook 里的 `console.log` 一次没触发过**。一度怀疑是方法签名/重载 不对,或者这条 CGI 通路假设整个错了。 ### 3.3 root cause:`frida -W` 只逮"当前进程池里第一个新冒出来的匹配 spawn",不是"这次发布用到的那个" 用 `adb logcat -v time ActivityTaskManager:I ActivityManager:I` 配合 hook 同时跑, 第 4 轮才看全整条链路的进程归属: ``` 11:04:01.764 Start proc 18899:com.tencent.mm:appbrand0 for Appbrand0IPCService ← 选图阶段起的,跟发布无关 11:10:08.241 START MediaTabPickerUI 11:10:16.225 START AppBrandPluginUI 11:10:20.128 Start proc 32030:com.tencent.mm:appbrand2 for Appbrand2IPCService ← 真正宿主 11:10:25.286 START TmplWebViewMMUI ← 紧跟在 appbrand2 起来之后 ``` `appbrand2`(pid 32030)几乎是紧挨着 `TmplWebViewMMUI`(真正装载发布用 WebView 编辑器的 Activity)启动前一刻才冒出来的,是它的宿主进程。但因为 `frida -W` 在流程更早的 `appbrand0` spawn 时就已经"用掉"了那一次捕获(这正是之前记录过的 `-W` 已知限制: "只逮一次,撞见第一个匹配的 spawn 就处理,之后新冒出来的同名进程一律 Ignoring"——见 `phoneuse` `TASKS.md` 相关记录),后面真正需要的 `appbrand2` spawn 被完全无视, hook 装在了一个跟这次发布毫不相干的进程上,自然永远不会触发。 **这不是"reqUrl/reqBody 假设错了",是"用 CLI 的 `-W` 这个一次性开关去应付一个进程池 一次流程里会冒出好几个同名子进程"这个用法本身跟场景不匹配。** ### 3.4 常驻 Python spawn-gating 脚本验证成功,但 hook 装上后仍然打不中 写了 `wechat_send_internal/multi_spawn_hook.py`(`device.enable_spawn_gating()` + `on('spawn-added', ...)`,逐个 spawn 都 attach+hook+resume,不像 CLI `-W` 那样只处理 第一个)。踩了一个新坑:`session.create_script()`(原生 Python binding)既不像 CLI 那样自动 bundle `frida-java-bridge`,也没有 `require()`——报 `ReferenceError: 'require' is not defined`。解法:本地起一个临时 npm 项目 `npm install frida-java-bridge`,写 `import Java from 'frida-java-bridge'` 的 entry.js, `frida-compile entry.js -o xxx_bundled.js -P gum -S` 编译成单文件 bundle, Python 脚本加载这个编译产物而不是原始 source。(`hook_lite_transfer.js` 保留原样给 CLI 用;`hook_lite_transfer_bundled.js` 是给 Python API 用的编译版,改了逻辑要两边 都同步 + 重新编译。) 修完这个坑后,`k2.a()`/`y7.a()` 在 appbrand0/1/2 三个进程上全部确认 hook 成功 (无报错),但**发布流程走完,三个进程加起来还是零次调用**。说明前面 §3.3 的 process 假设本身可能就是错的——不是"没挂上正确的 appbrand 编号",而是这次调用可能 根本不在任何 appbrand 进程里。 ### 3.5 反编译代码证实:真正的汇聚点是 `Ljs1/m;->c()`,很可能跑在主进程 翻了 `TmplWebViewMMUI.smali`:它只是 `TmplWebViewToolUI` → ... → `WebViewUI`(微信通用网页容器)的空壳子类,**跟小程序(`appbrand`/`lite`)体系完全 无关**,是走微信另一套更通用的 WebView JS-SDK 桥(`plugin/webview/luggage/jsapi/` 包,`luggage` 是内部代号)。搜 `TransferRequestInfo` 的全部引用方(比 §3.3 只搜 k2/y7 更全),找到 `com/tencent/mm/plugin/webview/luggage/jsapi/c.smali`(abstract 基类, 方法 `d(Context, String jsonStr, callback)`)——跟 k2/y7 结构几乎一样,从 JSON 里抠 `reqUrl`/`reqBody`/`cgiCmdId`/`h5Url` 建 `TransferRequestInfo`,但最后都殊途同归 调用同一个 sink:**`Ljs1/m;->c(TransferRequestInfo, callback)V`**(`js1/m.smali`)。 看了 `js1.m.c()` 的方法体:先判断 `Lcom/tencent/mm/sdk/platformtools/x2;->n()`, 是的话本地协程直接处理(`Ljs1/h`),不是的话通过 `Lcom/tencent/mm/ipcinvoker/f;->a("PROCESS_MAIN", requestInfo, ...)` **IPC 转发去 主进程**处理。`TmplWebViewMMUI` 本身大概率就跑在**主进程**(pid 29168,App 一直存活 不会重新 spawn),不在任何 appbrand 子进程——之前 logcat 看到的 `appbrand1`/`appbrand2` spawn(`MagicBrushServiceContextBase`/`Appbrand2IPCService`)现在看更像是同一时间段 凑巧冒出来的其它微信后台活动(图片编辑笔刷工具、某个通用 IPC 扩展服务),跟这次发布 未必有因果关系——之前的 process 归属推断是巧合关联,不是确证。 `Ljs1/m;->c()` 是比 k2/y7 更好的 hook 点:不管调用方是小程序侧还是 WebView-luggage 侧,到这一步 `TransferRequestInfo` 已经填好全部字段(`reqInfo.h.d`=reqUrl, `reqInfo.h.e`=reqBody, `reqInfo.h.f`=cgiCmdId, `reqInfo.h.h`=method, `reqInfo.e`=h5Url), 直接读字段就行,不用再解析原始 JSON。脚本:`wechat_send_internal/hook_transfer_sink.js` (原始版,entry)+ `hook_transfer_sink_bundled.js`(frida-compile 编译版,Python API 用)。 ### 3.6 ⚠️ 事故:spawn 主进程挂钩子导致微信"无法连接服务器",不要再直接这么干 `spawn_mm_and_hook.py`:`am force-stop com.tencent.mm` 全杀 → `device.spawn(["com.tencent.mm"])` (挂起态)→ attach + 装 `Ljs1/m;->c()` 钩子 → `resume()`。脚本本身跑起来一切正常 (hook 装上无报错),但**用户在手机上重新打开微信后,微信直接提示"无法连接服务器", 打不开**。Force-stop 全应用 + kill 掉我们的 frida 脚本 + 手动正常重新打开微信后 恢复正常——**确认是这次 spawn+hook 主进程的操作把微信启动流程搞坏了,不是设备网络 问题**(其间 `ping 8.8.8.8` 一直通)。 **教训**:之前对 appbrand 子进程做的"spawn 时挂起 → hook → resume"是安全的(那是 沙箱化的独立子进程,出问题最多子进程崩溃重启,不影响主进程/登录态,见 §3.1 尾注), 但**同样的手法用在主进程上风险明显更高**——主进程冷启动要做的初始化(网络栈/MMTLS 会话/一堆单例)顺序敏感,我们注入脚本 + spawn-gating 暂停的那个时间窗本身就可能 打乱这个顺序,哪怕脚本只做"pure observation"式的方法替换也可能触发这个问题 (具体是哪一步冲突的没有细查,代价太高不值得再试来定位)。**不要在没有更谨慎方案 之前重复"spawn 主进程 + 挂钩子"这个操作。** ### 3.7 第二次事故:换个注入时机(spawn 后立即 resume,隔 0.8s 再 attach)依然搞坏微信 `spawn_mm_and_hook.py` v2:不在 spawn-paused 状态下注入,而是 spawn 后立刻 `resume()`,隔 0.8 秒再对已经在跑的 pid 做正常 `device.attach()`。这次连 hook 本身 都没装成功——`Java.perform` 内部 attach JVMTI 环境时直接报 `Error: access violation accessing 0x0`(frida 自己的 JVMTI attach 逻辑挂了, 时机太早,ART 运行时还没就绪),我们的 hook 代码根本没跑到。 **但即便 hook 没装成功,微信依旧被搞坏了**——用户反馈"微信点不动"(界面卡死/无响应), 跟第一次的"无法连接服务器"症状不同,但同样需要 `am force-stop` 全杀 + 手动重新打开 才恢复正常。 **这排除了"是我们 hook 代码本身有 bug"这个解释**——两次事故一次 hook 装成功了 (§3.6,之后微信显示网络错误),一次 hook 压根没装上就已经 native 报错(这次, 微信直接卡死)。共同点只有"往微信主进程冷启动过程里做 frida attach 这个动作本身"。 不管注入时机多晚、hook 代码写不写东西,只要在主进程刚起来那段时间用 frida 碰它, 就有实质性概率把微信弄挂,且两次表现还不一样(无法连接服务器 / 界面卡死), 说明不是某个特定时序窗口的问题,而是这个操作本身对这台设备上这个微信版本 就是不安全的。 ### 3.8 结论:主进程注入这条路线到此为止 **不建议再尝试对微信主进程做任何形式的 frida spawn/attach**(不管是 spawn-paused 时机、resume 后延迟 attach,还是理论上的其它变体)。今天累计:6 轮真机手动发布 + 2 次主进程注入导致微信实际不可用(每次都要 force-stop 全杀 + 手动重开才恢复, 过程中用户体验是真实受影响的,不是纯理论风险)。风险已经从"可能浪费时间"上升到 "实际两次打断用户在用的微信",性价比不再支持继续在真机上试验变体。 `appbrand` 子进程的 spawn-gating hook(`multi_spawn_hook.py`,用 `hook_lite_transfer_bundled.js`)本身依然是安全的、已验证的技术(多次验证只影响 子进程,不影响主进程/登录态),可以留着给其它子进程场景复用;`Ljs1/m;->c()` 是不是真正 sink 也依然成立(反编译分析站得住),只是"怎么安全地够到跑在主进程里 的它"这个问题目前没有答案。 如果以后要再碰这条线,思路上可以考虑的方向(都没验证,下次再评估要不要试): - 不整个重启微信主进程,而是想办法在**已经在跑、且刚从后台切到前台**的主进程上 做非 spawn 式的常规 attach(拿掉"重启"这个变量,只保留"attach"这个变量, 分开验证到底是哪一步不安全)——但存量进程类枚举超时的老问题还在,且同样有 "只是 attach 就把微信弄挂"的风险,不能假设更安全。 - 完全放弃 hook 微信主进程,改成分析 MMTLS 外层协议本身能不能在不进程内注入的 情况下拿到明文(例如找有没有已知的 MMTLS 密钥导出/降级漏洞——目前没有调研过, 真实存在的可能性未知,可能是死路)。 - **退回"半自动"方案**:人工点发布,工具只负责自动生成图片+文案,不追求端到端 全自动。今天的两次真实故障之后,这个选项的性价比明显更高了,建议优先考虑。 --- ## 四、路线修正:改用 LSPosed(跟文字/图片/语音发送同一套机制),彻底解决主进程注入问题 用户指出关键盲点:文字/图片/语音发送逆向全程用的是 **LSPosed**(`xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java`,Zygote fork 时随进程诞生自动挂钩子),从没出过问题;这次改用 Frida spawn/attach 才两次真实搞坏微信。根因很清楚:**LSPosed 挂钩发生在系统自己的进程创建路径里(Zygote fork),不是外部 ptrace 注入一个已经在跑/刚起来的进程**,没有"抢在类加载膨胀前注入"这种时序竞争,自然不会撞上 Frida 那两次事故的问题。 ### 4.1 改造 WeChatHook.java,加 `hookBrandservicePublishTransfer` 在 `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 这个注入方式上。 字段映射(现场验证): ``` TransferRequestInfo { d=type(int), e=h5Url, f=scope, h=y24(Lr45/y24;), n=needVerifySignature, i=is_security_check } y24 { d=reqUrl, e=reqBody(JSON串), f=cgiCmdId, h=method, g=LinkedList headers } cw3 { d=key, e=value } ``` ### 4.2 真机捕获:js1.m.c() 全程只看到 webview 支持性调用,没看到"提交发布" 真人走一遍"我的作品"发布流程(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 打,跟真正提交表单/上传图片的调用形态不同。 ### 4.3 排查了两个疑似候选,都证伪 加了 `hookAnyCgiCapture`(复用 `hookVoiceCgiCapture` 的通用 cgi 捕获逻辑,去掉 `uploadvoice` 过滤,改成不设限一律记录 `com.tencent.mm.modelbase.l.a()` 的每次调用)验证还有没有其它内部二进制 cgi 调用可能是真正的提交: - **`/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`),跟贴图号发布无关,是这次会话的干扰项,不是漏网的真身)。 - **`/cgi-bin/micromsg-bin/newsendmsg`(1 次)**:时间戳对不上(14:18:59,早于 14:59 左右的真实发布窗口),是无关的背景消息活动,不是这次发布触发的。 - **`getphotoworks` / `get_me_tab_pho_items`(各 1-2 次,用 `w71.o1`/`w71.p1` 通用信封,跟 `mp/profile`/`mp/acct` 共用同一对协议类)**:命名和触发时机(打开"我的作品"入口附近)都像是刷新"作品列表",不是新建。 - 全程扫了所有 `reqUrl` 里的 `action=` 参数值,只有 `get_*`/`batch_report`/`getcomment`/`send_status` 这类读/上报语义,没有任何 `publish`/`post`/`create`/`submit` 字样。 ### 4.4 现状:提交发布的具体调用仍未定位,但基础设施已经完全打通 **没找到"是哪次调用真正创建了这条贴图号动态"**,但已经排除了目前想到的所有候选,且证实: 1. LSPosed 路线安全可靠(这次全程真机验证零故障),Frida spawn/attach 主进程那条路线(§3.6/3.7)**不再需要**——有更安全的替代方案。 2. `js1.m.c()` + `hookAnyCgiCapture`(通用 cgi 捕获)两个钩子都已在 `WeChatHook.java` 里长期驻留、可复用,改改过滤条件就能对着任意后续假设重新抓。 3. 真正的提交调用要么是:①落在我们观察窗口之外(用户操作比预想快,提交发生在按下"发布"瞬间,捕获时机没对齐——需要更精细的时间戳比对,或者干脆常驻抓一整晚不设窗口);②走了一条完全不同、不经过 `TransferRequestInfo`/`modelbase.l` 的原生路径(类似图片消息发送 `b80.u.oj` 那种非 h5transfer 的二进制上传通道,更可能,因为提交要带图片二进制数据,用一个设计给"文本 JSON reqBody"的桥接机制传大图不合常理)。 **下一步(未做,需要用户决策)**:如果要继续,建议方向是①把 `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 链路里找。 ### 4.5 第二个候选也证伪:图片 CDN 服务 `m11.l0.d1()` 没有触发 顺着 `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 安装无误、微信全程正常。 真人再走一遍完整发布流程(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 服务)都被真机数据证伪**。 ### 4.6 现状总结(截至本轮) 排除法进展: - ❌ `js1.m.c()`(brandservice/webview JS-API transfer 汇聚点)——只见只读态调用 - ❌ `Ljs1/m;` 对应的 wire cgi `bizh5webtransfer`——同上,没有提交语义 - ❌ `wxartrappsvr/route`(表面最高频)——反编译证实是小程序心跳/保活,无关 - ❌ `newsendmsg`——时间戳对不上,无关背景消息 - ❌ `m11.l0.d1()`(webview 图片上传 CDN 服务)——零触发 真机验证过程本身完全安全(LSPosed 全程零故障,跟用户此前文字/图片/语音发送逆向经验一致),但**核心问题仍未解决**:提交发布的真实调用点还没找到。可能性: 1. 提交发生在某个我们还没识别的第三条路径(Flutter 侧直接走 platform channel 调 native,完全绕开 `TransferRequestInfo`/`modelbase.l`/`m11.l0` 这三层已知桥接)。 2. 或者提交时机跟我们以为的不一致(比如"发布"按钮按下后有明显延迟才真正提交,我们的操作窗口理解有误差)。 本轮结束时决定:鉴于已经连续多轮真机验证都是负结果,且今天会话已经很长(6+ 轮真人发布配合 + 2 次 Frida 主进程事故 + 多轮 LSPosed 验证),暂停在此,留给用户决定是否继续投入,还是改走"半自动"方案(人工点发布,工具只管生成图片+文案)。 --- ## 五、改用 `am`+UI 自动化自主复现发布流程(2026-07-27 下午续) 用户提出关键改进:与其每轮都让用户在手机上手动操作、我在旁边被动等日志,不如**我自己直接用 `adb input tap`/`screencap` 驱动 UI**,全程自主复现"我的作品→贴图号→发表"整个流程,缩短每轮迭代的等待成本,也不再消耗用户注意力。 ### 5.1 UI 自动化关键坑:这台设备是可折叠机,`screencap` 默认拿错屏幕 Pixel 10 Pro Fold 有两块屏(outer/cover 屏 + inner 屏),`adb exec-out screencap -p` 不带 `-d` 会警告"multiple displays"并默认拿第一个找到的(`4619827677550801152`,这次测得是纯黑——大概率是没在用的那块屏,或者是折叠态下未激活的屏)。**必须显式指定 `-d `**:`dumpsys SurfaceFlinger --display-id` 列出两个 ID,`dumpsys display | grep mPrimaryDisplayDevice` 能看到当前哪个是 "Outer Display";这次机器当时是以 outer/cover 屏(1080×2364,display id `4619827677550801153`)在用,`input tap` 坐标也要按这块屏的分辨率算(截图工具返回图默认会整体缩放显示,需要按标注的缩放比例换算成真实坐标再传给 `input tap`)。 ### 5.2 自主复现全流程成功(无需用户参与),确认 `wxartrappsvr/route` 也不是提交点 自主完整跑通"我→作品→(贴图号)发表→选图→下一步→填标题/正文→发表"全程(用中性的 AI 日报图,不涉真人照片),全程无需用户操作。两轮独立发布均确认**服务器真的接受了新文章**(`js1.m.c()` 抓到的预览页 URL 里 `chksm=` 每轮都不同,且发布后能在"作品"列表里看到新帖子),排除了"根本没发出去,我们看错了"这种可能。 针对上一轮(§4)里最高频但内容读不出来的 `wxartrappsvr/route`(`r45.zt5`),本轮解开了它为什么读不出来的谜:**它的 payload 不是扁平字段,是一个 `LinkedList`**(`zt5.d` 字段),每个 `re7{d=type int, e=protobuf.g 装的 body 字节串, f=route/action 字符串}`——用之前"扫描字段 0-25 的 getString/getInteger/getLong"的通用逻辑天生看不到 List 类型字段,这是之前"内容都是空"的真正原因,不是"这条路真的没数据"。 给 `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`。**这不是提交发布的调用,是小程序自身运行时的埋点上报,跟贴图号提交内容无关。** ### 5.3 意外发现:图片编辑步骤本身是一个小程序(`appid=wx0b2ec076ec39c786`) 这是本轮最有价值的新信息:从截图流程看,选完图后有一步"编辑"(`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)。 ### 5.4 §5.3 结束时的排除法进展 - ❌ `js1.m.c()` / `bizh5webtransfer` —— 只读态 - ❌ `m11.l0.d1()` (CDN 上传服务)—— 零触发 - ❌ `wxartrappsvr/route`(`r45.zt5`/`re7`)—— 确认是小程序埋点上报,无关 - 🆕 图片编辑步骤是小程序 `wx0b2ec076ec39c786`,当时判断是下一个该深挖的方向 ### 5.5 第四个候选:普通消息发图的 CDN 服务 `v70.w.oj()` 也证伪 沿着"贴图号提交会不会复用普通消息发图那套服务"的思路(该服务已在 [[wechat-image-send-internals]] 里完整逆向过:`v70.w.oj(r70.g, o70.l)` 是消息图片真正启动 CDN 上传协程的方法),加了 `hookGenericImgCdnSubmit`——**被动观察**任意调用方(不只是我们自己 `sendImageViaAPI` 时构造的调用,用 `sCalledByUs` 标志区分),在 base+Tinker CL、全进程装好,真机确认 hook 安装成功、微信全程运行正常。 自主复现发布流程(这次用户手动解锁手机后由我接着跑,中途踩了一个新坑:**设备在会话中途自己息屏落锁了**,`adb shell wm dismiss-keyguard` 对有密码/图案锁的设备无效——不是每次都能纯脚本推进,锁屏后必须让用户物理解锁,不能也不该尝试绕过):**`v70.w.oj()` 全程零触发**,而 `js1.m.c()`/`bizh5webtransfer` 依旧只见只读态调用(277 次),本轮发布依旧真实成功(`chksm=` 再次变化,"作品"列表可见新帖子)。**第四个候选证伪**:贴图号提交图片不复用普通消息发图的 CDN 通道。 ### 5.6 现状:四个候选全部证伪,核心提交调用仍未定位 - ❌ `js1.m.c()` / `bizh5webtransfer` —— 只读态 - ❌ `m11.l0.d1()` (webview luggage CDN 上传服务)—— 零触发 - ❌ `wxartrappsvr/route`(`r45.zt5`/`re7`)—— 小程序埋点上报,无关 - ❌ `v70.w.oj()` (普通消息发图 CDN 服务)—— 零触发 四条最合理的候选路径都已经用真机数据(不是猜测)证伪,每次发布本身都确认真实成功。这已经不是"还没挂对钩子"的问题了——更可能是:①真正的提交调用发生在某个我们完全没设想到的类/服务里(贴图号可能有一套自己专属、从没在其它已逆向功能里出现过的原生 API);②或者走了完全绕开 `modelbase.l`/已知 CDN 服务的通道(比如小程序沙箱自己的 `wx.request`/`wx.uploadFile` 实现,跟主进程网络栈是分离的子系统,可能需要 hook 小程序 JS runtime 层而非 native 层)。 到此为止,`hookAnyCgiCapture` 是全局通用观察点,覆盖面已经很广(能看到所有内部二进制 cgi),四次真机验证也证明这套 LSPosed 工具链完全稳定可靠(今天全程零故障)。但纯靠"猜一个类挂上去看"的排除法已经遇到收益递减——建议下一步换个思路:要么直接反编译小程序沙箱自身的 JS bridge 层(`wx.uploadFile`/`wx.request` 在 native 侧的实现类,可能在 `com.tencent.mm.plugin.appbrand.*` 包下,是全新的、还没搜过的领域),要么接受"半自动"方案。今天的调查已经很深入,建议先到此为止,下次再看要不要继续深挖小程序沙箱 JS bridge 这条新方向。 --- ## 六、(次日续)读小程序运行时 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`)。 鉴于今天已经连续两天、多轮真机验证 + 两次反编译资产过期的教训,这是一个自然的收尾点。 ### 6.5 验证结果:不是反编译资产过期,是 Flutter/Pigeon 这条线本身大概率跟这次的发布流程无关 用户要求直接验证"当前真机在跑的 APK,类名是否变了"——按此做了严格验证: 1. 查当前安装信息:`versionName=8.0.74 versionCode=3120 lastUpdateTime=2026-07-06`;`~/wechat_apk/base.apk`(旧反编译资产的来源)是 2026-07-27 从设备拉的,版本吻合,**base.apk 本身没有过期**。 2. 但真正在跑的代码还叠了一层 **Tinker 热更新补丁**(`/data/data/com.tencent.mm/tinker/patch-83069b25/`,2026-07-12 生成)——补丁目录里翻到 **`lib/arm64-v8a/libapp.so`**(Flutter Dart AOT 编译产物的标准文件名),实锤"我的作品"这个 Flutter 功能是**靠 Tinker 热更新下发的**,不在原始安装包里。这印证了本文档最早(§一)就记录过的教训:"base.apk ≠ 正在跑的码",但这次要验证的类恰好两边都有。 3. 拉了 Tinker 补丁的 `dex/tinker_classN.apk`(174MB,17 个 dex),用 `strings` 扫每个 dex 确认 `BizPublishApi`/`lx.m5` 具体在哪个 dex(`classes11.dex`,跟 base.apk 反编译时的 `smali_classes11` 目录编号一致),用 jadx 直接反编译这个 dex 里的 `BizPublishApi.java` 全文对比。 **结论:两份反编译(base.apk vs Tinker 补丁)内容完全一致**,不是类名/代码变了导致 hook 落空。真正读懂的是 `BizPublishApi$Companion.setUp()` 的完整分发逻辑:11 个方法各自对应一个具名的 Flutter `BasicMessageChannel`(channel 名形如 `"dev.flutter.pigeon.mm_auto_gen.BizPublishApi.notifyBizPublishResult"`),**onMessage 收到 Dart 端发来的消息后直接 `bizPublishApi.notifyBizPublishResult(...)` 调用**,中间没有任何反射/代理/多态转发会让 Xposed 的方法级 hook 失效。也就是说:**hook 挂的位置技术上完全正确**(后来证实两份 dex 里代码一字不差),**两轮真机验证零触发的唯一合理解释是:Dart/Flutter 那一侧压根没有为这次测的"贴图号发单图"这个简单流程发送这条 Pigeon 消息**。 结合已经稳定复现的事实——`js1.m.c()`/`bizh5webtransfer` 每轮都稳定触发 60-280 次,且每轮发布都真实成功(`chksm` 持续变化)——比较合理的推断是:**`BizPublishApi` 这套 Pigeon 桥接大概率服务于另一个更复杂的发布场景(比如完整的公众号图文创作/`appmsg_editor` 富文本文章),不是"贴图号 + 单图 + 短文字"这个我们一直在测的简化路径**。这条 Flutter 线索本身是真实存在的架构,但对当前要抓的这个具体流程,很可能是一条死胡同。 ### 6.6 下一步方向(记录以便随时接续,未执行) 真正的提交调用大概率仍然落在已经反复确认触发、但从未逐条精确解码时间线的 `js1.m.c()`/`bizh5webtransfer` 洪流里(每轮 60-280 次调用,此前只做了整体分类统计,没有按"点击发表那一刻精确到毫秒级"做过 reqUrl 逐条比对)。下次可以: 1. 精确记录"点击发表"那一次 `input tap` 的系统时间戳(已经在脚本里做了,`date +%s.%N`),再把 `js1.m.c()` 洪流按时间排序,重点看 tap 后最先触发的几条、以及包含 `action=` 参数值明显不是 `get_*`/只读语义的那几条(此前扫描全部都是 get/batch_report 语义,但没有严格逐条过一遍,可能漏看)。 2. 或者干脆放弃继续猜测哪个原生/Flutter 类是入口,转向"发布结果一定会写库"这个更确定的信号——去 hook WCDB(`hookWCDB` 已有,之前用于消息表)看贴图号发布成功后有没有对应的本地 DB INSERT,反过来从写库那一刻回溯调用栈,比继续挨个猜 jsapi 类更有把握。 3. 或接受目前的进展,转向"半自动"方案(人工点发布,工具只管生成图片+文案)——今天两天的调查已经把所有直觉候选都验证过一遍,继续投入的边际收益在下降。 三个方向都还没做,留给下次接手时决定。 --- ## 四、现场状态(写这份记录时) - 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。 - 设备上遗留:`~/wechat_apk/`(含 base.apk + decompiled/,可保留复用,不小,按需清理)。 - 本次会话未留任何后台 frida/mitm 进程在跑(已逐一 kill 确认)。 - `wechat_send_internal/hook_lite_transfer.js` 已提交到仓库(本次新增,还未 git add/commit, 待用户决定是否连同这份记录一起提交)。