Android逆向视角下的Protobuf协议分析(中篇):解码与还原

admin 2026-04-16 04:36:37 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细讲解了Android逆向中Protobuf协议的解码与还原方法,包括使用protoc工具、Python库和Blackboxprotobuf进行裸解码、类型修正和.proto文件重构。关键发现包括WireType识别技巧和交互式类型推断策略,并提供了实战代码示例和篡改数据重放攻击的可行方案。 综合评分: 88 文章分类: 逆向分析,移动安全,漏洞分析,安全工具,WEB安全


cover_image

Android 逆向视角下的 Protobuf 协议分析(中篇):解码与还原

原创

泡泡以安 泡泡以安

泡泡以安

2026年4月2日 09:09 浙江

在小说阅读器读本章

去阅读

系列说明:本文是「Android 逆向视角下的 Protobuf 协议分析」系列的第二篇,聚焦解码与还原。上篇已介绍了 Wire Format 编码原理和流量识别方法,建议先阅读上篇再读本篇。

上篇:[基础理论篇] —— Protobuf 概念、Wire Format 编码原理、流量识别

下篇:[实战篇] —— Frida 动态 Hook 实战、常见对抗与绕过、完整工具速查表


目录

  • 四、如何解码 Protobuf 数据
  • 五、如何还原 .proto 定义

本篇背景回顾:上篇我们已经掌握了如何「认出」protobuf——无论是 APK 中的类名特征还是网络流量中的二进制特征。现在摆在我们面前的是一串十六进制字节,例如 08 96 01 12 07 74 65 73 74 69 6E 67。接下来要做的事情分两步:

  1. 解码:把二进制字节转换成人类可读的字段值(本篇第四章)
  2. 还原:从代码结构或数据特征,推断出完整的 .proto 定义,以便构造/篡改请求(本篇第五章)

四、如何解码 Protobuf 数据

拿到了疑似 protobuf 的二进制数据之后,下一步就是解码。根据手头信息的完整程度(有无 .proto 文件),解码方式从「裸解码」到「精确解码」有多个层次。

4.1 使用 protoc –decode_raw

最基础的裸解码方式,无需 .proto 文件,只需安装 protoc 即可:

# 从二进制文件直接解码
protoc --decode_raw < message.bin

# 从十六进制字符串解码 (先转二进制再解码)
echo"089601120774657374696e67"&nbsp;| xxd -r -p | protoc --decode_raw

# 输出结果:
# 1: 150
# 2: "testing"

# 从 Base64 编码数据解码
echo"CJYBEgd0ZXN0aW5n"&nbsp;| base64 -d | protoc --decode_raw

# 如果有 .proto 文件, 可以精确解码 (带字段名和类型)
protoc --decode=example.Example -I ./protos/ example.proto < message.bin
# 输出:
# id: 150
# name: "testing"

