文章总结: 本文详细讲解了Android逆向中Protobuf协议的解码与还原方法,包括使用protoc工具、Python库和Blackboxprotobuf进行裸解码、类型修正和.proto文件重构。关键发现包括WireType识别技巧和交互式类型推断策略,并提供了实战代码示例和篡改数据重放攻击的可行方案。 综合评分: 88 文章分类: 逆向分析,移动安全,漏洞分析,安全工具,WEB安全
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。接下来要做的事情分两步:
- 解码:把二进制字节转换成人类可读的字段值(本篇第四章)
- 还原:从代码结构或数据特征,推断出完整的
.proto定义,以便构造/篡改请求(本篇第五章)
四、如何解码 Protobuf 数据
拿到了疑似 protobuf 的二进制数据之后,下一步就是解码。根据手头信息的完整程度(有无 .proto 文件),解码方式从「裸解码」到「精确解码」有多个层次。
4.1 使用 protoc –decode_raw
最基础的裸解码方式,无需 .proto 文件,只需安装 protoc 即可:
# 从二进制文件直接解码
protoc --decode_raw < message.bin
# 从十六进制字符串解码 (先转二进制再解码)
echo"089601120774657374696e67" | xxd -r -p | protoc --decode_raw
# 输出结果:
# 1: 150
# 2: "testing"
# 从 Base64 编码数据解码
echo"CJYBEgd0ZXN0aW5n" | 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 的多次出现会被分别显示 - 无法识别
packedrepeated 字段——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 google.protobuf.internal.decoder import _DecodeVarint
from google.protobuf.internal.wire_format import (
WIRETYPE_VARINT, # 0: 变长整数
WIRETYPE_FIXED64, # 1: 固定 8 字节
WIRETYPE_LENGTH_DELIMITED, # 2: 长度前缀
WIRETYPE_FIXED32, # 5: 固定 4 字节
)
import struct
from typing import Any
def decode_protobuf_raw(data: bytes, depth: int = 0) -> list[dict[str, Any]]:
"""
递归解码 protobuf 二进制数据 (无需 .proto 文件)。
对于每个字段, 返回其 field_number、wire_type 和原始值,
同时尝试提供多种解读 (如 Varint 的 zigzag 解码、bytes 的 UTF-8 和嵌套解码)。
"""
results: list[dict[str, Any]] = []
pos = 0
while pos < len(data):
# 解码 Tag (Varint 格式): 包含 field_number 和 wire_type
tag, new_pos = _DecodeVarint(data, pos)
field_number = tag >> 3 # 高位: 字段编号
wire_type = tag & 0x07 # 低 3 位: 线缆类型
pos = new_pos
if wire_type == WIRETYPE_VARINT: # 0: 变长整数
value, pos = _DecodeVarint(data, pos)
results.append({
'field': field_number,
'wire_type': 'varint',
'value': value,
# 同时提供 ZigZag 解码值, 方便判断是否为 sint 类型
'zigzag': (value >> 1) ^ -(value & 1),
# bool 解读
'as_bool': bool(value) if value in (0, 1) elseNone,
})
elif wire_type == WIRETYPE_FIXED64: # 1: 固定 8 字节
raw = data[pos:pos + 8]
value = struct.unpack('<q', raw)[0] # 有符号 64 位整数
double_value = struct.unpack('<d', raw)[0] # 双精度浮点数
pos += 8
results.append({
'field': field_number,
'wire_type': 'fixed64',
'value': value,
'as_double': double_value,
})
elif wire_type == WIRETYPE_LENGTH_DELIMITED: # 2: 长度前缀
# 先读取 Varint 格式的长度值
length, pos = _DecodeVarint(data, pos)
value = data[pos:pos + length]
pos += length
entry: dict[str, Any] = {
'field': field_number,
'wire_type': 'length_delimited',
'length': length,
'raw': value.hex(),
}
# 尝试解读为 UTF-8 字符串
try:
decoded_str = value.decode('utf-8')
# 额外检查: 过滤掉含大量控制字符的 "伪字符串"
if decoded_str.isprintable() or'\n'in decoded_str:
entry['as_string'] = decoded_str
except UnicodeDecodeError:
pass
# 尝试作为嵌套 message 递归解码
try:
nested = decode_protobuf_raw(value, depth + 1)
if nested: # 解码成功且有内容
entry['as_message'] = nested
except Exception:
pass
results.append(entry)
elif wire_type == WIRETYPE_FIXED32: # 5: 固定 4 字节
raw = data[pos:pos + 4]
value = struct.unpack('<i', raw)[0] # 有符号 32 位整数
float_value = struct.unpack('<f', raw)[0] # 单精度浮点数
pos += 4
results.append({
'field': field_number,
'wire_type': 'fixed32',
'value': value,
'as_float': float_value,
})
else:
# 遇到未知 wire_type (3/4 为废弃的 Group, 6/7 未定义)
break
return results
# 使用示例
if __name__ == '__main__':
data = bytes.fromhex("089601120774657374696e67")
result = decode_protobuf_raw(data)
for field in result:
print(field)
# {'field': 1, 'wire_type': 'varint', 'value': 150, 'zigzag': 75, 'as_bool': None}
# {'field': 2, 'wire_type': 'length_delimited', 'length': 7, 'raw': '74657374696e67',
# 'as_string': 'testing', 'as_message': [...]}
4.3 使用 Blackboxprotobuf
blackboxprotobuf 是 NCC Group 开发的专为逆向设计的 Python 库,最大亮点是支持交互式类型修正——你可以在裸解码的基础上手动指定每个字段的真实类型,逐步逼近真实的 .proto 定义:
# 安装
pip install blackboxprotobuf
import 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'] = 'string' # 修正为 string 类型
typedef['2']['name'] = 'username' # 赋予有意义的字段名
# 第三步: 用修正后的类型定义重新解码
message, _ = blackboxprotobuf.decode_message(data, typedef)
print(message)
# {'1': 150, 'username': 'testing'}
# 现在 field 2 正确显示为字符串, 且有了字段名
# 第四步: 构造/篡改请求 (用修正后的 typedef 编码)
new_message = {'1': 999, 'username': 'hacked'}
encoded = blackboxprotobuf.encode_message(new_message, typedef)
print(encoded.hex())
# 输出篡改后的 protobuf 二进制数据, 可直接用于重放攻击
进阶用法:blackboxprotobuf 支持将 typedef 保存为 JSON 文件,方便在多次分析间复用。对于同一个 API 的多次请求,可以先用第一次请求建立 typedef,后续请求直接套用:
import json # 保存 typedef with open('typedef.json', 'w') as f: json.dump(typedef, f) # 加载复用 with open('typedef.json') as f: 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 "089601120774657374696e67" | xxd -r -p | protobuf_inspector
# 输出 (带颜色和缩进):
# root:
# 1 <varint> = 150
# 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 UserInfo extends
com.google.protobuf.GeneratedMessageV3 {
// [特征 1] 字段编号常量: 直接暴露 field number, 命名格式固定
publicstaticfinalint ID_FIELD_NUMBER = 1;
publicstaticfinalint NAME_FIELD_NUMBER = 2;
publicstaticfinalint EMAIL_FIELD_NUMBER = 3;
publicstaticfinalint AGE_FIELD_NUMBER = 4;
publicstaticfinalint ADDRESSES_FIELD_NUMBER = 5;
publicstaticfinalint STATUS_FIELD_NUMBER = 6;
// [特征 2] 字段声明: 类型直接对应 .proto 中的类型
privateint id_; // int32
privatevolatile String name_; // string (volatile 是 protobuf 生成代码的特征)
privatevolatile String email_; // string
privateint age_; // int32
private java.util.List<Address> addresses_; // repeated Address (嵌套 message)
privateint status_; // 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 void writeTo(CodedOutputStream output) throws IOException {
// field 1: int32 类型, 条件 "!= 0" 是 proto3 默认值优化 (零值不序列化)
if (id_ != 0) {
output.writeInt32(1, id_); // → int32 id = 1;
}
// field 2: string 类型, 条件 "isEmpty()" 同理
if (!name_.isEmpty()) {
output.writeString(2, name_); // → string name = 2;
}
// field 3: string 类型
if (!email_.isEmpty()) {
output.writeString(3, email_); // → string email = 3;
}
// field 4: int32 类型
if (age_ != 0) {
output.writeInt32(4, age_); // → int32 age = 4;
}
// field 5: repeated message 类型 (循环写入 = repeated)
for (int i = 0; i < addresses_.size(); i++) {
output.writeMessage(5, addresses_.get(i)); // → repeated Address addresses = 5;
}
// field 6: enum 类型
if (status_ != 0) {
output.writeEnum(6, status_); // → Status status = 6;
}
}
由此精确还原出 .proto 定义:
syntax = "proto3";
message UserInfo {
int32 id = 1;
string name = 2;
string email = 3;
int32 age = 4;
repeated Address addresses = 5; // 嵌套 message, 需要进一步分析 Address 类
Status status = 6; // 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 Status implements com.google.protobuf.ProtocolMessageEnum {
UNKNOWN(0), // protobuf enum 的第一个值必须是 0
ACTIVE(1),
INACTIVE(2),
BANNED(3);
privatefinalint value;
// protobuf 特征方法: 返回枚举的整数值
public final int getNumber() { return value; }
// protobuf 特征方法: 通过整数值反查枚举
public static Status forNumber(int value) { ... }
}
还原为 .proto:
enum Status {
UNKNOWN = 0; // proto3 要求第一个值必须是 0
ACTIVE = 1;
INACTIVE = 2;
BANNED = 3;
}
5.1.5 识别 OneOf
oneof 字段在 Java 中生成一个 case enum 和 union-like 的存储结构:
// oneof 生成的代码特征:
// 1. 一个 xxxCase_ int 字段, 记录当前活跃的是哪个 oneof 分支
privateint payloadCase_ = 0;
// 2. 一个 Object 字段, 存储当前活跃分支的值
private Object payload_;
// 3. 一个 Case enum, 列出所有分支
publicenum PayloadCase {
TEXT(1), // field number = 1
IMAGE(2), // field number = 2
VIDEO(3), // field number = 3
PAYLOAD_NOT_SET(0); // 未设置
}
// 4. 每个分支有独立的 getter
public String getText() { ... }
public ImageData getImage() { ... }
public VideoData getVideo() { ... }
还原为 .proto:
message ChatMessage {
oneof payload {
string text = 1;
ImageData image = 2;
VideoData video = 3;
}
}
识别关键:看到一个
int xxxCase_字段 + 一个Object xxx_字段 + 一个XxxCaseenum,基本可以确认是oneof结构。
5.1.6 识别 Map
map 字段在 protobuf 生成的 Java 代码中使用专用的 MapField 类型:
// map 字段的 Java 代码特征
private MapField<String, Integer> tags_;
// 对应的 getter 返回 java.util.Map
public Map<String, Integer> getTagsMap() { ... }
public int getTagsCount() { ... }
public boolean containsTags(String key) { ... }
还原为 .proto:
message Foo {
map<string, int32> tags = 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 混淆后,类名和方法名被替换为无意义的短名(如 a、b、c),但代码结构模式不变:
// 混淆后的 writeTo 方法 - 类名和字段名都被混淆了
public void a(CodedOutputStream var1) throws IOException {
// 但 writeXxx 方法名属于 protobuf 库, 不会被混淆!
if (this.a != 0) {
var1.writeInt32(1, this.a); // field 1: int32 (确定)
}
if (!this.b.isEmpty()) {
var1.writeString(2, this.b); // field 2: string (确定)
}
if (this.c != null) {
var1.writeMessage(3, this.c); // field 3: message (确定, 嵌套类型需追踪 this.c 的类型)
}
}
混淆代码的还原技巧:
- 搜索
CodedOutputStream的调用:即使应用代码被混淆,protobuf 库本身通常不被混淆(它是外部依赖),所以CodedOutputStream类名和writeXxx方法名都会保留 - 搜索
writeXxx方法调用:writeInt32、writeString等方法名是 protobuf 库的 API,不会被混淆。所有调用这些方法的代码都是writeTo的一部分 - 搜索
FIELD_NUMBER常量:常量值(如1、2、3)不会被混淆,即使常量名被改为a、b、c - 搜索
parseFrom方法:反序列化入口方法的签名特征不变——它接受byte[]或CodedInputStream,返回 Message 对象 - **利用
getDescriptor()**:如果使用的是完整版 protobuf-java(而非 lite),descriptor 包含完整的 schema 信息,即使代码被混淆也能直接提取出原始的.proto定义
5.3 从 Descriptor 还原
如果 APK 使用 protobuf-java(完整版而非 lite),每个生成的 Java 文件中都包含一段序列化的 FileDescriptorProto——这是 .proto 文件的完整二进制表示,包括字段名、类型、注释等全部信息:
// 每个 protobuf 生成的 Java 外部类 (Outer Class) 包含 descriptor 初始化代码
static {
// descriptorData 是 FileDescriptorProto 的 protobuf 序列化字节 (被编码为 Java 字符串)
String[] descriptorData = {
"\n\016user_info.proto\022\007example\032\016address.proto\"" +
"\213\001\n\010UserInfo\022\n\n\002id\030\001 \001(\005\022\014" + ...
};
// 这段数据就是 FileDescriptorProto 的序列化形式, 包含完整的 .proto 信息
}
提取并解码 descriptor,自动还原 .proto:
from google.protobuf import descriptor_pb2
# 从 APK 中提取的 descriptor 二进制数据 (可能需要从 Java 字符串转换)
descriptor_data = open("descriptor.bin", "rb").read()
# 用 FileDescriptorProto 解析 descriptor
file_desc = descriptor_pb2.FileDescriptorProto()
file_desc.ParseFromString(descriptor_data)
# 打印还原的 .proto 定义
print(f'syntax = "proto{file_desc.syntax}";')
if file_desc.package:
print(f'package {file_desc.package};')
# 遍历所有 import 依赖
for dep in file_desc.dependency:
print(f'import "{dep}";')
# 遍历所有 message 定义
for msg in file_desc.message_type:
print(f'\nmessage {msg.name} {{')
for field in msg.field:
# 获取类型名称 (去掉 TYPE_ 前缀并转小写)
type_name = descriptor_pb2.FieldDescriptorProto.Type.Name(field.type)
type_str = type_name.lower().replace("type_", "")
# 获取标签 (repeated / optional / required, 去掉 LABEL_ 前缀)
label = descriptor_pb2.FieldDescriptorProto.Label.Name(field.label)
label_str = label.lower().replace("label_", "")
# 如果是 message 或 enum 类型, 使用 type_name 字段 (包含引用的类型名)
if field.type in (11, 14): # TYPE_MESSAGE=11, TYPE_ENUM=14
type_str = field.type_name.lstrip('.')
print(f' {label_str} {type_str} {field.name} = {field.number};')
print('}')
# 遍历所有 enum 定义
for enum in file_desc.enum_type:
print(f'\nenum {enum.name} {{')
for val in enum.value:
print(f' {val.name} = {val.number};')
print('}')
重要提示:protobuf-lite 和 protobuf-nano 不包含 descriptor 信息(为了减小 APK 体积)。因此这种方法仅适用于使用 protobuf-java 完整版的应用。判断方法:在反编译代码中搜索
FileDescriptor或DescriptorProto,如果找到则说明是完整版。
5.4 使用 PBTK 自动化还原
PBTK (Protobuf Toolkit) 可以自动从 APK 中提取 .proto 文件定义,省去手动分析的繁琐过程:
# 克隆并安装
git clone https://github.com/nicehash/PBTK.git
cd 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 address.proto common.proto ...
注意:PBTK 的提取效果依赖于应用是否包含 descriptor 信息。对于 protobuf-lite 应用,PBTK 可能只能提取到部分信息或完全失败。此时需要回退到手动分析
writeTo方法。
5.5 从网络数据盲猜还原
当没有源码、只有二进制网络数据时,可以通过多样本对比分析来推断字段语义:
import blackboxprotobuf
# 收集同一接口的多个请求/响应样本 (样本越多, 推断越准确)
samples = [
bytes.fromhex("0801120a4a6f686e20446f651803"),
bytes.fromhex("0802120b4a616e6520536d6974681804"),
bytes.fromhex("080312084a696d2042726f776e1802"),
]
# 解码所有样本
for i, sample in enumerate(samples):
msg, typedef = blackboxprotobuf.decode_message(sample)
print(f"Sample {i+1}: {msg}")
# Sample 1: {'1': 1, '2': b'John Doe', '3': 3}
# Sample 2: {'1': 2, '2': b'Jane Smith', '3': 4}
# Sample 3: {'1': 3, '2': b'Jim Brown', '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'] = 'user_id'
typedef['2']['type'] = 'string'
typedef['2']['name'] = 'name'
typedef['3']['name'] = 'level' # 暂定为 level, 后续可根据更多数据修正
# 用修正后的 typedef 解码
for sample in samples:
msg, _ = blackboxprotobuf.decode_message(sample, typedef)
print(msg)
# {'user_id': 1, 'name': 'John Doe', 'level': 3}
# {'user_id': 2, 'name': 'Jane Smith', 'level': 4}
# {'user_id': 3, 'name': 'Jim Brown', '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 \
google/protobuf/descriptor.proto < descriptors.desc
获取 .desc 文件的途径:
- APK assets 目录中可能直接包含
.desc文件- gRPC Server Reflection 服务——如果服务端开启了 reflection(开发/测试环境常见),可以用
grpcurl直接获取所有 service 和 message 定义- 某些应用在初始化时会从服务端下载 descriptor,可以通过抓包获取
小结与展望
本篇覆盖了 protobuf 逆向的「解码—还原」完整链路:
解码层(第四章):从最简单的 protoc --decode_raw 到 Python 递归裸解码、blackboxprotobuf 交互式类型修正、protobuf-inspector 可视化输出,以及 Burp 插件集成方案——根据场景选用合适工具。
还原层(第五章):按可靠性排序的六种方法:
- 分析
writeTo方法(最精确,适用所有版本) - 处理混淆代码(结构不变,库 API 名不被混淆)
- 提取 Descriptor(适用完整版 protobuf-java)
- PBTK 自动化(快速但有局限)
- 多样本对比盲猜(无源码时的最后手段)
- 反编译
.desc文件
核心结论:无论哪种还原方式,最终目标都是输出一份可用的
.proto定义文件,然后就可以用 blackboxprotobuf 或官方 protobuf 库在本地编解码、构造和篡改请求。还原精度越高,后续的构造和测试效率就越高。
下篇将进入最后一个阶段:我们已经知道数据格式,接下来的问题是——如何用 Frida 在运行时动态捕获、修改 protobuf 数据,以及当应用主动对抗分析时如何绕过? 敬请关注。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:泡泡以安 泡泡以安 泡泡以安《Android 逆向视角下的 Protobuf 协议分析(中篇):解码与还原》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论