WECHAT_SEND_API.md 9.76 KB

微信 8.0.74 内部发送 API 逆向全景(文字 / 语音 / 图片)

通过 LSPosed 模块 wechathook 调用微信自己的内部发送 API,无 UI 模拟点击。 逆向自微信 8.0.74(versionCode 3120) on Pixel(Android 16 / A16)。 源码 wechathook_src/WeChatHook.java,构建 rebuild_wechathook.sh,发送 CLI wechat_send.sh。

TL;DR 能力矩阵

能力 状态 广播接口
文字(中英文) ✅ 完全可用,真机验证送达 SEND_MSG_INTERNAL --es to <wxid> --es text <文字> 或 --es text_b64 <base64>
语音(TTS→SILK) ✅ 半自动 hot-swap(真机验证投递) ARM_VOICE_SWAP --es src_path <SILK> --ei duration <ms> + 触发录音
图片 ✅ 完全可用,全图 CDN 上传打通,真机验证全分辨率送达 SEND_IMAGE_INTERNAL --es to <wxid> --es path <路径>

0. 通用基础设施

0.1 wxid 解析(nickname → username)

内部 API 认 wxid(username),不是昵称。从解密的 EnMicroMsg.db 查:

key = MD5("1234567890ABCDEF" + uin)[:7]     # imei 用微信默认值
本机: uin=215004919 → key=e03ce81
SQLCipher: cipher_compatibility=1, cipher_page_size=1024
uin 来源: /data/data/com.tencent.mm/shared_prefs/*.xml 里 last_login_uin
# Mac: brew install sqlcipher
sqlcipher EnMicroMsg.db
> PRAGMA key='e03ce81'; PRAGMA cipher_compatibility=1; PRAGMA cipher_page_size=1024;
> SELECT username,nickname FROM rcontact WHERE nickname LIKE '%火星人%';
# → yibooo | 火星人

解密逻辑完整实现见 lifelog-recorder/.../vision/WeChatDb.kt。

0.2 Tinker classloader 坑(关键)

微信真正跑的代码在 Tinker 补丁 里(/data/user/0/com.tencent.mm/tinker/patch-*/dex/tinker_classN.apk,~174MB), 混淆字段名与 base.apk 不同。反编译要拉 tinker dex:

adb shell su -c 'cp /data/user/0/com.tencent.mm/tinker/patch-*/dex/tinker_classN.apk /sdcard/tinkerN.apk'
adb pull /sdcard/tinkerN.apk
jadx --single-class <cls> --no-res -d out tinkerN.apk
# 定位含某字符串的类:dexdump -d classesN.dex | 解析 Class descriptor + const-string

hook 必须在 sTinkerCL 上做(真实发送跑在 tinker CL,base CL hook 不触发)。 App.onCreate 里对每个 hook 都要 hookXxx(sTinkerCL, procName+"/tinker")。

0.3 验证陷阱

  • am force-stop com.tencent.mm 是 SIGKILL,不 flush WCDB 加密 WAL;sqlcipher 读不了加密 WAL → force-stop 后查主库永远停在旧 checkpoint。验证送达靠实时 UI 截图 / 肉眼确认,别靠 force-stop 后查库。
  • LSPosed 日志文件会滚动(大 dump 触发),grep 前先 ls -t 找当前文件。

1. 文字发送 ✅

用法

# 英文/ASCII
adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text "hello"'
# 中文(base64 避免 shell 损坏 UTF-8)
B64=$(python3 -c "import base64;print(base64.b64encode('你好火星人'.encode()).decode())")
adb shell su -c "am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text_b64 '$B64'"

内部实现(sendViaHighLevel)

w11.r0(talker, content, type=1, flag=0, Object=null)   # Object-构造器插入 f9
  → com.tencent.mm.modelbase.z2.b(scene)               # 入 NetSceneQueue(真实网络派发)

关键点 / 踩过的坑:

  • 必须用 Object-构造器(第5参 Object);long-构造器和 builder 路径 log OK 但不入库。
  • dk5.s5.Ni 是发名片的(db.b = com.tencent.mm.ui.contact.db 联系人卡片生成器),不是发文字! 用它会得到 type=42 空名片失败消息。别用。
  • w11.s1.b(IMAGE/TEXT,...) 4参形式建的 r1 不完整,n1.a() NPE。别用于文字。
  • z2.b(m1) 是 IGetNetSceneQueue,内部 .d(scene) 入队,绕开硬编码混淆字段名。

2. 语音发送 ✅(半自动 hot-swap)

纯合成(newsendmsg/r0/z2.b)永远卡「发送中」——音频上传锁死在 jg0.x.w Kotlin 协程里。 绕过方案:让微信真录一段,在 CDN 上传步偷换 SILK 字节。

偷换点 = jg0.x.w(self, qi3.d0, w21.w0, int type=4, int durMs, w21.t silk, Continuation)
         arg5 w21.t 只有一个 byte[] 字段 = 明文 SILK 音频
广播 ARM_VOICE_SWAP 装弹 → input swipe 触发录音键长按 → 偷换在 hookVoiceProbe 的 w 分支

完整细节见 memory wechat-voice-send-internals.md 和 tts_voice_swap.sh。


3. 图片发送 ✅(全图 CDN 上传打通)

用法(真机验证:全分辨率图片送达 filehelper,可点开原图 + 保存)

adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to filehelper --es path /sdcard/Pictures/x.png'

完整发图链路(hook 真实发送三次抓取 + tinkerN.apk 反编译)