裸解码的局限性

  • 无法区分 int32 / sint32 / uint32——它们都是 Wire Type 0(Varint),裸解码只能显示原始整数值
  • 无法区分 string / bytes / embedded message——它们都是 Wire Type 2(Length-delimited),裸解码会尝试显示为字符串,如果不是合法 UTF-8 则显示为转义序列
  • 不知道字段名,只有 field number(如 1: 150 而不是 id: 150
  • 无法识别 repeated 字段——相同 field number 的多次出现会被分别显示
  • 无法识别 packed repeated 字段——packed 数据会被当作一整段 bytes 显示

实用技巧:当 protoc --decode_raw 输出中某个 Length-delimited 字段显示为乱码(如 2: "\001\002\003..."),通常说明它是一个嵌套 message 或 bytes 字段。你可以将该字段的原始数据单独提取出来,再次用 protoc --decode_raw 解码,如果成功则确认是嵌套 message。

4.2 使用 Python protobuf 库

当需要编程处理大量 protobuf 数据时,可以使用 Python 的 protobuf 库实现递归裸解码:

from&nbsp;google.protobuf.internal.decoder&nbsp;import&nbsp;_DecodeVarint
from&nbsp;google.protobuf.internal.wire_format&nbsp;import&nbsp;(
&nbsp; &nbsp; WIRETYPE_VARINT, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 0: 变长整数
&nbsp; &nbsp; WIRETYPE_FIXED64, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 1: 固定 8 字节
&nbsp; &nbsp; WIRETYPE_LENGTH_DELIMITED,&nbsp;# 2: 长度前缀
&nbsp; &nbsp; WIRETYPE_FIXED32, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 5: 固定 4 字节
)
import&nbsp;struct
from&nbsp;typing&nbsp;import&nbsp;Any

def&nbsp;decode_protobuf_raw(data: bytes, depth: int =&nbsp;0)&nbsp;-> list[dict[str, Any]]:
&nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; 递归解码 protobuf 二进制数据 (无需 .proto 文件)。

&nbsp; &nbsp; 对于每个字段, 返回其 field_number、wire_type 和原始值,
&nbsp; &nbsp; 同时尝试提供多种解读 (如 Varint 的 zigzag 解码、bytes 的 UTF-8 和嵌套解码)。
&nbsp; &nbsp; """
&nbsp; &nbsp; results: list[dict[str, Any]] = []
&nbsp; &nbsp; pos =&nbsp;0

&nbsp; &nbsp;&nbsp;while&nbsp;pos < len(data):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 解码 Tag (Varint 格式): 包含 field_number 和 wire_type
&nbsp; &nbsp; &nbsp; &nbsp; tag, new_pos = _DecodeVarint(data, pos)
&nbsp; &nbsp; &nbsp; &nbsp; field_number = tag >>&nbsp;3&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 高位: 字段编号
&nbsp; &nbsp; &nbsp; &nbsp; wire_type = tag &&nbsp;0x07&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 低 3 位: 线缆类型
&nbsp; &nbsp; &nbsp; &nbsp; pos = new_pos

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;wire_type == WIRETYPE_VARINT: &nbsp;# 0: 变长整数
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value, pos = _DecodeVarint(data, pos)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; results.append({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'field': field_number,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'wire_type':&nbsp;'varint',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'value': value,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 同时提供 ZigZag 解码值, 方便判断是否为 sint 类型
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'zigzag': (value >>&nbsp;1) ^ -(value &&nbsp;1),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# bool 解读
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'as_bool': bool(value)&nbsp;if&nbsp;value&nbsp;in&nbsp;(0,&nbsp;1)&nbsp;elseNone,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;elif&nbsp;wire_type == WIRETYPE_FIXED64: &nbsp;# 1: 固定 8 字节
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; raw = data[pos:pos +&nbsp;8]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value = struct.unpack('<q', raw)[0] &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 有符号 64 位整数
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; double_value = struct.unpack('<d', raw)[0] &nbsp; &nbsp;# 双精度浮点数
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pos +=&nbsp;8
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; results.append({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'field': field_number,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'wire_type':&nbsp;'fixed64',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'value': value,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'as_double': double_value,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;elif&nbsp;wire_type == WIRETYPE_LENGTH_DELIMITED: &nbsp;# 2: 长度前缀
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 先读取 Varint 格式的长度值
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; length, pos = _DecodeVarint(data, pos)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value = data[pos:pos + length]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pos += length

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; entry: dict[str, Any] = {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'field': field_number,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'wire_type':&nbsp;'length_delimited',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'length': length,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'raw': value.hex(),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 尝试解读为 UTF-8 字符串
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; decoded_str = value.decode('utf-8')
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 额外检查: 过滤掉含大量控制字符的 "伪字符串"
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;decoded_str.isprintable()&nbsp;or'\n'in&nbsp;decoded_str:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; entry['as_string'] = decoded_str
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;except&nbsp;UnicodeDecodeError:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;pass

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 尝试作为嵌套 message 递归解码
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; nested = decode_protobuf_raw(value, depth +&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;nested: &nbsp;# 解码成功且有内容
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; entry['as_message'] = nested
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;except&nbsp;Exception:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;pass

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; results.append(entry)

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;elif&nbsp;wire_type == WIRETYPE_FIXED32: &nbsp;# 5: 固定 4 字节
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; raw = data[pos:pos +&nbsp;4]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; value = struct.unpack('<i', raw)[0] &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 有符号 32 位整数
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; float_value = struct.unpack('<f', raw)[0] &nbsp; &nbsp;# 单精度浮点数
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pos +=&nbsp;4
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; results.append({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'field': field_number,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'wire_type':&nbsp;'fixed32',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'value': value,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'as_float': float_value,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 遇到未知 wire_type (3/4 为废弃的 Group, 6/7 未定义)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break

&nbsp; &nbsp;&nbsp;return&nbsp;results

# 使用示例
if&nbsp;__name__ ==&nbsp;'__main__':
&nbsp; &nbsp; data = bytes.fromhex("089601120774657374696e67")
&nbsp; &nbsp; result = decode_protobuf_raw(data)
&nbsp; &nbsp;&nbsp;for&nbsp;field&nbsp;in&nbsp;result:
&nbsp; &nbsp; &nbsp; &nbsp; print(field)
&nbsp; &nbsp;&nbsp;# {'field': 1, 'wire_type': 'varint', 'value': 150, 'zigzag': 75, 'as_bool': None}
&nbsp; &nbsp;&nbsp;# {'field': 2, 'wire_type': 'length_delimited', 'length': 7, 'raw': '74657374696e67',
&nbsp; &nbsp;&nbsp;# &nbsp;'as_string': 'testing', 'as_message': [...]}

4.3 使用 Blackboxprotobuf

blackboxprotobuf 是 NCC Group 开发的专为逆向设计的 Python 库,最大亮点是支持交互式类型修正——你可以在裸解码的基础上手动指定每个字段的真实类型,逐步逼近真实的 .proto 定义:

# 安装
pip install blackboxprotobuf
import&nbsp;blackboxprotobuf

# 第一步: 自动裸解码 (类型由库推断)
data = bytes.fromhex("089601120774657374696e67")
message, typedef = blackboxprotobuf.decode_message(data)

print(message)
# {'1': 150, '2': b'testing'}
# 注意: field 2 被推断为 bytes 类型, 显示为 b'testing'

print(typedef)
# {'1': {'type': 'int', 'name': ''}, '2': {'type': 'bytes', 'name': ''}}

# 第二步: 手动修正类型定义 (根据业务语义判断)
typedef['2']['type'] =&nbsp;'string'&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 修正为 string 类型
typedef['2']['name'] =&nbsp;'username'&nbsp; &nbsp; &nbsp;&nbsp;# 赋予有意义的字段名

# 第三步: 用修正后的类型定义重新解码
message, _ = blackboxprotobuf.decode_message(data, typedef)
print(message)
# {'1': 150, 'username': 'testing'}
# 现在 field 2 正确显示为字符串, 且有了字段名

# 第四步: 构造/篡改请求 (用修正后的 typedef 编码)
new_message = {'1':&nbsp;999,&nbsp;'username':&nbsp;'hacked'}
encoded = blackboxprotobuf.encode_message(new_message, typedef)
print(encoded.hex())
# 输出篡改后的 protobuf 二进制数据, 可直接用于重放攻击

进阶用法:blackboxprotobuf 支持将 typedef 保存为 JSON 文件,方便在多次分析间复用。对于同一个 API 的多次请求,可以先用第一次请求建立 typedef,后续请求直接套用:

import&nbsp;json
# 保存 typedef
with&nbsp;open('typedef.json',&nbsp;'w')&nbsp;as&nbsp;f:
&nbsp;json.dump(typedef, f)
# 加载复用
with&nbsp;open('typedef.json')&nbsp;as&nbsp;f:
&nbsp;saved_typedef = json.load(f)
msg, _ = blackboxprotobuf.decode_message(new_data, saved_typedef)

4.4 使用 protobuf-inspector

protobuf-inspector 提供彩色的、层级化的终端输出,特别适合快速浏览复杂的嵌套 protobuf 数据:

# 安装
pip install protobuf-inspector

# 使用: 从标准输入读取二进制数据
protobuf_inspector < message.bin

# 配合 xxd 从十六进制解码
echo&nbsp;"089601120774657374696e67"&nbsp;| xxd -r -p | protobuf_inspector

# 输出 (带颜色和缩进):
# root:
# &nbsp; 1 <varint> = 150
# &nbsp; 2 <chunk> = "testing"

与 protoc 的区别:protobuf-inspector 的输出更适合人类阅读(有颜色和清晰的类型标注),而 protoc --decode_raw 的输出更适合脚本处理(纯文本格式)。在快速分析阶段推荐用 protobuf-inspector,在自动化流水线中推荐用 protoc。

4.5 Burp Suite 插件

对于使用 Burp Suite 进行 Web/API 测试的安全研究人员,以下插件可以在抓包界面中直接解码和编辑 protobuf 数据:

| 插件 | 说明 | 特点 | | — | — | — | | Protobuf Decoder | 自动解码 protobuf 请求/响应 | 支持裸解码,无需 .proto | | PBTK (Protobuf Toolkit) | 综合工具包 | 支持从 APK 提取 .proto 后精确解码 | | protobuf-editor | 在 Burp 中编辑 protobuf | 支持修改字段值后重新编码 |

推荐工作流:先用 PBTK 从 APK 提取 .proto 定义,再将提取的 .proto 文件配置到 Burp 的 Protobuf Decoder 插件中,即可在抓包界面中看到带字段名的精确解码结果,大幅提升分析效率。

4.6 在线工具

无需安装任何软件,直接在浏览器中解码:

  • protobuf-decoder.netlify.app:在线粘贴 hex 或 base64 数据即可裸解码,支持嵌套 message 的递归展开
  • protogen.marcgravell.com:在线 .proto 编辑器,支持编码/解码测试,可以输入 .proto 定义后直接构造和解析数据

安全提醒:在线工具虽然方便,但你上传的数据可能被服务端记录。如果分析的是敏感应用的通信数据,强烈建议使用本地工具(protoc、blackboxprotobuf 等)进行离线解码。


五、如何还原 .proto 定义

裸解码只能看到 field number 和原始值,无法得知字段名和精确类型。要构造/篡改 protobuf 请求,必须还原出 .proto 定义。以下按可靠性从高到低介绍各种还原方法。

5.1 从 Java 生成类逆向还原

这是最可靠的方法。protobuf 编译器(protoc)生成的 Java 类有固定的代码模式——即使类名被混淆,代码结构也不会变。

5.1.1 识别 Message 类

// protobuf-java (完整版) 生成的类具有以下典型结构
publicfinalclass&nbsp;UserInfo&nbsp;extends
&nbsp; &nbsp;&nbsp;com.google.protobuf.GeneratedMessageV3&nbsp;{

&nbsp; &nbsp;&nbsp;// [特征 1] 字段编号常量: 直接暴露 field number, 命名格式固定
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;ID_FIELD_NUMBER =&nbsp;1;
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;NAME_FIELD_NUMBER =&nbsp;2;
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;EMAIL_FIELD_NUMBER =&nbsp;3;
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;AGE_FIELD_NUMBER =&nbsp;4;
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;ADDRESSES_FIELD_NUMBER =&nbsp;5;
&nbsp; &nbsp;&nbsp;publicstaticfinalint&nbsp;STATUS_FIELD_NUMBER =&nbsp;6;

&nbsp; &nbsp;&nbsp;// [特征 2] 字段声明: 类型直接对应 .proto 中的类型
&nbsp; &nbsp;&nbsp;privateint&nbsp;id_; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// int32
&nbsp; &nbsp;&nbsp;privatevolatile&nbsp;String name_; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// string (volatile 是 protobuf 生成代码的特征)
&nbsp; &nbsp;&nbsp;privatevolatile&nbsp;String email_; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// string
&nbsp; &nbsp;&nbsp;privateint&nbsp;age_; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// int32
&nbsp; &nbsp;&nbsp;private&nbsp;java.util.List<Address> addresses_; &nbsp;&nbsp;// repeated Address (嵌套 message)
&nbsp; &nbsp;&nbsp;privateint&nbsp;status_; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// enum (在 Java 中以 int 存储)
}

识别技巧:即使经过混淆,XXX_FIELD_NUMBER 常量的不会被混淆(因为混淆器不会修改常量值)。搜索所有值为小正整数(1~100)且类型为 static final int 的常量,筛选出同一个类中有多个这样常量的,就很可能是 protobuf 生成的 Message 类。

5.1.2 识别 writeTo 方法(最关键)

writeTo 方法是还原 .proto 的核心线索——它逐字段调用 CodedOutputStream 的类型化写入方法,每一行调用都精确对应一个 .proto 字段定义

// protobuf-lite 生成的 writeTo 方法
public&nbsp;void&nbsp;writeTo(CodedOutputStream output)&nbsp;throws&nbsp;IOException&nbsp;{
&nbsp; &nbsp;&nbsp;// field 1: int32 类型, 条件 "!= 0" 是 proto3 默认值优化 (零值不序列化)
&nbsp; &nbsp;&nbsp;if&nbsp;(id_ !=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeInt32(1, id_); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// → int32 id = 1;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// field 2: string 类型, 条件 "isEmpty()" 同理
&nbsp; &nbsp;&nbsp;if&nbsp;(!name_.isEmpty()) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeString(2, name_); &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// → string name = 2;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// field 3: string 类型
&nbsp; &nbsp;&nbsp;if&nbsp;(!email_.isEmpty()) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeString(3, email_); &nbsp; &nbsp; &nbsp; &nbsp;// → string email = 3;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// field 4: int32 类型
&nbsp; &nbsp;&nbsp;if&nbsp;(age_ !=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeInt32(4, age_); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// → int32 age = 4;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// field 5: repeated message 类型 (循环写入 = repeated)
&nbsp; &nbsp;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < addresses_.size(); i++) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeMessage(5, addresses_.get(i)); &nbsp;// → repeated Address addresses = 5;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// field 6: enum 类型
&nbsp; &nbsp;&nbsp;if&nbsp;(status_ !=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; output.writeEnum(6, status_); &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// → Status status = 6;
&nbsp; &nbsp; }
}

由此精确还原出 .proto 定义:

syntax =&nbsp;"proto3";

message&nbsp;UserInfo&nbsp;{
&nbsp;&nbsp;int32&nbsp;id =&nbsp;1;
&nbsp;&nbsp;string&nbsp;name =&nbsp;2;
&nbsp;&nbsp;string&nbsp;email =&nbsp;3;
&nbsp;&nbsp;int32&nbsp;age =&nbsp;4;
&nbsp;&nbsp;repeated&nbsp;Address addresses =&nbsp;5; &nbsp;// 嵌套 message, 需要进一步分析 Address 类
&nbsp; Status status =&nbsp;6; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// enum, 需要进一步分析 Status 定义
}

5.1.3 writeTo 中的类型映射表

以下是 CodedOutputStream 的写入方法与 .proto 类型的完整对照表——逆向分析时直接查表即可:

| CodedOutputStream 方法 | Proto 类型 | Wire Type | 说明 | | — | — | — | — | | writeInt32(n, v) | int32 | 0 (Varint) | 负数会占 10 字节 | | writeInt64(n, v) | int64 | 0 (Varint) | 负数会占 10 字节 | | writeUInt32(n, v) | uint32 | 0 (Varint) | 无符号,适合非负值 | | writeUInt64(n, v) | uint64 | 0 (Varint) | 无符号,适合非负值 | | writeSInt32(n, v) | sint32 | 0 (Varint) | ZigZag 编码,适合有符号值 | | writeSInt64(n, v) | sint64 | 0 (Varint) | ZigZag 编码,适合有符号值 | | writeFixed32(n, v) | fixed32 | 5 (32-bit) | 固定 4 字节,适合值通常较大 | | writeFixed64(n, v) | fixed64 | 1 (64-bit) | 固定 8 字节 | | writeSFixed32(n, v) | sfixed32 | 5 (32-bit) | 有符号固定 4 字节 | | writeSFixed64(n, v) | sfixed64 | 1 (64-bit) | 有符号固定 8 字节 | | writeFloat(n, v) | float | 5 (32-bit) | IEEE 754 单精度 | | writeDouble(n, v) | double | 1 (64-bit) | IEEE 754 双精度 | | writeBool(n, v) | bool | 0 (Varint) | 值只有 0 和 1 | | writeString(n, v) | string | 2 (LEN) | UTF-8 编码 | | writeBytes(n, v) | bytes | 2 (LEN) | 原始字节 | | writeMessage(n, v) | 嵌套 message | 2 (LEN) | 递归编码 | | writeEnum(n, v) | enum | 0 (Varint) | 值为枚举的整数值 | | writeGroup(n, v) | group | 3+4 | 已废弃,极少见 |

5.1.4 识别 Enum

protobuf 生成的 Enum 类继承特定接口,且包含 getNumber() 方法:

// protobuf 生成的 Enum 类结构
publicenum&nbsp;Status implements com.google.protobuf.ProtocolMessageEnum {
&nbsp; &nbsp; UNKNOWN(0), &nbsp; &nbsp; &nbsp;// protobuf enum 的第一个值必须是 0
&nbsp; &nbsp; ACTIVE(1),
&nbsp; &nbsp; INACTIVE(2),
&nbsp; &nbsp; BANNED(3);

&nbsp; &nbsp;&nbsp;privatefinalint&nbsp;value;

&nbsp; &nbsp;&nbsp;// protobuf 特征方法: 返回枚举的整数值
&nbsp; &nbsp;&nbsp;public&nbsp;final&nbsp;int&nbsp;getNumber()&nbsp;{&nbsp;return&nbsp;value; }

&nbsp; &nbsp;&nbsp;// protobuf 特征方法: 通过整数值反查枚举
&nbsp; &nbsp;&nbsp;public&nbsp;static&nbsp;Status&nbsp;forNumber(int&nbsp;value)&nbsp;{ ... }
}

还原为 .proto

enum&nbsp;Status&nbsp;{
&nbsp; UNKNOWN =&nbsp;0; &nbsp; &nbsp;// proto3 要求第一个值必须是 0
&nbsp; ACTIVE =&nbsp;1;
&nbsp; INACTIVE =&nbsp;2;
&nbsp; BANNED =&nbsp;3;
}

5.1.5 识别 OneOf

oneof 字段在 Java 中生成一个 case enum 和 union-like 的存储结构:

// oneof 生成的代码特征:
// 1. 一个 xxxCase_ int 字段, 记录当前活跃的是哪个 oneof 分支
privateint&nbsp;payloadCase_ =&nbsp;0;
// 2. 一个 Object 字段, 存储当前活跃分支的值
private&nbsp;Object payload_;

// 3. 一个 Case enum, 列出所有分支
publicenum&nbsp;PayloadCase {
&nbsp; &nbsp; TEXT(1), &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// field number = 1
&nbsp; &nbsp; IMAGE(2), &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// field number = 2
&nbsp; &nbsp; VIDEO(3), &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// field number = 3
&nbsp; &nbsp; PAYLOAD_NOT_SET(0); &nbsp;// 未设置
}

// 4. 每个分支有独立的 getter
public&nbsp;String&nbsp;getText()&nbsp;{ ... }
public&nbsp;ImageData&nbsp;getImage()&nbsp;{ ... }
public&nbsp;VideoData&nbsp;getVideo()&nbsp;{ ... }

还原为 .proto

message&nbsp;ChatMessage&nbsp;{
&nbsp;&nbsp;oneof&nbsp;payload {
&nbsp; &nbsp;&nbsp;string&nbsp;text =&nbsp;1;
&nbsp; &nbsp; ImageData image =&nbsp;2;
&nbsp; &nbsp; VideoData video =&nbsp;3;
&nbsp; }
}

识别关键:看到一个 int xxxCase_ 字段 + 一个 Object xxx_ 字段 + 一个 XxxCase enum,基本可以确认是 oneof 结构。

5.1.6 识别 Map

map 字段在 protobuf 生成的 Java 代码中使用专用的 MapField 类型:

// map 字段的 Java 代码特征
private&nbsp;MapField<String, Integer> tags_;

// 对应的 getter 返回 java.util.Map
public&nbsp;Map<String, Integer>&nbsp;getTagsMap()&nbsp;{ ... }
public&nbsp;int&nbsp;getTagsCount()&nbsp;{ ... }
public&nbsp;boolean&nbsp;containsTags(String key)&nbsp;{ ... }

还原为 .proto

message&nbsp;Foo&nbsp;{
&nbsp; map<string,&nbsp;int32> tags =&nbsp;7;
}

Wire Format 层面map<K, V> field = N 实际上等价于 repeated MapEntry field = N,其中 MapEntry 是一个隐含的嵌套 message { K key = 1; V value = 2; }。因此在裸解码时,map 字段会显示为 repeated 的嵌套 message,每个嵌套 message 包含 field 1(key)和 field 2(value)。

5.2 从混淆代码还原

当代码被 ProGuard / R8 混淆后,类名和方法名被替换为无意义的短名(如 abc),但代码结构模式不变

// 混淆后的 writeTo 方法 - 类名和字段名都被混淆了
public&nbsp;void&nbsp;a(CodedOutputStream var1)&nbsp;throws&nbsp;IOException&nbsp;{
&nbsp; &nbsp;&nbsp;// 但 writeXxx 方法名属于 protobuf 库, 不会被混淆!
&nbsp; &nbsp;&nbsp;if&nbsp;(this.a !=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; var1.writeInt32(1,&nbsp;this.a); &nbsp; &nbsp; &nbsp;// field 1: int32 (确定)
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(!this.b.isEmpty()) {
&nbsp; &nbsp; &nbsp; &nbsp; var1.writeString(2,&nbsp;this.b); &nbsp; &nbsp; &nbsp;// field 2: string (确定)
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;if&nbsp;(this.c !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; var1.writeMessage(3,&nbsp;this.c); &nbsp; &nbsp;&nbsp;// field 3: message (确定, 嵌套类型需追踪 this.c 的类型)
&nbsp; &nbsp; }
}

混淆代码的还原技巧

  1. 搜索 CodedOutputStream 的调用:即使应用代码被混淆,protobuf 库本身通常不被混淆(它是外部依赖),所以 CodedOutputStream 类名和 writeXxx 方法名都会保留
  2. 搜索 writeXxx 方法调用writeInt32writeString 等方法名是 protobuf 库的 API,不会被混淆。所有调用这些方法的代码都是 writeTo 的一部分
  3. 搜索 FIELD_NUMBER 常量:常量值(如 123)不会被混淆,即使常量名被改为 abc
  4. 搜索 parseFrom 方法:反序列化入口方法的签名特征不变——它接受 byte[] 或 CodedInputStream,返回 Message 对象
  5. **利用 getDescriptor()**:如果使用的是完整版 protobuf-java(而非 lite),descriptor 包含完整的 schema 信息,即使代码被混淆也能直接提取出原始的 .proto 定义

5.3 从 Descriptor 还原

如果 APK 使用 protobuf-java(完整版而非 lite),每个生成的 Java 文件中都包含一段序列化的 FileDescriptorProto——这是 .proto 文件的完整二进制表示,包括字段名、类型、注释等全部信息:

// 每个 protobuf 生成的 Java 外部类 (Outer Class) 包含 descriptor 初始化代码
static&nbsp;{
&nbsp; &nbsp;&nbsp;// descriptorData 是 FileDescriptorProto 的 protobuf 序列化字节 (被编码为 Java 字符串)
&nbsp; &nbsp; String[] descriptorData = {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"\n\016user_info.proto\022\007example\032\016address.proto\""&nbsp;+
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"\213\001\n\010UserInfo\022\n\n\002id\030\001 \001(\005\022\014"&nbsp;+ ...
&nbsp; &nbsp; };
&nbsp; &nbsp;&nbsp;// 这段数据就是 FileDescriptorProto 的序列化形式, 包含完整的 .proto 信息
}

提取并解码 descriptor,自动还原 .proto

from&nbsp;google.protobuf&nbsp;import&nbsp;descriptor_pb2

# 从 APK 中提取的 descriptor 二进制数据 (可能需要从 Java 字符串转换)
descriptor_data = open("descriptor.bin",&nbsp;"rb").read()

# 用 FileDescriptorProto 解析 descriptor
file_desc = descriptor_pb2.FileDescriptorProto()
file_desc.ParseFromString(descriptor_data)

# 打印还原的 .proto 定义
print(f'syntax = "proto{file_desc.syntax}";')
if&nbsp;file_desc.package:
&nbsp; &nbsp; print(f'package&nbsp;{file_desc.package};')

# 遍历所有 import 依赖
for&nbsp;dep&nbsp;in&nbsp;file_desc.dependency:
&nbsp; &nbsp; print(f'import "{dep}";')

# 遍历所有 message 定义
for&nbsp;msg&nbsp;in&nbsp;file_desc.message_type:
&nbsp; &nbsp; print(f'\nmessage&nbsp;{msg.name}&nbsp;{{')
&nbsp; &nbsp;&nbsp;for&nbsp;field&nbsp;in&nbsp;msg.field:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 获取类型名称 (去掉 TYPE_ 前缀并转小写)
&nbsp; &nbsp; &nbsp; &nbsp; type_name = descriptor_pb2.FieldDescriptorProto.Type.Name(field.type)
&nbsp; &nbsp; &nbsp; &nbsp; type_str = type_name.lower().replace("type_",&nbsp;"")

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 获取标签 (repeated / optional / required, 去掉 LABEL_ 前缀)
&nbsp; &nbsp; &nbsp; &nbsp; label = descriptor_pb2.FieldDescriptorProto.Label.Name(field.label)
&nbsp; &nbsp; &nbsp; &nbsp; label_str = label.lower().replace("label_",&nbsp;"")

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 如果是 message 或 enum 类型, 使用 type_name 字段 (包含引用的类型名)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;field.type&nbsp;in&nbsp;(11,&nbsp;14): &nbsp;# TYPE_MESSAGE=11, TYPE_ENUM=14
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type_str = field.type_name.lstrip('.')

&nbsp; &nbsp; &nbsp; &nbsp; print(f' &nbsp;{label_str}&nbsp;{type_str}&nbsp;{field.name}&nbsp;=&nbsp;{field.number};')
&nbsp; &nbsp; print('}')

# 遍历所有 enum 定义
for&nbsp;enum&nbsp;in&nbsp;file_desc.enum_type:
&nbsp; &nbsp; print(f'\nenum&nbsp;{enum.name}&nbsp;{{')
&nbsp; &nbsp;&nbsp;for&nbsp;val&nbsp;in&nbsp;enum.value:
&nbsp; &nbsp; &nbsp; &nbsp; print(f' &nbsp;{val.name}&nbsp;=&nbsp;{val.number};')
&nbsp; &nbsp; print('}')

重要提示:protobuf-lite 和 protobuf-nano 不包含 descriptor 信息(为了减小 APK 体积)。因此这种方法仅适用于使用 protobuf-java 完整版的应用。判断方法:在反编译代码中搜索 FileDescriptor 或 DescriptorProto,如果找到则说明是完整版。

5.4 使用 PBTK 自动化还原

PBTK (Protobuf Toolkit) 可以自动从 APK 中提取 .proto 文件定义,省去手动分析的繁琐过程:

# 克隆并安装
git&nbsp;clone&nbsp;https://github.com/nicehash/PBTK.git
cd&nbsp;PBTK && pip install -r requirements.txt

# 从 APK 提取 .proto 文件 (支持完整版和 lite 版)
python extractors/from_apk.py target.apk -o output_protos/

# 也支持从 .jar / .dex 文件中提取
python extractors/jar_extract.py classes.dex -o output_protos/

# 提取后检查输出目录
ls output_protos/
# user_info.proto &nbsp;address.proto &nbsp;common.proto &nbsp;...

注意:PBTK 的提取效果依赖于应用是否包含 descriptor 信息。对于 protobuf-lite 应用,PBTK 可能只能提取到部分信息或完全失败。此时需要回退到手动分析 writeTo 方法。

5.5 从网络数据盲猜还原

当没有源码、只有二进制网络数据时,可以通过多样本对比分析来推断字段语义:

import&nbsp;blackboxprotobuf

# 收集同一接口的多个请求/响应样本 (样本越多, 推断越准确)
samples = [
&nbsp; &nbsp; bytes.fromhex("0801120a4a6f686e20446f651803"),
&nbsp; &nbsp; bytes.fromhex("0802120b4a616e6520536d6974681804"),
&nbsp; &nbsp; bytes.fromhex("080312084a696d2042726f776e1802"),
]

# 解码所有样本
for&nbsp;i, sample&nbsp;in&nbsp;enumerate(samples):
&nbsp; &nbsp; msg, typedef = blackboxprotobuf.decode_message(sample)
&nbsp; &nbsp; print(f"Sample&nbsp;{i+1}:&nbsp;{msg}")
# Sample 1: {'1': 1, '2': b'John Doe', &nbsp; '3': 3}
# Sample 2: {'1': 2, '2': b'Jane Smith', '3': 4}
# Sample 3: {'1': 3, '2': b'Jim Brown', &nbsp;'3': 2}

# 根据多样本统计和业务逻辑推断字段语义:
# field 1: 值递增 (1, 2, 3) → 很可能是自增 ID
# field 2: 都是可读的人名字符串 → string 类型, 可能是 name/username
# field 3: 小整数且不递增 (3, 4, 2) → 可能是 enum (如用户级别) 或 age

# 建立 typedef
_, typedef = blackboxprotobuf.decode_message(samples[0])
typedef['1']['name'] =&nbsp;'user_id'
typedef['2']['type'] =&nbsp;'string'
typedef['2']['name'] =&nbsp;'name'
typedef['3']['name'] =&nbsp;'level'&nbsp; &nbsp; &nbsp; &nbsp;# 暂定为 level, 后续可根据更多数据修正

# 用修正后的 typedef 解码
for&nbsp;sample&nbsp;in&nbsp;samples:
&nbsp; &nbsp; msg, _ = blackboxprotobuf.decode_message(sample, typedef)
&nbsp; &nbsp; print(msg)
# {'user_id': 1, 'name': 'John Doe', &nbsp; 'level': 3}
# {'user_id': 2, 'name': 'Jane Smith', 'level': 4}
# {'user_id': 3, 'name': 'Jim Brown', &nbsp;'level': 2}

盲猜还原的经验法则

  • 值为 0/1 的 Varint → 大概率是 bool
  • 值递增的 Varint → 大概率是 ID(int32/int64
  • 值范围小且固定的 Varint → 大概率是 enum
  • 13 位数字的 Varint → 大概率是毫秒级时间戳(int64
  • 10 位数字的 Varint → 大概率是秒级时间戳(int32
  • Length-delimited 且内容可读 → string
  • Length-delimited 且内容能被 protoc --decode_raw 成功解码 → 嵌套 message
  • Length-delimited 且内容不可读也无法解码 → bytes(可能是加密数据、图片等)

5.6 protoc –descriptor_set_out 与反编译

如果你获取到了 .desc 描述符文件(也叫 FileDescriptorSet),可以直接反编译出 .proto 源文件:

# 使用 protoc 从描述符文件中解码特定消息 (需要知道完整的消息名)
protoc --descriptor_set_in=descriptors.desc --decode=package.MessageName

# 使用 protodec 工具将描述符文件反编译为 .proto 文件
# https://github.com/scylladb/protodec
protodec --input descriptors.desc --output ./protos/

# 也可以用 protoc 自己来反编译描述符 (利用 descriptor.proto 自描述)
protoc --decode=google.protobuf.FileDescriptorSet \
&nbsp; google/protobuf/descriptor.proto < descriptors.desc

获取 .desc 文件的途径

  1. APK assets 目录中可能直接包含 .desc 文件
  2. gRPC Server Reflection 服务——如果服务端开启了 reflection(开发/测试环境常见),可以用 grpcurl 直接获取所有 service 和 message 定义
  3. 某些应用在初始化时会从服务端下载 descriptor,可以通过抓包获取

小结与展望

本篇覆盖了 protobuf 逆向的「解码—还原」完整链路:

解码层(第四章):从最简单的 protoc --decode_raw 到 Python 递归裸解码、blackboxprotobuf 交互式类型修正、protobuf-inspector 可视化输出,以及 Burp 插件集成方案——根据场景选用合适工具。

还原层(第五章):按可靠性排序的六种方法:

  1. 分析 writeTo 方法(最精确,适用所有版本)
  2. 处理混淆代码(结构不变,库 API 名不被混淆)
  3. 提取 Descriptor(适用完整版 protobuf-java)
  4. PBTK 自动化(快速但有局限)
  5. 多样本对比盲猜(无源码时的最后手段)
  6. 反编译 .desc 文件

核心结论:无论哪种还原方式,最终目标都是输出一份可用的 .proto 定义文件,然后就可以用 blackboxprotobuf 或官方 protobuf 库在本地编解码、构造和篡改请求。还原精度越高,后续的构造和测试效率就越高。

下篇将进入最后一个阶段:我们已经知道数据格式,接下来的问题是——如何用 Frida 在运行时动态捕获、修改 protobuf 数据,以及当应用主动对抗分析时如何绕过? 敬请关注。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:泡泡以安 泡泡以安 泡泡以安《Android 逆向视角下的 Protobuf 协议分析(中篇):解码与还原》

评论:0   参与:  0