微信 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,发送 CLIwechat_send.sh。
TL;DR 能力矩阵
| 能力 | 状态 | 广播接口 |
|---|---|---|
| 文字(中英文) | ✅ 完全可用,真机验证送达 | SEND_MSG_INTERNAL --es to <wxid> --es text <文字> 或 --es text_b64 <base64> |
| 语音(全合成) | ✅ 纯 API 合成,真机验证渲染成语音条(无录音/无 hot-swap) | SEND_VOICE_CGI --es to <wxid> --es src_path <SILK> --ei duration <ms> |
| 语音(hot-swap,旧) | ✅ 半自动 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. 语音发送 ✅(全合成,纯 API)
全合成打通(2026-07-07 真机验证渲染成语音条)。之前以为纯合成必卡「发送中」是因为找错了分支:
本机语音走的是 jg0.x.w 的内联 cgi 分支(/cgi-bin/micromsg-bin/uploadvoice,SILK 内联在请求
字段 7),不是 CDN 引擎。这条 cgi 一步同时上传音频+投递消息,且是标准 NetScene,可自己构建+派发。
# SILK 须 WeChat 进程可读(用其自身 voice2 文件,或 chown 到 WeChat uid;/sdcard 会 EACCES)
adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_CGI \
--es to filehelper --es src_path <设备上SILK路径> --ei duration 2000'
内部实现(sendVoiceViaCgi)
读 SILK → 按 6000 字节分块 → 每块:
cv6 请求 pb: set(0,self) set(1,talker) set(2,offset) set(3,len) set(4,"newSendVoice_"+voiceId)
set(6,dur) set(7,cu5(SILK切片)) set(8,末块?1:0) set(11,msgsource) set(12,4) set(15,createId)
→ modelbase.o 13参构造器(reqPb,respPb,url,cmdid=127,0,0,true,0,0,false,0,false,null) # 绕 modelbase.l 字段名不匹配
→ modelbase.i.p(o) → scene = i 的 m1 字段(modelbase.g)
→ 派发: i.l()(注册响应消费者+返 pq5.g deferred) + pq5.g.l(30000L)(终端订阅触发 cgi)
真机: VOICECGI-RESULT errType=0 errCode=0 svrMsgId(11)>0 = 服务器接受+分配msgId;filehelper 显示绿色语音条✅
关键坑:
- 派发必须
i.l()+订阅,裸z2.b(scene)不 wire 回调 → 场景永不完成、dv6 空、svrMsgId=0。 - 防 GC:deferred 弱持有 i(CgiLifecycleObserver WeakReference),须静态强引用 i+deferred。
- 字段名 base≠tinker:
modelbase.l直接反射NoSuchFieldError;用modelbase.o公开构造器 + 按签名找方法。 - SILK 权限:hook 在 WeChat 进程读文件,
/sdcardEACCES,root cp 进 app 沙盒被 SELinux 挡;用 voice2 自身文件。
旧路:半自动 hot-swap(全合成打通后可弃用)
让微信真录一段,在 jg0.x.w(arg5 w21.t 的 byte[] = 明文 SILK)偷换字节。广播 ARM_VOICE_SWAP 装弹
→ input swipe 触发录音键长按 → 偷换在 hookVoiceProbe 的 w 分支。见 tts_voice_swap.sh。
完整逆向记录见 memory wechat-voice-send-internals.md。
3. 图片发送 ✅(全图 CDN 上传打通)
用法(真机验证:全分辨率图片送达 filehelper,可点开原图 + 保存)
# path 必须是 MediaStore 索引的媒体目录里的图(/sdcard/Pictures/...),否则红叹号(见下 ⚠️)
adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to filehelper --es path /sdcard/Pictures/x.png'
⚠️ 红叹号 bug(2026-07-07):发图前 stage 的文件必须在 MediaStore 索引的媒体目录 (
/sdcard/Pictures/)并触发 media scan,否则微信图片 CDN 上传解析不到文件 → 上传成破损图 (红叹号,接收方收不到),而oj() DONE/CDN submit DONE日志照样打(这俩日志只代表"提交" 不代表"上传成功",不能当送达判据)。封装脚本wechat_send.sh image/ MCPsend_wechat_image已修:stage 到/sdcard/Pictures/wx_send_<pid>.<ext>+ 广播MEDIA_SCANNER_SCAN_FILE+ sleep 1 再发。 Hermes CLI 端到端验证:读群 DB→摘要→PIL 生图(Termux 私有目录)→send_wechat_image→自动 stage→全图送达火星人 ✅
完整发图链路(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 端到端脚本