贴图号自动发布日报 — 逆向进度记录(进行中)
目标:把 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 密钥导出/降级漏洞——目前没有调研过, 真实存在的可能性未知,可能是死路)。
- 退回"半自动"方案:人工点发布,工具只负责自动生成图片+文案,不追求端到端 全自动。今天的两次真实故障之后,这个选项的性价比明显更高了,建议优先考虑。
四、路线修正:改用 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<cw3> 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 现状:提交发布的具体调用仍未定位,但基础设施已经完全打通
没找到"是哪次调用真正创建了这条贴图号动态",但已经排除了目前想到的所有候选,且证实:
- LSPosed 路线安全可靠(这次全程真机验证零故障),Frida spawn/attach 主进程那条路线(§3.6/3.7)不再需要——有更安全的替代方案。
js1.m.c()+hookAnyCgiCapture(通用 cgi 捕获)两个钩子都已在WeChatHook.java里长期驻留、可复用,改改过滤条件就能对着任意后续假设重新抓。- 真正的提交调用要么是:①落在我们观察窗口之外(用户操作比预想快,提交发生在按下"发布"瞬间,捕获时机没对齐——需要更精细的时间戳比对,或者干脆常驻抓一整晚不设窗口);②走了一条完全不同、不经过
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 cgibizh5webtransfer——同上,没有提交语义 - ❌
wxartrappsvr/route(表面最高频)——反编译证实是小程序心跳/保活,无关 - ❌
newsendmsg——时间戳对不上,无关背景消息 - ❌
m11.l0.d1()(webview 图片上传 CDN 服务)——零触发
真机验证过程本身完全安全(LSPosed 全程零故障,跟用户此前文字/图片/语音发送逆向经验一致),但核心问题仍未解决:提交发布的真实调用点还没找到。可能性:
- 提交发生在某个我们还没识别的第三条路径(Flutter 侧直接走 platform channel 调 native,完全绕开
TransferRequestInfo/modelbase.l/m11.l0这三层已知桥接)。 - 或者提交时机跟我们以为的不一致(比如"发布"按钮按下后有明显延迟才真正提交,我们的操作窗口理解有误差)。
本轮结束时决定:鉴于已经连续多轮真机验证都是负结果,且今天会话已经很长(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 <display-id>: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<r45.re7>(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 现状:架构性认知有巨大进展,但排除法本身连续两次因为"反编译资产过期/类被优化"而失手
关键收获(即使没抓到最终调用,这些是扎实的正向进展):
- 确认了"我的作品"是 Flutter,用 Pigeon 桥接——这是理解整个功能为什么这么难逆向的关键:Flutter/Dart 编译产物(
libapp.so,AOT 编译)对 smali 反编译完全不可见,是比小程序 JS 字节码更彻底的黑盒。 - 确认了小程序(
wx0b2ec076ec39c786)内部有massSend.js模块专门管发布提交,但其字节码格式需要专门反编译器才能读出逻辑。 - 两次验证"当前反编译资产可能已过期"——
~/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 改动均已清理(见上文事故记录),微信网络恢复正常。
- 设备上遗留:
~/wechat_apk/(含 base.apk + decompiled/,可保留复用,不小,按需清理)。 - 本次会话未留任何后台 frida/mitm 进程在跑(已逐一 kill 确认)。
wechat_send_internal/hook_lite_transfer.js已提交到仓库(本次新增,还未 git add/commit, 待用户决定是否连同这份记录一起提交)。