☰
微信协议逆向分析:从抓包到protobuf字段还原与mmtls解密
2026/10/8 2:24:06 网站建设 项目流程

简介:这份资料面向网络安全研究者、协议逆向爱好者与移动端安全工程师,聚焦网络协议分析、逆向工程方法以及微信协议研究,适合具备一定抓包与逆向基础、希望深入理解即时通讯协议实现细节的读者。包内以PDF学术论文与文档为主,包含法国学者Georges Bossert和Frédéric Guihéry在协议逆向领域的研究成果,以及香港中文大学关于微信协议分析的论文,另附相关配套资料,压缩包整体约3.11MB,便于集中查阅与对照研读。目前已有1387人学习下载,说明该方向具备一定关注度。读者可从中获取协议逆向的通用分析思路、微信通信协议的结构化拆解方法,以及学术层面对加密与消息交互流程的论证,为后续抓包实验、协议建模或安全评估提供理论支撑与参考路径,尤其适合需要从论文层面理解微信协议设计逻辑的研究者。

1. 抓包抓不到就上逆向:微信协议分析到底在解决什么问题

做移动端安全或者自动化测试的同行,大概率都遇到过这个场景:想抓微信某个小程序的接口,Charles 证书装了、SSL Pinning 也绕了,结果抓到的请求体是一坨二进制,字段名全是\x08\x01\x12这种鬼东西。这就是 protobuf 序列化之后的原始字节流,光靠抓包工具根本看不懂。网络协议分析逆向以及微信协议分析,本质上就是解决这个问题——从加密或序列化的字节流里,把协议结构、字段含义、加解密逻辑还原出来。

这件事的受众很明确:做安全审计的、做自动化测试的、做竞品分析的,以及搞 CTF 逆向入门想找真实靶场的。微信协议分析的价值在于,它是目前国内最复杂的民用通信协议之一,短连接、长连接、mmtls、protobuf 多层嵌套全占了,啃下来之后再看其他 App 的协议基本是降维打击。但要注意,本文讨论的是协议格式分析和逆向方法论,不涉及任何绕过安全机制进行非法获取数据的内容,所有操作都在授权测试环境下进行。

2. 从字节流到结构体:协议逆向的四层拆解模型

2.1 先搞清楚你面对的是哪一层协议

很多人一上来就抓包,抓完发现全是乱码就懵了。问题出在没分层。一个完整的微信通信链路,从下到上至少叠了四层:TCP/TLS 传输层、mmtls 安全层、protobuf 序列化层、业务逻辑层。你抓到的乱码可能只是最上面那层的表现形式,真正的结构藏在下面。

常见做法是先用 Wireshark 看 TCP 流,确认是不是标准 TLS。如果是标准 TLS,那说明你抓的是旧版或者非核心接口;如果 TCP 载荷开头是\x17\x03\x03那还是 TLS,但如果是一段看起来像随机数但长度有规律的数据,那大概率是 mmtls 或者自定义加密。这一步的判断决定了后面走哪条路。

我一般会先跑一个最小化的判定脚本,把 pcap 里的 TCP 载荷按流提取出来,看前 16 个字节的熵值。熵值接近 8 的基本可以确定是加密数据,熵值在 4 到 6 之间的可能是 protobuf 或者 JSON。这个判断花不了五分钟,但能省掉后面几个小时的瞎试。

import dpkt import math from collections import Counter def entropy(data): if not data: return 0 counter = Counter(data) length = len(data) return -sum((count/length) * math.log2(count/length) for count in counter.values()) def extract_tcp_payloads(pcap_path): """从 pcap 提取 TCP 载荷,按流分组""" flows = {} with open(pcap_path, 'rb') as f: pcap = dpkt.pcap.Reader(f) for ts, buf in pcap: try: eth = dpkt.ethernet.Ethernet(buf) ip = eth.data tcp = ip.data if len(tcp.data) > 0: # 用四元组做流标识 key = (ip.src, tcp.sport, ip.dst, tcp.dport) flows.setdefault(key, []).append(tcp.data) except Exception: continue return flows def analyze_flows(flows): """对每条流的前几个包做熵值分析""" for key, payloads in flows.items(): if len(payloads) < 3: continue first = payloads[0][:64] ent = entropy(first) print(f"Flow {key[0]}:{key[1]} -> {key[2]}:{key[3]}") print(f" Packets: {len(payloads)}, First payload entropy: {ent:.2f}") if ent > 7.5: print(" => 高度疑似加密数据") elif 4 < ent < 6.5: print(" => 可能是 protobuf 或结构化数据") else: print(" => 可能是明文或低熵数据") if __name__ == "__main__": flows = extract_tcp_payloads("capture.pcap") analyze_flows(flows)

