微信 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> |
| 语音(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 上传是最后一公里)
用法(已能发出图片消息+缩略图,全图上传待完成)
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 反编译)
真实发图 insert 栈:
b80.u.oj(o70.k) ← 插库+发送 (返 ArrayList)
← b80.s.invokeSuspend ← = MicroMsg.ImgUpload.SendImgPreFeatureService (只做 prebuild)
← 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)
nj(o70.h, int) 进度回调
关键数据结构(字段都是单字母,混淆)
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)
取 b80.u 插件 + r6 folder
→ 建 o70.k{c=talker, d=3, g=[o70.i(path, fileUri, 0)]}
→ 建 o70.h (同步字段: e=path, g=talker, k=fileUri, c=3, clientId)
→ Bi(h,"thumb.jpg") 否则 pj(h) + nj(h,10000) # prebuild
→ oj(k) # 插库+发送
真机: oj 返回 size=1, convertTo msgtype=3 触发, pt0.e0.h 触发, 缩略图渲染 ✅
但全图 CDN 上传转圈不完成 ❌
剩余:图片 CDN 上传最后一公里
b80.s只是 prebuild service(checkPreBuildImg),不含 oj;图片字节 CDN 上传是独立异步服务(log tagMicroMsg.ImgUpload)。我们同步 pj+oj 没把它拉起来 / 没跑完。- 试过 uri=null→fileUri、source 0→3,均未解决。imgId=0(真实是 MediaStore id)。
- 下一步:① hook
MicroMsg.ImgUpload上传 NetScene(像语音摸w21.g1那样)看真实发图怎么触发上传;② 或让流程跑在 worker 线程/协程(真实 prebuild 在 worker,我们是同步 main);③ 或 imgId 用真实 MediaStore id(把文件插 MediaStore 拿 content://+id)。
构建 / 部署
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 端到端脚本