WeChat 非 A11y 发送消息 — 逆向研究报告
目标:WeChat 8.0.74 / Android 16 / Pixel 10 Pro Fold (rooted + LSPosed JingMatrix Vector v1.11.0) 约束:不封号,Research only,未触发真实发送
一、三条路线结论
| 路线 | 原理 | 可行性 | 理由 |
|---|---|---|---|
| Route A | LSPosed hook 调用 WeChat 内部 NetSceneSendMsg API |
✅ 可行 | 已找到完整调用链,同正常用户发送路径完全一致 |
| Route B | Hook 网络层(OkHttp/SSL 拦截) | ❌ 不可行 | 消息发送走 MMTLS(WeChat 自定义加密 TCP),不经 OkHttp/HTTPS |
| Route C | 直写 SQLite message 表 |
❌ 不可行 | DB 写入只是本地状态,不触发网络发送,消息对方收不到 |
二、Route A — 完整调用链(逆向结果)
2.1 关键类(DEX 位置)
| 类名 | DEX | 用途 |
|---|---|---|
w11.s1 |
classes9.dex | SendMsgCgiFactory 工厂入口(static a(talker)) |
w11.r1 |
classes11.dex | Builder —— 设置 talker/content/type/pipeline |
w11.p1 |
classes9.dex | PPC Pipeline 枚举:TEXT(d) / IMAGE(e) / VIDEO(f) / ... |
w11.r0 |
classes12.dex | NetSceneSendMsg —— 写 DB + 触发网络发送 |
w11.c1 |
classes5.dex | RevokeMsgManager —— 发送前的撤回状态检查 |
dk5.s5 |
classes9.dex | SendMsgMgr —— 高层 API(Ni(content,talker,isGroup,kl)) |
tg3.t1 |
classes.dex | 服务定位器入口 —— t1.a() 返回 dk5.s5 单例 |
com.tencent.mm.storage.f9 |
- | 消息存储对象(写入 message 表的实体) |
2.2 调用链(文本消息)
触发方(BroadcastReceiver in WeChat process)
│
├─ [方案 A1 - 直接 Factory]
│ w11.s1.a(talker) → 创建 w11.r1(l=p1.TEXT)
│ .e(content) → 设置文本内容(fields: d)
│ .b() → executeByPPC,无回调
│
├─ [方案 A2 - 高层 API]
│ (dk5.s5) tg3.t1.a()
│ .Ni(content, talker, false, null) → 1 对 1 文本
│ .Ni(content, talker, true, null) → 群聊文本
│
└─ 两种方案最终都走:
w11.r0(talker, content, type=1, ...)
├─ f9.setType(1) → message.type = 1(文本)
├─ x3.bj().N9(f9, true) → 写入 message 表(status=SENDING=1)
└─ r0.H() → r0.doScene()
├─ lVar.c = "/cgi-bin/micromsg-bin/newsendmsg"
├─ lVar.d = 522 (cmdId)
└─ dispatch() → MMTLS TCP 二进制 protobuf
2.3 w11.r1 Builder 字段映射
r1.a = "" // 未知(sender相关?)
r1.b = "" // talker(通过 g(talker) 设置)
r1.d = "" // content(通过 e(content) 设置)
r1.e = 1 // 消息类型(默认=1文本;通过 h(type) 设置)
r1.j = new h7() // 消息附加信息
r1.l = p1.TEXT // PPC Pipeline 类型(s1.a() 默认设为 TEXT)
2.4 doScene() CGI 参数(在 w11.r0 中)
lVar.c = "/cgi-bin/micromsg-bin/newsendmsg"; // CGI 路径
lVar.d = 522; // CmdId
lVar.e = 237; // SubCmdId
lVar.f = 1000000237; // FullCmdId
三、Route B 为何不可行
WeChat 手机端消息不走 HTTPS/OkHttp。发送路径:
- 持久 TCP 长连接 → MMTLS 加密(WeChat 自研,类似 TLS 但不是 TLS)→ 二进制 protobuf
- OkHttp 只处理微信里的 Finder(视频号)等 HTTP CGI 业务,不处理 IM 消息
- SSL Unpinning(Frida/LSPosed)只能解 TLS,对 MMTLS 无效
- 如要 hook 网络层,需要逆向
libtencent_mars.so(Mars 框架的 MMTLS native 实现)——极复杂,不在此路线
四、Route C 为何不可行
从 w11.r0 构造函数逆向结果:
- 构造时写入
message表(status = SENDING = 1) - 然后调用
doScene()发起 MMTLS 网络请求 - 成功回调后更新 status = SENT = 2
如果直接写 SQLite:
- 写 status=SENT(2):本地显示已发,但对方服务器从未收到,对方收不到
- 写 status=SENDING(1):消息卡在"发送中",WeChat 会尝试重发,但重发路径依赖正常 send 逻辑,不一定触发
- 结论:直写 DB 无法完成实际消息投递
五、Route A 实现方案
5.1 架构
在 LSPosed 模块中,于 handleLoadPackage 里注册一个 BroadcastReceiver(在 WeChat 进程内),
监听 com.hermes.wechat.SEND_MSG_INTERNAL。外部通过 adb shell am broadcast 触发。
// 在 WeChat 进程内注册 receiver
context.registerReceiver(new BroadcastReceiver() {
public void onReceive(Context ctx, Intent intent) {
String talker = intent.getStringExtra("to"); // wxid_xxx 或 xxx@chatroom
String content = intent.getStringExtra("text");
sendTextViaInternalApi(talker, content);
}
}, new IntentFilter("com.hermes.wechat.SEND_MSG_INTERNAL"),
Context.RECEIVER_NOT_EXPORTED);
5.2 发送逻辑(Xposed 内)
private static void sendTextViaInternalApi(String talker, String content) {
// 必须在主线程执行(WeChat 的 DB/网络操作绑定主线程)
new Handler(Looper.getMainLooper()).post(() -> {
try {
// 方案 A1(直接 Factory,更底层)
ClassLoader cl = /* lpparam.classLoader */;
Class<?> s1 = XposedHelpers.findClass("w11.s1", cl);
Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker);
XposedHelpers.callMethod(r1, "e", content); // set content
XposedHelpers.callMethod(r1, "b"); // execute
XposedBridge.log("[WeChatHook] Internal send queued: " + talker);
} catch (Throwable t) {
XposedBridge.log("[WeChatHook] Internal send failed: " + t);
}
});
}
5.3 Talker ID 格式
| 场景 | talker 格式 | 示例 |
|---|---|---|
| 1 对 1 | wxid_ 开头或 <phone>@stranger |
wxid_abc123 |
| 群聊 | <数字>@chatroom |
12345678901@chatroom |
从 EnMicroMsg.db 查询:
-- 查 1 对 1 的 talker
SELECT username FROM rcontact WHERE nickname = '目标昵称' LIMIT 1;
-- 查群 talker
SELECT roomname FROM chatroom WHERE roomdisplayname = '群名' LIMIT 1;
5.4 封号风险评估
- Route A 的发送路径与正常用户操作完全一致:经过同样的 DB 写入、同样的 CGI 请求、同样的 MMTLS 加密
- 风险主要来自行为模式(频率、时间、内容),而非技术路径
- 建议:单次发送、不高频、不夜间大量发送
六、AOT devirtualization 风险(来自平板登录 hook 经验)
WeChat 自身的虚方法(如 onCreate/initView)在 AOT 编译后绑死直接跳转,Xposed hook 注册成功但不触发。
Route A 不受此影响,因为:
w11.s1.a()是静态方法,无 vtable 查找w11.r1.e()/.b()是在 r1 新建对象上调用,引用明确,不经 vtable- 触发 Receiver 的
android.content.BroadcastReceiver.onReceive()是跨 APK 调用,框架层无法被 WeChat AOT 内联
即:Route A 的 hook 链路天然对 AOT devirtualization 免疫。
七、后续:图片发送
从 p1 枚举可知 pipeline 类型还有 IMAGE(e) / VIDEO(f) / CDN_IMAGE(g)。
图片发送需先上传图片到 WeChat CDN 获取 attachid,然后再构造 app 消息(type=3/43)。
已验证路径(来自 wechat-image-sender Hermes skill,见 CLAUDE.md):
am start -a android.intent.action.SEND -t image/png → WeChat UI 选择联系人 → 固定坐标点发送
Route A 图片发送尚未逆向,留待后续。