这段代码的逻辑很直接:用 dpkt 解析 pcap,按四元组把 TCP 载荷分组,然后对每条流的第一个包的前 64 字节算香农熵。熵值超过 7.5 说明字节分布接近均匀随机,基本可以判定是加密的;4 到 6.5 之间说明有结构但又不是纯文本,protobuf 编码后的数据经常落在这个区间。参数上唯一需要调的是[:64]这个切片长度,如果协议头比较长可以改成 128,但一般 64 字节足够做判断了。

2.2 protobuf 逆向:没有 .proto 文件怎么还原字段

确认了是 protobuf 之后,下一步就是还原字段结构。protobuf 的 wire format 其实是有规律的:每个字段由 tag 和 value 组成,tag 的低 3 位是 wire type,高位是 field number。wire type 一共就几种:0 是 varint,1 是 64 位定长,2 是长度前缀,5 是 32 位定长。知道这个规律之后,即使没有 .proto 文件,也能把字段树扒出来。

我一般会写一个递归解析器,把二进制流按 wire format 拆成树形结构,然后人工看哪些字段像是长度、哪些像是类型标识。这个过程有点像考古,一层一层往下挖。

def parse_varint(data, pos): """解析 varint,返回 (值, 新位置)""" result = 0 shift = 0 while pos < len(data): byte = data[pos] result |= (byte & 0x7F) << shift pos += 1 if not (byte & 0x80): break shift += 7 return result, pos def parse_protobuf(data, depth=0, max_depth=6): """递归解析 protobuf 二进制流""" if depth > max_depth: return "..." fields = [] pos = 0 while pos < len(data): try: tag, pos = parse_varint(data, pos) except Exception: break field_num = tag >> 3 wire_type = tag & 0x07 if wire_type == 0: # varint value, pos = parse_varint(data, pos) fields.append((field_num, 'varint', value)) elif wire_type == 1: # 64-bit value = int.from_bytes(data[pos:pos+8], 'little') pos += 8 fields.append((field_num, '64bit', value)) elif wire_type == 2: # length-delimited length, pos = parse_varint(data, pos) raw = data[pos:pos+length] pos += length # 尝试递归解析,失败就当字符串 try: sub = parse_protobuf(raw, depth+1, max_depth) if sub: fields.append((field_num, 'message', sub)) else: raise ValueError except Exception: try: text = raw.decode('utf-8') if text.isprintable(): fields.append((field_num, 'string', text)) else: raise ValueError except Exception: fields.append((field_num, 'bytes', raw.hex()[:40])) elif wire_type == 5: # 32-bit value = int.from_bytes(data[pos:pos+4], 'little') pos += 4 fields.append((field_num, '32bit', value)) else: break return fields def pretty_print(fields, indent=0): """格式化输出解析结果""" for num, ftype, value in fields: prefix = " " * indent if ftype == 'message': print(f"{prefix}field {num} (message):") pretty_print(value, indent+1) else: print(f"{prefix}field {num} ({ftype}): {value}") # 使用示例 raw = bytes.fromhex("08011204616263641a020801") result = parse_protobuf(raw) pretty_print(result)

这个解析器的核心是parse_varint和递归的parse_protobuf。varint 是 protobuf 里最基础的编码方式,每个字节的低 7 位是数据,最高位表示是否还有后续字节。wire type 为 2 的时候是长度前缀,先读长度再读对应字节数,然后尝试递归解析——如果递归成功说明是嵌套 message,失败就尝试当 UTF-8 字符串,再失败就当原始字节输出 hex。

参数上max_depth控制递归深度,默认 6 层,微信的协议嵌套一般不超过 4 层,设 6 是留余量。实际用的时候你会发现有些字段解析出来是乱码,这很正常,因为 protobuf 不保证字段顺序,也不保证所有字段都是你期望的类型。这时候需要结合业务逻辑反推:比如某个字段的值总是 1 到 100 之间,那可能是消息类型或者状态码;某个字段长度固定 32 字节,那可能是 hash 或者 key。

2.3 用 frida 在运行时抓明文字段

静态解析二进制流有个致命问题:你看到的永远是加密后的结果。微信的核心业务字段在序列化之前是明文存在于内存里的,这时候就需要 frida 上场了。思路是 hook protobuf 的序列化函数,在数据被编码之前把参数 dump 出来。

