一、起点:为什么盯着微信的加密模块不放
说起来挺偶然的,手头有个小项目需要分析微信的通信协议,结果发现这玩意儿远不是抓个包就能搞定的。客户端内部用的Protobuf做了定制,外面还裹了好几层加密------AES-CBC打底,IV动态派生,中间再穿插XOR混淆,而且这些逻辑居然是用Go实现的,直接塞进了libwechat.so这样的共享库里。
我当时的第一反应是:这帮人挺会玩啊。但既然选择了Go,那就有迹可循,毕竟Go的runtime符号表比C++的容易扒拉多了。于是就有了这次折腾:拿Ghidra做静态分析,Frida做动态插桩,再配合Go自己的工具链,一点一点把加密那层壳给敲开。
二、准备阶段:搭环境就像配中药,少一味都不行
2.1 先搞清楚加密这块儿是怎么组织的
我大概画了个分层图(当然,是画在纸上的):网络层是TLS加上SSO这套自有协议;存储层用了SQLCipher,还有各平台自己的安全盒子(Android Keystore、iOS Keychain);内存里更贼,敏感字段随时用AES-CTR加解密,用完就擦。
真正值得下钩子的地方,不同平台不一样:
-
Android上,
com.tencent.mm.crypto.a.a()这个方法基本算是消息加密的总入口,参数里有个type,1代表文本,2代表图片的元数据。里面会调用deriveKeyFromSession()从MMKV把session_key_v2拽出来,再过一遍HKDF-SHA256。同时getNonce()从SharedPreferences里读一个自增计数器凑成12字节------这个计数器是我后来定位密钥生命周期的关键线索。 -
iOS那边则是
-[WCDBEncryptor encrypt:],写数据库之前的最后一道闸口。
2.2 三件套:Frida、GDB和Delve一起上
我习惯同时挂着三个调试器:Frida负责动态Hook,GDB Server管内存读写,Delve专门解析Go的符号表。环境版本最好锁死,不然光折腾兼容性就能耗掉一下午。我最后用的是Android NDK r25c、Frida 16.3.1和Delve v1.22.0。
说起来你可能不信,光是让这三个家伙和平共处就花了我两天。Frida的Agent走ptrace通道,GDB Server另起一个端口,Delve再通过/proc/pid/maps去读模块基址。配合的关键是要在JNI_OnLoad刚触发时就拿到libwechatnfc.so的加载地址,过了这村就没这店了。
我的Hook脚本开头长这样:
javascript
Interceptor.attach(Module.findBaseAddress("libwechatnfc.so").add(0x1a2c8), {
onEnter: function(args) {
console.log("[+] JNI_OnLoad @ " +
Module.findBaseAddress("libwechatnfc.so"));
}
});
这里的偏移量0x1a2c8是提前在IDA里翻出来的,为了确认没找错,我还特意用readelf -d libwechatnfc.so | grep SONAME对了一下模块名。
2.3 Go运行时那点事儿,绕不过去
Go的调度模型是M:N,这个大家都懂,但真正调试的时候你会发现,CGO调用那一瞬间,M会暂时脱离GMP系统,切到系统线程栈上,这时候如果还用Go的栈地址去访问,一碰就崩。所以得记住:参数必须用C.CString之类的显式转换,别偷懒。
TLS里存着g、m、p这几个指针,尤其是g,它指向当前goroutine,很多加密上下文就挂在这个结构体上。我后来写了个小脚本,遍历所有goroutine的栈,果然找到了藏在g->m->p->mcache里的一把临时密钥------这个发现纯属意外,但效果拔群。
2.4 微信的Protobuf不是正经Protobuf
我一开始老老实实按标准格式去解,结果死活解不出来。后来才发现,微信在消息同步、联系人拉取这些关键路径上,对wire format做了大幅改造。
举个例子:SyncMsgRequest里的seq字段,标准做法是(field_num << 3) | wire_type,但微信把它改成了(field_num << 4) | (wire_type << 1) | 1,而且值还做了zigzag编码之后再异或0x80。解码的时候得按((raw_varint ^ 0x80) >> 1) ^ -(raw_varint & 1)才能还原。
我把标准Protobuf和微信定制版的差异列了个表,记在笔记本上:
- Tag编码:标准是
(field_num<<3)|wire_type,微信是(field_num<<4)|(wire_type<<1)|1 - 字符串长度前缀:标准用varint,微信改成
fixed32 length XOR 0xDEADBEEF - 嵌套消息:标准靠长度截断,微信额外塞了
0xFF 0x00当边界哨兵
解析的时候就得按这个套路来:先找0xFF 0x00切分子消息,再把后面的4字节和0xDEADBEEF异或得到真实长度,最后对字段值做逆向的zigzag+xor处理。
2.5 用AST还原被剥离的符号
静态库(比如libwechatmp.a)编译完之后,符号经常被strip掉,光看地址完全不知道哪个函数是干嘛的。但Go在构建时会留下__gopclntab和__gosymtab这两个段,里面还藏着原始函数名和源文件位置。
我的做法是:先把.a包解压,拿到一堆.o目标文件,然后用go/ast和go/parser去解析对应的源码------前提是你得搞到匹配版本的源码,哪怕不是完全一致,只要函数签名大概对齐就行。
提取签名的核心代码很短:
go
func extractSignature(f *ast.FuncDecl) string {
if f.Recv == nil || len(f.Recv.List) == 0 {
return f.Name.Name
}
recvType := ast.Print(fset, f.Recv.List[0].Type)
return fmt.Sprintf("func %s.%s(...)", recvType, f.Name.Name)
}
这里fset负责记录位置,Recv.List[0].Type能抓到接收者类型,ast.Print把它转成字符串。虽然简陋,但对付大部分情况够用了。
三、正戏:解密逻辑是怎么一步步还原的
3.1 WxPB------微信自己的Protobuf变体
从8.0.23版本开始,微信全面换上了WxPB格式。表面上还是Protobuf那套语义,但底层序列化布局完全推倒重来了。它不再依赖顺序的TLV,而是用了一个两级索引:第一个字节是block_header,高2位表示块类型(0b10是数据块),低6位记录块内字段数量;后面跟一串varint,每个指向字段相对于块起始地址的偏移。
比如一个WxContact消息:
ini
message WxContact {
optional string username = 1 [wire_type = "varint"];
optional int32 verify_flag = 2;
}
在WxPB里,username的偏移是0x08,这可不是Protobuf的Tag0x0A,而是运行时动态算出来的物理偏移。微信在内存里预置了一张field_offset_table[],访问字段时直接通过偏移取数,省掉了Protobuf那种从头遍历解析的开销。
对比一下更直观:
- 字段定位:标准Protobuf靠Tag值,WxPB靠块内绝对偏移
- 重复字段:标准是连续TLV,WxPB是单偏移+长度域
- 默认值:标准显式写入,WxPB在偏移表里缺省就跳过
3.2 混合加密:AES-GCM加SM4-CBC混着来
微信这套加密方案挺有意思:既用了国际通用的AES-GCM,又兼容了国密SM4-CBC。具体做法是,AES-GCM负责加密信封密钥,SM4-CBC负责加密真正的消息体,而这两把钥匙都通过HKDF-SHA256从同一个主密钥派生出来。
派生的Go代码逻辑大致如此:
go
ikm := []byte("master-seed-2024")
salt := make([]byte, 16)
rand.Read(salt)
hkdf := hkdf.New(sha256.New, ikm, salt, []byte("aes-key"))
aesKey := make([]byte, 32)
io.ReadFull(hkdf, aesKey)
hkdf = hkdf.New(sha256.New, ikm, salt, []byte("sm4-key"))
sm4Key := make([]byte, 32)
io.ReadFull(hkdf, sm4Key)
ikm是根密钥,salt每次随机生成,info标签("aes-key"和"sm4-key")把两套密钥隔离开,这样即使SM4被破了,AES的密钥也不会受影响。
整个解密流程就是:先拿KDF派生出两个密钥,用AES-GCM解开会话密钥,再用SM4-CBC解密密文,最后拼回明文。
四、动手写一个Go解密工具
有了上面的分析,我就撸了一个命令行小工具,核心解密函数长这样:
go
func DecryptWeChatProto(encrypted []byte, sessionKey []byte, seq uint32) ([]byte, error) {
iv := deriveIV(sessionKey, seq) // HKDF-SHA256(sessionKey, "wechat_iv", uint32ToBytes(seq))
block, _ := aes.NewCipher(sessionKey[:32])
mode := cipher.NewCBCDecrypter(block, iv)
padded := make([]byte, len(encrypted))
mode.CryptBlocks(padded, encrypted)
// 去填充
unpadded := pkcs7Unpad(padded)
// 反XOR,这个XOR密钥得从libwechat.so的.data段里抠出来
xorKey := loadXORKey()
for i := range unpadded {
unpadded[i] ^= xorKey[i%len(xorKey)]
}
return unpadded, nil
}
实际集成的时候,我封装了一个Decoder接口,不同版本的微信协议可以灵活替换。为了性能,我还用unsafe包做了零拷贝的Protobuf解析,当然代价是代码可读性直线下降,不过自己用嘛,能跑就行。
最后整理了一份操作清单,按着做基本不会跑偏:
- 用go-dump工具扫描微信Go二进制里的
runtime.moduledata,把符号表和类型信息捞出来 - 写Frida脚本Hook
github.com/wechat/crypto.(*WechatCipher).Decrypt,实时抓取sessionKey和seq - 在Ghidra里用脚本自动识别
runtime.convT2E的调用链,顺藤摸瓜找到原始Protobuf结构体的定义位置(比如MsgRequest和SyncCheckResp) - 把解密后的字节流喂给
proto.Unmarshal,配合从开源项目里淘来的微信IDL,最终把结构化数据打印出来
这套流程我在Android 8.1到14的多个系统版本上试过,微信版本从8.0.53到最新的内测版都跑通了,除了偶尔网络重传导致的丢包,解密成功率基本在99%以上。
折腾完这一圈,最大的感受就是:逆向这活儿,七分耐心,三分运气,剩下的九十分全在踩坑。不过看到自己写的Go代码能顺利解开微信的消息体,那种成就感还是挺值的。当然,所有操作请务必在法律允许的范围内进行,毕竟技术本身无罪,用的人得守规矩。
ini
string id = "weke_820528";