真实发图完整栈(tinkerN.apk 确认):
  SendImgProxyUI.onCreate → 后台 Runnable 组 o70.k
    → b80.s.invokeSuspend (= MicroMsg.ImgUpload.SendImgPreFeatureService, 只 prebuild 缩略图)
      → pe5.f.a(...)  ★orchestrator★
          ├ List<o70.l> res = b80.u.oj(o70.k)        插库(f9)+插ImgInfo(b0)+建描述符
          └ for each o70.l: v70.w.oj(r70.g, o70.l)   ★提交 CDN 上传(真正传字节)★
      ← SendImgProxyUI / gallery

图片插件 b80.u (implements o70.e, = i95.n0.c(o70.e.class)):
  pj(o70.h)          prebuild: 压缩 + 缩略图, stage 进 r6 文件夹
  Bi(o70.h, String)  文件存在检查 (返 boolean)
  oj(o70.k)          插库 + 建上传描述符 (返 ArrayList<o70.l>,不提交 CDN)
  nj(o70.h, int)     进度回调
CDN 服务 v70.w (implements xs.d1, = i95.n0.c(xs.d1.class)):
  oj(r70.g, o70.l)   提交单图 CDN 上传 → 协程 e80.c(★最后一公里★)

关键数据结构(字段都是单字母,混淆)

o70.k(r6 preBuildFolder)          // 批量发送对象
  a = r6 folder
  b = UUID clientId (auto)
  c = talker (toUserName)
  d = source (真实用 3)
  f = compressType
  g = List<o70.i>                 // 图片列表
  i = HashMap extras

o70.i(String imgPath, Uri, Long imgId)   // 单张图片
  a = 路径, b = Uri, c = imgId

o70.h(r6)                         // PreBuildParams (pj 的输入)
  e = imgPath, g = talker, k = Uri, b(方法) = clientId
  c = source, f = imgId, h = compressType

r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC")   // = g80.o.c7()

完整实现(sendImageViaAPI)——两步:oj 插库建描述符 + 逐个提交 CDN

关键发现:b80.u.oj(o70.k) 只做「插消息 f9 + 插 ImgInfo b0」并 返回 ArrayList<o70.l>(每图一个 CDN 上传描述符)——它自己不提交 CDN。 旧代码把返回的 list 丢掉 → 上传永不触发(缩略图渲染但全图转圈)。真实编排 pe5.f.a 才是循环这个 list、逐图提交到 CDN 服务的地方。补上这步即通。

第 1 步(插库+建描述符,与旧版相同):
  取 b80.u 插件(n0.c(o70.e.class))+ r6 folder(lp0.b.h0(...j()+"_MsgImgUploadFSC"))
  → 建 o70.k{c=talker, d=3, g=[o70.i(path, fileUri, 0)]} + o70.h(同步字段)
  → Bi/pj/nj prebuild → List<o70.l> res = oj(k)        # 插库,返回上传描述符

第 2 步(最后一公里 = 提交 CDN,新增):
  cdnSvc = n0.c(xs.d1.class)                            # → v70.w  (MsgImgFeatureService)
  self   = c01.z1.r()                                   # 自己 wxid("" 也接受)
  compress = o70.k.f                                    # 压缩类型
  for (o70.l l : res):
     i0 = new k70.i0(); i0.a = 3                        # crossParams,普通图只 source 必需
     g  = new r70.g(l.e /*已 prebuild 路径*/, compress, self, talker, i0)
     cdnSvc.oj(g, l)                                    # → 启动 CDN 上传协程 e80.c

真机验证(2026-07-07,微信 8.0.74):oj() DONE res=size=1 + CDN submit DONE items=1, 无异常;filehelper 收到消息,点开是全分辨率原图(有原图 + 保存按钮,不再转圈)。

关键 API(逆向自 tinkerN.apk)

用途 反射 token / 签名
图片插件(插库+建描述符) b80.u.oj(o70.k) : ArrayList<o70.l>(n0.c(o70.e.class))
CDN 提交(每图一次) v70.w.oj(r70.g, o70.l) : flow.j(n0.c(xs.d1.class),tag MicroMsg.ImgUpload.MsgImgFeatureService)
CDN 参数 r70.g(String imgPath, int compressType, String fromUser, String toUser, k70.i0 crossParams)
crossParams k70.i0() 无参构造,字段 a=source(int,普通图=3)
自己 wxid c01.z1.r() : String(静态)

坑:v70.w.oj 内部走 ViewModelProvider.get(e80.f.class),必须主线程调(广播 receiver 本来就在主线程,OK);用的是服务自己的 ViewModelStore,不需要活的 Activity。真实 CDN 上传在服务的协程 scope 异步跑,oj 立刻返回一个 flow,不用 collect 也会传完。orchestrator 是 pe5.f.a(...)(b80.s 只是 prebuild service checkPreBuildImg,不含 oj)。


构建 / 部署

export ADB=/opt/homebrew/share/android-commandlinetools/platform-tools/adb
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
bash rebuild_wechathook.sh     # 编译 + d8 + 签名 + pm install -r + 重启微信(无需 reboot)

⚠️ 反复 pm install -r 模块可能搞断 LSPosed 注入 → 重启设备 + pm install-existing com.tencent.mm 修复(非破坏性,不掉登录)。

相关文件

  • wechathook_src/WeChatHook.java — hook 源码(2981 行,文字/语音/图片全在里面)
  • backups/WeChatHook_*_text+voice+image.java — 本文档对应的代码快照
  • memory wechat-voice-send-internals.md — 更细的逆向记录 + 踩坑史
  • tts_voice_swap.sh — 语音 hot-swap 端到端脚本