摸着石头过河:我用Go把微信加密模块给逆向了一回

一、起点:为什么盯着微信的加密模块不放

说起来挺偶然的,手头有个小项目需要分析微信的通信协议,结果发现这玩意儿远不是抓个包就能搞定的。客户端内部用的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里存着gmp这几个指针,尤其是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/astgo/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解析,当然代价是代码可读性直线下降,不过自己用嘛,能跑就行。

最后整理了一份操作清单,按着做基本不会跑偏:

  1. 用go-dump工具扫描微信Go二进制里的runtime.moduledata,把符号表和类型信息捞出来
  2. 写Frida脚本Hook github.com/wechat/crypto.(*WechatCipher).Decrypt,实时抓取sessionKeyseq
  3. 在Ghidra里用脚本自动识别runtime.convT2E的调用链,顺藤摸瓜找到原始Protobuf结构体的定义位置(比如MsgRequestSyncCheckResp
  4. 把解密后的字节流喂给proto.Unmarshal,配合从开源项目里淘来的微信IDL,最终把结构化数据打印出来

这套流程我在Android 8.1到14的多个系统版本上试过,微信版本从8.0.53到最新的内测版都跑通了,除了偶尔网络重传导致的丢包,解密成功率基本在99%以上。


折腾完这一圈,最大的感受就是:逆向这活儿,七分耐心,三分运气,剩下的九十分全在踩坑。不过看到自己写的Go代码能顺利解开微信的消息体,那种成就感还是挺值的。当然,所有操作请务必在法律允许的范围内进行,毕竟技术本身无罪,用的人得守规矩。

ini 复制代码
string id = "weke_820528";
相关推荐
idcu5 小时前
CodeSchema 开源首发:一个给 AI 编码助手「喂」精准代码上下文的索引服务
开源·go·ai编程
私域管理小助手7 小时前
微信定时发圈,内容提前排好,到点自动发
微信
newerp1 天前
Golang 切片扩容策略
后端·程序员·go
我的div丢了肿么办1 天前
go中make声明切片, 修改切片,append给切片扩容,合并切片,复制切片
后端·go
纪卓志George1 天前
保守十四年:Go 的泛型方法与丢失的定位
go
运维开发笔记1 天前
8.3 Go Struct 嵌入学习笔记
go
xiangjiaodalishi1 天前
2026年GEO生成式引擎优化服务商观察:企业如何挑选适配自身的AI营销协作伙伴
go
蛋先生DX1 天前
明明都是源码到CPU,各语言中间原理咋不是一个套路?
java·javascript·go
万邦科技Lafite1 天前
1688一键创建订单付款API操作指南讲解
人工智能·微信·api·电商开放平台·淘宝开放平台·api开放接口