常见做法是 hookcom.google.protobuf.MessageLite的toByteArray方法,或者更底层一点 hookCodedOutputStream的写入方法。但微信不一定用标准 protobuf 库,可能是自己实现的序列化逻辑,这时候就得靠字符串搜索和调用栈回溯来定位关键函数。

// frida hook protobuf 序列化入口 Java.perform(function() { var MessageLite = Java.use('com.google.protobuf.MessageLite'); var methods = MessageLite.class.getMethods(); methods.forEach(function(method) { var name = method.getName(); if (name.indexOf('toByteArray') >= 0 || name.indexOf('writeTo') >= 0) { try { MessageLite[name].overload.apply(MessageLite, method.getParameterTypes()).implementation = function() { var result = this[name].apply(this, arguments); console.log("[Proto] " + this.getClass().getName()); console.log("[Proto] Method: " + name); // 打印对象的所有字段 var fields = this.getClass().getDeclaredFields(); fields.forEach(function(f) { f.setAccessible(true); try { var val = f.get(this); if (val !== null) { console.log(" " + f.getName() + " = " + val); } } catch(e) {} }); return result; }; } catch(e) {} } }); });

这段 frida 脚本的逻辑是遍历MessageLite的所有方法,找到名字里带toByteArray或writeTo的,然后 hook 它们。在 hook 里先调用原方法拿到返回值,同时通过反射把当前对象的所有字段打印出来。这样你就能看到序列化之前的明文结构了。

参数说明:Java.perform是 frida 的标准入口,确保在 Java 虚拟机就绪后执行。overload.apply是为了处理重载方法,因为toByteArray可能有多个签名。反射部分用setAccessible(true)绕过访问控制,这在分析混淆过的代码时特别有用。

实际跑的时候你会发现输出量巨大,因为微信里 protobuf 对象创建非常频繁。建议加过滤条件,比如只打印类名里包含特定关键词的,或者只打印字段数量超过某个阈值的。我一般会先跑一遍看类名分布,找到目标类之后再精确 hook。

3. mmtls 与加密层:为什么你的抓包工具看不到明文

3.1 mmtls 和标准 TLS 的区别在哪

微信从某个版本开始用了自研的 mmtls 替代标准 TLS,这是很多人抓包翻车的根本原因。标准 TLS 的握手过程是 ClientHello、ServerHello、Certificate、KeyExchange 这一套,证书是 X.509 格式,Charles 和 Fiddler 就是靠替换证书来中间人解密的。但 mmtls 不走这套,它没有证书链,密钥交换用的是自定义的 ECDH 变体,所以你的抓包工具根本找不到证书可以替换。

判断是不是 mmtls 有个简单方法:看 TCP 连接建立后的第一个包。标准 TLS 的第一个字节是0x16(Handshake),后面跟版本号0x0301或0x0303。mmtls 的第一个字节通常是0x16但后面的版本号是自定义的,或者干脆是其他值。更准确的方法是看包长度和后续交互模式,mmtls 的握手包长度通常不是标准 TLS 的那些固定值。

我一般会先用 Wireshark 的tls.handshake.type过滤器看能不能识别出握手类型,如果识别不出来但 TCP 载荷又有规律,那基本就是自定义协议了。这时候需要把载荷 dump 出来,对照已知的 mmtls 结构做模式匹配。

3.2 定位密钥交换的关键函数

mmtls 的密钥交换逻辑在 native 层,通常是 C++ 实现的。用 frida 的Interceptor.attach可以 hook native 函数,但前提是你得知道函数地址。常见做法是先 hookSSL_CTX_new或者SSL_new这类 OpenSSL 的函数,看微信是不是基于 OpenSSL 改的。如果是,那密钥交换大概率还是走 OpenSSL 的 ECDH 流程,只是证书验证被替换了。

// hook OpenSSL 的密钥交换相关函数 var sslLib = Process.findModuleByName("libssl.so"); if (sslLib) { var exports = sslLib.enumerateExports(); exports.forEach(function(exp) { if (exp.name.indexOf("SSL_CTX_set_tmp_ecdh") >= 0 || exp.name.indexOf("SSL_CTX_set_ecdh_auto") >= 0 || exp.name.indexOf("SSL_get_shared_ciphers") >= 0) { console.log("Found: " + exp.name + " @ " + exp.address); Interceptor.attach(exp.address, { onEnter: function(args) { console.log("[ECDH] " + exp.name + " called"); // 打印参数 for (var i = 0; i < 4; i++) { console.log(" arg" + i + " = " + args[i]); } }, onLeave: function(retval) { console.log("[ECDH] returned: " + retval); } }); } }); }

