Commit c6f760bb40855c3633c93b5987f43c48062ba72b
1 parent
a87d7f62
research(wechat): 贴图号发布逆向 - k2/y7 hook 明文 CGI 路线
MITM 网络层拦截确认死路(贴图号走 MMTLS,跟 IM 消息一样不可读), 改从 LiteAppJsApiExtTransfer/WebTransfer 的 k2.a()/y7.a() hook 拿明文 reqUrl/reqBody,绕开 MMTLS。hook 脚本验证过能跑,Frida attach 存量 appbrand 进程会超时,只有 -W 等全新 spawn 能抓到。 mitm_proxy.py 顺手加了本地 jsonl 抓包日志(调试用,跟本次逆向工作 共用同一台设备)。
Showing
3 changed files
with
152 additions
and
1 deletions
phoneuse/mitm_proxy.py
| ... | ... | @@ -105,7 +105,17 @@ def make_host_cert(hostname: str) -> tuple[Path, Path]: |
| 105 | 105 | return cert_path, key_path |
| 106 | 106 | |
| 107 | 107 | |
| 108 | +CAPTURE_LOG = Path(os.environ.get("MITM_CAPTURE_LOG", | |
| 109 | + str(Path.home() / ".hermes" / "event_bus" / "mitm_captures.jsonl"))) | |
| 110 | + | |
| 111 | + | |
| 108 | 112 | def post_event(event: dict): |
| 113 | + record = dict(event) | |
| 114 | + try: | |
| 115 | + with open(CAPTURE_LOG, "a") as f: | |
| 116 | + f.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| 117 | + except Exception: | |
| 118 | + pass | |
| 109 | 119 | try: |
| 110 | 120 | payload = json.dumps({ |
| 111 | 121 | "type": "network", |
| ... | ... | @@ -207,7 +217,7 @@ async def handle_connect(client_r: asyncio.StreamReader, |
| 207 | 217 | "port": port, |
| 208 | 218 | "req_len": len(req_acc), |
| 209 | 219 | "resp_len": len(resp_acc), |
| 210 | - "req_preview": req_acc[:512].decode("utf-8", errors="replace"), | |
| 220 | + "req_preview": req_acc[:2048].decode("utf-8", errors="replace"), | |
| 211 | 221 | "ts": int(time.time()), |
| 212 | 222 | }) |
| 213 | 223 | except Exception: |
| ... | ... | @@ -262,6 +272,7 @@ async def handle_http(client_r: asyncio.StreamReader, |
| 262 | 272 | post_event({ |
| 263 | 273 | "method": method, "url": url, "host": host, "port": port, |
| 264 | 274 | "req_len": len(req_acc), "resp_len": len(resp_acc), |
| 275 | + "req_preview": req_acc[:2048].decode("utf-8", errors="replace"), | |
| 265 | 276 | "ts": int(time.time()), |
| 266 | 277 | }) |
| 267 | 278 | ... | ... |
wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md
0 → 100644
| 1 | +# 贴图号自动发布日报 — 逆向进度记录(进行中) | |
| 2 | + | |
| 3 | +目标:把 lifelog/wechat-viewer 生成的每日日报(图片 + 文字)自动发到微信"贴图号" | |
| 4 | +(一种手机端管理的品牌服务号内容发布功能,入口在"我的作品")。 | |
| 5 | + | |
| 6 | +约束:跟 `RESEARCH.md`(消息发送)同一台设备(Pixel 10 Pro Fold, root + Magisk + frida-server | |
| 7 | +17.9.10 常驻 :27042),WeChat 8.0.74。 | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 一、已验证结论 | |
| 12 | + | |
| 13 | +### 1. 网络层 MITM 是死路(别再试) | |
| 14 | + | |
| 15 | +用 `phoneuse/mitm_proxy.py`(已在设备装 CA + iptables NAT 重定向 uid 10336 的 80/443 到本地代理) | |
| 16 | +抓包,贴图号发布 + 定位请求全部是 `POST szextshort.weixin.qq.com:443 /mmtls/<hex>`—— | |
| 17 | +外层 TLS 解密后 payload 还是微信自己的 MMTLS 二进制包,内容不可读。跟 `RESEARCH.md` 当年 | |
| 18 | +"IM 消息走 MMTLS 不走 OkHttp"的结论一致,贴图号也一样,不是 Finder 那种明文 CGI。 | |
| 19 | + | |
| 20 | +**⚠️ 事故记录**:第一次全量拦截微信 uid 的 80/443 时,真实打断了用户当场发布(提示"上传图片失败", | |
| 21 | +后来确认是定位请求被搅坏)。所有 CA/iptables/mitm_proxy 改动已完全清理干净 | |
| 22 | +(CA 从 `/system/etc/security/cacerts/` 删除,iptables 规则已 flush,进程已杀)。 | |
| 23 | +**以后不要再用全量网络重定向的方式碰这个设备的微信流量。** | |
| 24 | + | |
| 25 | +### 2. Activity 调用链(logcat 抓的,可信) | |
| 26 | + | |
| 27 | +``` | |
| 28 | +BizMyWorksFlutterViewActivity (plugin.brandservice.ui.flutter,Flutter 写的"我的作品"列表) | |
| 29 | + → MediaTabPickerUI (plugin.gallery.picker.tab,原生选图) | |
| 30 | + → AppBrandPluginUI (plugin.appbrand.ui,交给小程序宿主进程) | |
| 31 | + → TmplWebViewMMUI (plugin.brandservice.ui.timeline.preload.ui,WebView 编辑器) | |
| 32 | + → 回到 BizMyWorksFlutterViewActivity | |
| 33 | +``` | |
| 34 | + | |
| 35 | +### 3. 反编译找到的关键 hook 点(重大进展) | |
| 36 | + | |
| 37 | +APK 反编译位置(设备上,Termux):`~/wechat_apk/decompiled`(apktool 3.0.2 反编译 | |
| 38 | +`~/wechat_apk/base.apk`,从 `/data/app/~~xb1vAMX_YH46_JsgviBuTw==/com.tencent.mm-3EBNVMamk3aQotXae5mBQg==/base.apk` | |
| 39 | +拷贝,261MB,17 个 dex)。**这份反编译产物很大,没有也不应该进 git,留在设备上按需重新生成。** | |
| 40 | + | |
| 41 | +搜索 `com/tencent/mm/plugin/brandservice/api/TransferRequestInfo` 的引用方,定位到: | |
| 42 | + | |
| 43 | +- **`com/tencent/mm/plugin/lite/jsapi/comms/k2.smali`** —— `LiteAppJsApiExtTransfer` | |
| 44 | + (`lite` = 微信小程序框架内部代号)。方法 `a(String, JSONObject, boolean)` 是小程序 JS | |
| 45 | + 调 `wx.xxx()` 通用透传 CGI 的原生实现:从传入的 `JSONObject` 里取 **`reqUrl`(明文 CGI 地址) | |
| 46 | + / `cgiCmdId` / `reqBody`(明文请求体)/ `method`**,塞进 `TransferRequestInfo`, | |
| 47 | + 再交给 `Ljs1/m;->c(TransferRequestInfo, callback)` 发出去(发出去之后才会被包进 MMTLS)。 | |
| 48 | +- **`com/tencent/mm/plugin/lite/jsapi/comms/y7.smali`** —— `LiteAppJsApiWebTransfer`, | |
| 49 | + 结构几乎一样,服务 WebView 侧(对应 `TmplWebViewMMUI`)。 | |
| 50 | + | |
| 51 | +**这意味着:任何小程序(包括贴图号背后那个)要发后端请求,都要先把明文 reqUrl/reqBody | |
| 52 | +过这两个方法之一。Hook 这两个方法 = 不用管 MMTLS,直接看到发布请求的明文内容,且只读打印 | |
| 53 | +不改行为,不会像网络层 MITM 那样有破坏性副作用。** | |
| 54 | + | |
| 55 | +Hook 脚本已写好并验证能用:`wechat_send_internal/hook_lite_transfer.js` | |
| 56 | +(`Java.perform` 里对 `k2`/`y7` 的 `a(String,JSONObject,boolean)` 做 overload hook, | |
| 57 | +打印三个参数后照常调用原方法,纯观察不拦截)。 | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 二、Frida attach 的坑(重要,省得下次重新踩) | |
| 62 | + | |
| 63 | +微信这种巨型 App,`Java.perform` 第一次要枚举当前进程里所有已加载的类, | |
| 64 | +如果对着一个**已经跑了一段时间、加载了很多 class 的进程**去 attach, | |
| 65 | +枚举耗时会超过 frida CLI 脚本加载的默认超时(`Failed to load script: timeout was reached`)。 | |
| 66 | +已经在这台设备上反复验证: | |
| 67 | +- 对存量的 `com.tencent.mm:appbrand0/1/2`(不管是空闲的还是当前 TOP 前台的)attach,**全部超时**。 | |
| 68 | +- 只有用 `frida -U -W '.*appbrand.*' -l script.js`(等一个**全新** spawn 的匹配进程, | |
| 69 | + 从它刚诞生、类还没加载多少的时候就注入)才成功过一次: | |
| 70 | + ``` | |
| 71 | + Handling: Spawn(pid=22388, identifier="com.tencent.mm:appbrand1") | |
| 72 | + Spawned `com.tencent.mm:appbrand1`. Resuming main thread! | |
| 73 | + [hook] hooked com.tencent.mm.plugin.lite.jsapi.comms.k2.a(...) | |
| 74 | + [hook] hooked com.tencent.mm.plugin.lite.jsapi.comms.y7.a(...) | |
| 75 | + ``` | |
| 76 | +- **`-W PATTERN` 的 PATTERN 是正则不是 glob**(`.*appbrand.*`,不能写 `*appbrand*`)。 | |
| 77 | +- **`-W` 只逮一次**:撞见第一个匹配的 spawn 就处理,之后新冒出来的同名进程一律 "Ignoring", | |
| 78 | + 不会连续抓多个。 | |
| 79 | + | |
| 80 | +微信这次的坑:**贴图号这个功能背后同时用了不止一个 appbrand 进程**(这次看到 appbrand0/1/2 | |
| 81 | +三个都在跑),`-W` 只抓住了其中一个(appbrand1),但实际处理这次发布 JS 调用的 | |
| 82 | +很可能是另外两个之一——它们当时已经不是"刚诞生"状态,attach 全部超时,没抓到任何 | |
| 83 | +`reqUrl`/`reqBody` 输出。 | |
| 84 | + | |
| 85 | +--- | |
| 86 | + | |
| 87 | +## 三、下一步计划(还没做,下次接着来) | |
| 88 | + | |
| 89 | +1. **先把 3 个 appbrand 进程全部杀掉**(`su -c 'kill -9 <pid0> <pid1> <pid2>'`,杀子进程不影响 | |
| 90 | + 微信主进程/登录态,不用重启整个微信)。 | |
| 91 | +2. **同时开 3 个 `frida -U -W '.*appbrand.*' -l wechat_send_internal/hook_lite_transfer.js` | |
| 92 | + 后台进程**(各自输出到独立日志文件),寄希望于 3 个监听器分别逮住 3 个新冒出来的 appbrand | |
| 93 | + 进程(不保证一定三个都能抓到不冲突,但比只开一个强)。 | |
| 94 | + - 后台常驻的技巧:`nohup bash -c "yes | frida ... -l script.js" > log 2>&1 &` | |
| 95 | + (用 `yes` 管道防止 frida REPL 因为 stdin EOF 而退出导致 hook 被卸载)。 | |
| 96 | +3. 让用户重新走一遍"我的作品"发布流程(打开 → 选图 → 发布)。 | |
| 97 | +4. 检查三份日志里有没有 `[hook] com.tencent.mm.plugin.lite.jsapi.comms.k2.a() called`, | |
| 98 | + 拿到里面的 `reqUrl`/`cgiCmdId`/`reqBody` JSON。 | |
| 99 | +5. 如果拿到了:`reqBody` 大概率是个 protobuf 或 JSON,需要再看一层怎么编码 | |
| 100 | + (`TransferRequestInfo` 里 `h` 字段是 `Lr45/y24` protobuf 对象,字段 `d`=reqUrl, | |
| 101 | + `e`=reqBody, `f`=cgiCmdId, `h`=method),可能需要另外找 `r45/y24.smali` 看 protobuf | |
| 102 | + schema,才能拼出一个能重放的请求。 | |
| 103 | +6. 如果这条路线折腾成本继续偏高(目前已经好几轮真人配合失败),跟用户确认是否值得 | |
| 104 | + 继续投入,或者退回到"半自动"方案(比如让人工点一下发布,只自动生成图片+文案, | |
| 105 | + 不追求全自动免手工)。 | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## 四、现场状态(写这份记录时) | |
| 110 | + | |
| 111 | +- 设备上所有 mitm/iptables/CA 改动均已清理(见上文事故记录),微信网络恢复正常。 | |
| 112 | +- 设备上遗留:`~/wechat_apk/`(含 base.apk + decompiled/,可保留复用,不小,按需清理)。 | |
| 113 | +- 本次会话未留任何后台 frida/mitm 进程在跑(已逐一 kill 确认)。 | |
| 114 | +- `wechat_send_internal/hook_lite_transfer.js` 已提交到仓库(本次新增,还未 git add/commit, | |
| 115 | + 待用户决定是否连同这份记录一起提交)。 | ... | ... |
wechat_send_internal/hook_lite_transfer.js
0 → 100644
| 1 | +// Read-only hook: log LiteApp (Mini Program) generic CGI transfer calls. | |
| 2 | +// Does NOT modify parameters or block the call — pure observation. | |
| 3 | +Java.perform(function () { | |
| 4 | + function hookClass(clsName) { | |
| 5 | + try { | |
| 6 | + var cls = Java.use(clsName); | |
| 7 | + cls.a.overload('java.lang.String', 'org.json.JSONObject', 'boolean').implementation = function (p1, p2, p3) { | |
| 8 | + try { | |
| 9 | + console.log("\n[hook] " + clsName + ".a() called at " + new Date().toISOString()); | |
| 10 | + console.log(" arg1 (appid/auth?): " + p1); | |
| 11 | + console.log(" arg2 (json params): " + (p2 !== null ? p2.toString() : "null")); | |
| 12 | + console.log(" arg3 (flag): " + p3); | |
| 13 | + } catch (e) { | |
| 14 | + console.log("[hook] logging error: " + e); | |
| 15 | + } | |
| 16 | + return this.a(p1, p2, p3); | |
| 17 | + }; | |
| 18 | + console.log("[hook] hooked " + clsName + ".a(String, JSONObject, boolean)"); | |
| 19 | + } catch (e) { | |
| 20 | + console.log("[hook] FAILED to hook " + clsName + ": " + e); | |
| 21 | + } | |
| 22 | + } | |
| 23 | + hookClass("com.tencent.mm.plugin.lite.jsapi.comms.k2"); | |
| 24 | + hookClass("com.tencent.mm.plugin.lite.jsapi.comms.y7"); | |
| 25 | +}); | ... | ... |