BRANDSERVICE_PUBLISH_RESEARCH.md 17 KB

贴图号自动发布日报 — 逆向进度记录(进行中)

目标:把 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/<hex>—— 外层 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 密钥导出/降级漏洞——目前没有调研过, 真实存在的可能性未知,可能是死路)。
  • 退回"半自动"方案:人工点发布,工具只负责自动生成图片+文案,不追求端到端 全自动。今天的两次真实故障之后,这个选项的性价比明显更高了,建议优先考虑。

四、现场状态(写这份记录时)

  • 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。
  • 设备上遗留:~/wechat_apk/(含 base.apk + decompiled/,可保留复用,不小,按需清理)。
  • 本次会话未留任何后台 frida/mitm 进程在跑(已逐一 kill 确认)。
  • wechat_send_internal/hook_lite_transfer.js 已提交到仓库(本次新增,还未 git add/commit, 待用户决定是否连同这份记录一起提交)。