这段脚本先找到libssl.so模块,然后枚举导出函数,筛选出和 ECDH 密钥交换相关的函数并 hook。onEnter里打印参数,onLeave里打印返回值。通过观察哪些函数被调用、参数是什么,可以推断出密钥交换的流程。

参数说明:Process.findModuleByName是 frida 查找已加载模块的标准方法,如果返回 null 说明模块还没加载,需要等或者主动 dlopen。enumerateExports列出所有导出符号,微信可能对符号做了裁剪,所以能找到的函数可能不完整。Interceptor.attach的args数组里存的是原始参数值,如果是指针需要进一步用Memory.readByteArray读取内容。

实际调试的时候,你会发现微信的 native 层符号被 strip 得很厉害,很多函数没有名字。这时候需要结合 IDA 做静态分析,找到关键函数的偏移,然后用 frida 的base.add(offset)来 hook。这个过程比较耗时,但一旦定位到密钥交换的入口,后面的解密就是顺水推舟了。

3.3 解密之后怎么验证协议结构

拿到解密后的明文只是第一步,接下来要验证你还原的 protobuf 结构对不对。验证方法很简单:把解析出来的字段值和你实际操作的业务对应起来。比如你发了一条文本消息,解密后的数据里应该有一个字段的值正好是消息内容,另一个字段的值是接收方 ID,还有一个字段是时间戳。

我一般会做一张对照表,左边是实际操作,右边是解析出来的字段变化。如果某个字段的值随着你的操作有规律地变化,那它的含义基本就确定了。这个方法虽然笨,但比瞎猜靠谱得多。

操作预期字段变化实际观察到的字段推断含义
发送文本"hello"内容字段变为"hello"field 3 值变为"hello"消息内容
切换接收人接收人字段变化field 5 值变化接收方 ID
等待 10 秒再发时间戳字段增大field 7 值增大时间戳
发送图片多出一个长度字段field 9 出现且值较大媒体数据

这张表是动态更新的,每验证一个字段就填一行。当大部分字段都能对应上之后,协议结构就算还原得差不多了。剩下的那些不变化的字段可能是版本号、设备标识之类的固定值,不影响核心功能。

4. 避坑指南:协议逆向中那些让你白干半天的坑

4.1 抓到的包全是心跳,没有业务数据

现象:抓包抓了半天,解出来全是固定长度的短包,内容几乎一样,没有任何业务字段。

原因:微信的长连接会定期发心跳包维持连接,业务数据走的是另一条连接或者另一个端口。很多人只抓了一个端口就以为抓全了。

解决:先看 TCP 连接的建立频率和端口分布。微信通常会同时维护多条连接,业务数据可能走 443 也可能走其他端口。用tcp.port过滤器把所有相关端口都抓上,然后按流分析,找到那个包长度变化大、交互频繁的流。

4.2 protobuf 解析出来字段全是乱码

现象:用解析器跑出来的字段值要么是乱码,要么是看起来像随机数的 hex。

原因:你解析的层级不对。微信的协议经常是外层一个 protobuf,里面嵌套一个加密的 bytes 字段,真正的业务数据在那个加密字段里面。你直接解析外层当然只能看到密文。

解决:先看哪个字段的 wire type 是 2 且长度比较大,那大概率是嵌套的加密数据。把这个字段单独拿出来,先解密再解析。解密逻辑需要结合前面 hook 到的密钥交换流程。

4.3 frida hook 不到目标函数

现象:脚本跑起来了,但目标函数一次都没被调用,或者直接报错说找不到类。

原因:微信有反调试和反 hook 机制,frida 的默认注入方式可能被检测到了。另外类名可能被混淆了,你找的com.google.protobuf.MessageLite可能被改成了a.b.c。

解决:先用Java.enumerateLoadedClasses把所有已加载的类列出来,搜索包含 protobuf 关键词的类名。如果 frida 被检测,可以试试用frida-gadget以库的形式注入,或者用 Xposed 替代。反调试这块是猫鼠游戏,没有一劳永逸的方案。

4.4 解密后的数据对不上业务

现象:解密成功了,protobuf 也解析出来了,但字段值和实际操作对不上,比如发的是"你好"解出来是"abc"。

原因:可能有多层加密,你只解了第一层。或者编码方式不是 UTF-8,微信在某些场景下会用 GBK 或者自定义编码。

解决:先确认解密后的数据是不是合法的 protobuf,用protoc --decode_raw跑一下看能不能解析。如果 protobuf 能解析但字符串是乱码,试试用 GBK 解码。如果还是不对,检查是不是还有一层异或或者 AES 没解。

4.5 分析到一半 App 闪退

现象:hook 脚本运行一段时间后微信直接崩溃,日志里看到SIGSEGV或者abort。

原因:hook 的函数被频繁调用,你的回调逻辑太重导致超时,或者修改了内存里的关键数据导致后续逻辑崩溃。

解决:在 hook 回调里尽量只做数据 dump,不要做复杂计算。如果数据量大,先写到文件后续再分析。另外注意不要修改原函数的参数和返回值,除非你确定修改是安全的。崩溃后先看 tombstone 日志,定位到具体是哪个地址出的问题。

5. 进阶技巧:用差分分析快速定位关键字段

前面讲的都是单次抓包的分析方法,效率其实不高。真正让我省时间的技巧是差分分析:对同一个操作做多次,每次只改变一个变量,然后对比解析结果,找出哪个字段跟着变了。

具体做法是写一个自动化脚本,控制微信做特定操作(比如发消息),每次操作之间只改一个参数(消息内容、接收人、消息类型),然后把每次的抓包结果解析出来做 diff。变化的字段就是和那个参数相关的字段。

import subprocess import json from collections import defaultdict def run_operation(params): """触发一次微信操作,返回抓包文件路径""" # 这里用 adb 模拟操作,实际可以用 uiautomator 或 appium cmd = ["adb", "shell", "am", "broadcast", "-a", "com.example.TRIGGER", "--es", "action", params["action"], "--es", "content", params["content"]] subprocess.run(cmd, capture_output=True) # 等待抓包完成 subprocess.run(["sleep", "3"]) return "capture_latest.pcap" def parse_and_extract(pcap_path): """解析 pcap 并提取所有 protobuf 字段""" # 复用前面的解析逻辑,返回 {field_path: value} 的字典 fields = {} # ... 解析逻辑省略,返回扁平化的字段字典 return fields def diff_analysis(base_params, variations): """差分分析:对比不同参数下的字段变化""" base_fields = parse_and_extract(run_operation(base_params)) results = defaultdict(list) for var in variations: params = {**base_params, **var} fields = parse_and_extract(run_operation(params)) for key in set(base_fields.keys()) | set(fields.keys()): base_val = base_fields.get(key) new_val = fields.get(key) if base_val != new_val: results[key].append({ "variation": var, "base": base_val, "new": new_val }) # 输出变化最频繁的字段 for key, changes in sorted(results.items(), key=lambda x: -len(x[1])): print(f"Field {key}: {len(changes)} changes") for c in changes[:3]: print(f" {c['variation']} -> {c['new']}") # 使用示例 base = {"action": "send_text", "content": "hello"} variations = [ {"content": "world"}, {"content": "hello2"}, {"action": "send_image"}, ] diff_analysis(base, variations)

这个脚本的核心思路是:固定其他参数,只改变一个变量,然后对比解析结果。run_operation负责触发操作并抓包,parse_and_extract把 pcap 解析成扁平的字段字典,diff_analysis做对比并输出变化最频繁的字段。

参数说明:variations列表里每个元素是一次变量改变,可以同时改多个参数也可以只改一个。run_operation里的等待时间sleep 3需要根据实际网络延迟调整,太短可能抓不到完整的包。输出部分按变化次数排序,变化最多的字段通常就是和变量最相关的字段。

这个方法的效率比人工看高得多。我一般会跑几十组变化,然后看哪些字段的变化模式符合预期。比如消息内容字段应该只在你改内容的时候变,接收人字段应该只在你改接收人的时候变。如果某个字段在所有变化里都变,那可能是时间戳或者序列号之类的。

差分分析还有个好处是可以发现隐藏的关联字段。比如你改消息内容的时候,某个长度字段也跟着变了,那说明这个字段是消息内容的长度。这种关联关系在单次分析里很难发现,但差分分析一目了然。

最后说一个我踩过的坑:差分分析的时候一定要控制好变量,每次只改一个。我一开始图省事,一次改了好几个参数,结果解析出来的字段变化根本对不上号,白白浪费了一下午。后来老老实实一次只改一个,虽然慢一点但结果清晰得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询