type
Post
status
Published
date
Jul 10, 2026
slug
summary
本篇目标:越来越多的 App 从传统的 RESTful JSON API 迁移到 gRPC + Protobuf——在 Charles 中 JSON 一目了然,但 Protobuf 编码的数据只是一堆二进制。本篇从 Protobuf 的编码原理与流量识别讲起,覆盖**离线解码、
.proto 还原、Frida 动态 Hook 三套方案**(包括 gRPC 框架层 + Protobuf 序列化层 + Native 层),再到常见对抗手段的绕过,最后给出方案选择速查表与日常逆向 Cheat Sheet。tags
frida
Android
逆向工程
category
公众号
icon
password
网址
上次编辑时间
Jul 10, 2026 06:24 AM
comment
AI 总结
本篇目标:越来越多的 App 从传统的 RESTful JSON API 迁移到 gRPC + Protobuf——在 Charles 中 JSON 一目了然,但 Protobuf 编码的数据只是一堆二进制。本篇从 Protobuf 的编码原理与流量识别讲起,覆盖**离线解码、.proto还原、Frida 动态 Hook 三套方案**(包括 gRPC 框架层 + Protobuf 序列化层 + Native 层),再到常见对抗手段的绕过,最后给出方案选择速查表与日常逆向 Cheat Sheet。 这是本系列中体量最大的一篇——读完之后,面对任何使用 gRPC / Protobuf 的 Android 应用,你都能完整地完成协议分析。
一、Protobuf 基础概念
1.1 什么是 Protobuf
Protobuf(Protocol Buffers)是 Google 于 2008 年开源的一种语言无关、平台无关的高效二进制序列化协议。它通过预定义的
.proto Schema 文件描述数据结构,再由 protoc 编译器生成目标语言的序列化/反序列化代码。与 JSON、XML 等文本格式相比,Protobuf 在体积和解析速度上有显著优势:它不在传输数据中携带字段名,而是用整数编号(field number)标识字段,配合变长整数编码(Varint),使得序列化后的数据极其紧凑。这正是它被广泛用于移动端和高性能后端通信的原因。
类比理解:如果把 JSON 比作「带标签的行李箱」——每个物品都贴着一张写满名字的标签,那么 Protobuf 就是「编号储物柜」——只用编号标识,取物时对照目录表即可。省掉了标签的空间,存取也更快。
1.2 为什么 Android 应用喜欢用 Protobuf
| 特性 | JSON | Protobuf |
| :-- | :-- | :-- |
| 格式 | 文本,人类可读 | 二进制,不可直接阅读 |
| 体积 | 较大(含字段名、引号、花括号) | 压缩 3~10 倍(仅含编号+值) |
| 解析速度 | 较慢(需词法分析) | 快 20~100 倍(直接内存映射) |
| Schema | 无强制约束 | 强制
.proto 定义,类型安全 |
| 前后兼容 | 需自行处理 | 原生支持字段增删的前后兼容 |
| 逆向难度 | 低(明文可读) | 高(二进制编码,无字段名) |Google 系应用(YouTube、Maps、Gmail 等)、微信、抖音、快手等大量使用 Protobuf。对于逆向工程师来说,抓包看到的不再是一目了然的 JSON,而是一堆看似无规律的二进制字节——本篇就是要解决这个问题。
1.3 Protobuf 的版本演进
- proto2(2008 年发布):支持
required/optional/extensions关键字,字段必须显式声明标签。目前仍有大量存量项目使用,但已不再推荐用于新项目。
- proto3(2016 年发布):去掉了
required,所有字段默认optional(即零值时不序列化),语法更简洁。是目前绝大多数新项目的首选版本。
- Protobuf Editions(2024 年起):Google 推出的新机制,用
edition = "2024"替代syntax = "proto2/proto3",通过 feature 标注精细控制字段行为。这一机制将逐步统一 proto2 和 proto3 的差异。对于逆向分析而言,Editions 在 Wire Format 层面与 proto2/proto3 完全兼容,不影响二进制编解码逻辑。
逆向视角:无论应用使用 proto2、proto3 还是 Editions,Wire Format 编码格式完全一致。逆向时只需关注编码层面,不必纠结版本差异。
二、Wire Format 编码原理
理解 Wire Format 是逆向 Protobuf 的核心基础。所有 Protobuf 数据——无论用什么语言生成、什么版本的 proto 语法——在二进制层面都遵循相同的编码规则。掌握了 Wire Format,你就能「裸眼」读懂 Protobuf 的二进制数据。
2.1 编码结构
每个字段由 Tag + Value 组成,Tag 本身是一个 Varint,编码了字段编号和类型信息:
其中:
field_number:.proto中定义的字段编号(1, 2, 3, ...)
wire_type:值的编码方式(决定了如何读取后续字节)
关键洞察:Tag 将字段编号和类型信息压缩到一个 Varint 中。低 3 位存储 wire\_type(最多 8 种),高位存储 field\_number。这意味着字段编号 1~15 的 Tag 只需要 1 个字节(因为15 << 3 | 7 = 0x7F,刚好 7 位),而编号 16~2047 需要 2 个字节。因此 Protobuf 建议高频字段使用 1~15 的编号以节省空间。
2.2 Wire Type 类型
| Wire Type | 值 | 含义 | 对应 Protobuf 类型 | 读取方式 |
| :-- | :-- | :-- | :-- | :-- |
| Varint | 0 | 变长整数 | int32, int64, uint32, uint64, sint32, sint64, bool, enum | 逐字节读取,MSB=0 时结束 |
| 64-bit | 1 | 固定 8 字节 | fixed64, sfixed64, double | 直接读取 8 字节 |
| Length-delimited | 2 | 长度前缀 | string, bytes, embedded messages, packed repeated | 先读 Varint 长度,再读对应字节 |
| 32-bit | 5 | 固定 4 字节 | fixed32, sfixed32, float | 直接读取 4 字节 |
注意:Wire Type 3(Start Group)和 4(End Group)在 proto2 中用于分组编码(Group),proto3 已完全移除支持。在逆向分析中,如果遇到 wire\_type=3 或 4,说明数据使用了极其古老的 proto2 Group 特性,现代应用中几乎不会出现。
2.3 Varint 编码
Protobuf 使用变长整数编码(Variable-Length Integer),核心思想是:每个字节的最高位(MSB, Most Significant Bit)作为延续标志位——1 表示后续还有字节,0 表示这是最后一个字节;低 7 位存储实际数据,采用小端序拼接。

Varint 编码 300
2.4 ZigZag 编码
标准 Varint 编码对负数非常不友好——
int32 的 -1 会被当作 0xFFFFFFFF 的无符号值来编码,需要 5 个字节(int64 的负数更是需要 10 个字节)。为解决这个问题,Protobuf 提供了 sint32 / sint64 类型,它们使用 ZigZag 编码先将有符号数映射为无符号数,再用 Varint 编码:逆向提示:当你在裸解码数据中看到一个 Varint 字段值为1,它可能是真正的整数1(int32),也可能是 ZigZag 编码的-1(sint32)。仅从二进制数据无法区分 int32 和 sint32,必须结合业务语义(例如温度、坐标等可能为负的字段更可能是 sint)或反编译writeTo方法中的writeInt32vswriteSInt32调用来判断。

ZigZag 把负数交织到自然数
2.5 手动解码示例
原始十六进制数据:
逐步解析:
对应
.proto 定义:
12 字节手动解码全过程
实际操作建议:手动解码看似繁琐,但在遇到解码工具报错或数据被截断时,手动逐字节分析是最可靠的手段。建议至少练习几次,直到能快速心算 Tag 的 field\_number 和 wire\_type。一个快速心算技巧:Tag 字节除以 8 得到 field\_number,对 8 取余得到 wire\_type。例如0x12 = 18,18 / 8 = 2(field\_number),18 % 8 = 2(wire\_type = Length-delimited)。
三、识别 Protobuf 与 gRPC
在逆向分析中,第一步是判断目标应用是否使用了 Protobuf / gRPC。以下从静态分析和网络流量两个维度提供识别方法。
3.1 APK 中的类名特征
反编译 APK 后搜索以下关键字,命中任何一项即可确认使用了 Protobuf:
在 smali 代码中,Protobuf 生成的消息类有非常明显的模式:
逆向技巧:即使应用经过 ProGuard/R8 混淆,Protobuf 库本身的类名(如com.google.protobuf.CodedOutputStream)通常不会被混淆(因为它们是外部依赖库)。因此,搜索这些库类名是识别 Protobuf 最可靠的方式。
3.2 SO 层面的特征
如果 Protobuf 编译进了 native 层(C++ 实现),可以在
.so 文件中搜索符号和字符串:常见的 C++ protobuf 导出符号:
注意:如果.so文件被 strip 过(去除符号表),nm和readelf可能找不到符号。此时应改用strings搜索字符串——Protobuf 的 C++ 实现中包含大量错误信息字符串(如"Message type ... has no field named ..."),这些字符串无法被 strip 去除。
3.3 .proto / .desc 文件残留
部分 APK 会直接打包
.proto 源文件(开发者疏忽或使用了动态加载 descriptor 的机制):意外收获:如果在 APK 中找到了.proto源文件或.desc描述符文件,逆向工作量将大幅减少——可以直接获得完整的消息定义,无需手动还原。在实际逆向中,约有 10~20% 的应用会不小心打包这些文件。
3.4 网络流量特征
抓包时,以下 Content-Type 值明确指向 Protobuf:
需要注意的「伪装」情况:
经验提示:当你看到application/octet-stream且响应体不是常见的文件格式(如 ZIP、APK、图片)时,值得尝试用protoc --decode_raw解码一下——很多应用为了避免被轻易识别协议格式,会故意使用通用 Content-Type。
当 Content-Type 不明确时,可通过以下二进制数据特征判断:
- **首字节通常是
08**:这是field_number=1, wire_type=0(Varint)的 Tag,是 Protobuf 消息最常见的开头(大多数消息的第一个字段为整数/bool/enum)
- **不以
{或<开头**:区别于 JSON({=0x7B)和 XML(<=0x3C)
- **不包含大量
00填充**:区别于固定长度二进制协议
- 数据较紧凑:没有字段名、没有分隔符、没有引号
- Tag 字节的低 3 位只能是 0, 1, 2, 5:如果首字节的
byte & 0x07不在{0, 1, 2, 5}中,则一定不是合法的 Protobuf
3.5 gRPC 帧结构(重要)
gRPC(Google Remote Procedure Call)是基于 HTTP/2 + Protobuf 的 RPC 框架。它在 Protobuf 数据外层包了一个 5 字节的帧头:

gRPC 帧结构
抓到 gRPC 的 body 后,必须先跳过前 5 字节才能用
protoc --decode_raw 等工具解码。如果压缩标志为 01,还需要先 gzip 解压。四、gRPC 框架架构与拦截点选择
4.1 gRPC 与裸 Protobuf 的差别
裸 Protobuf 场景下,App 自己把
Message.toByteArray() 的结果通过 OkHttp 发出去——这种情况下 Hook 序列化层就够了(见后文 §七)。但 gRPC 多了几层框架封装:
- 传输层:HTTP/2 多路复用 +
Content-Type: application/grpc+ 5 字节帧头
- 调用层:4 种调用模式(一元 / 服务端流 / 客户端流 / 双向流),各自走不同的
ClientCalls静态方法
- 元数据层:
Metadata(类似 HTTP Headers)承载认证 Token、签名、追踪 ID
- 拦截器层:
ClientInterceptor把 Metadata 注入到每个调用中
直接 Hook
toByteArray 能拿到 Message 二进制,但看不到调用对应的 RPC 方法名,也看不到 Metadata 中的 Token——这正是 gRPC 拦截要额外解决的问题。4.2 Android 上的五层拦截点

Android gRPC 技术栈与拦截点
| 拦截点 | 拿到什么 | 何时选它 |
| :-- | :-- | :-- |
| 1\. Stub 层 | 业务 Java 对象,
toString() 直接可读 | 知道具体 Stub 类名时最方便 |
| 2\. ClientCalls(gRPC 框架层) | 请求/响应 Message + RPC 方法名 | 通用首选,不需知具体 Stub 类名 |
| 3\. Protobuf 序列化层 | 仅 byte\[\] | App 不使用标准 gRPC 框架 |
| 4\. OkHttp / Cronet | HTTP/2 frame,含所有 Metadata | 1-3 都失效时的终极方案(见第09篇) |
| 5\. SSL\_read/write | 加密前明文字节流 | 自定义传输栈时兜底(见第08篇) |
gRPC 各层拦截点对比
4.3 gRPC 的四种调用模式
| 模式 | 描述 | ClientCalls 方法 | 频率 |
| :-- | :-- | :-- | :-- |
| Unary(一元) | 1 请求 → 1 响应 |
blockingUnaryCall / asyncUnaryCall | 最常见 |
| Server Streaming | 1 请求 → N 响应(流) | blockingServerStreamingCall / asyncServerStreamingCall | 推送场景 |
| Client Streaming | N 请求 → 1 响应 | asyncClientStreamingCall | 较少 |
| Bidirectional Streaming | N 请求 ↔ N 响应 | asyncBidiStreamingCall | 实时通信 |移动 App 中 90% 以上的 gRPC 调用是 Unary 模式;Server Streaming 用于消息推送/增量同步;双向流多见于音视频信令。
五、方案一:Hook ClientCalls(gRPC 框架层)
io.grpc.stub.ClientCalls 是所有生成 Stub 代码最终都会调用的入口——拦截它就能覆盖所有调用模式。5.1 同步一元调用 blockingUnaryCall
5.2 异步一元调用 + 代理 Observer 模式
异步调用的难点是:**请求在
asyncUnaryCall 出发,响应在 StreamObserver.onNext 回调里到达**。要拿到完整的请求-响应对,必须同时 Hook 这两个点——通过 Java.registerClass 创建一个代理 Observer,把回调转交给真正的 Observer 之前先打印日志。
异步 Observer 代理拦截 6 步时序
**Java.registerClass的几个坑: 1. 类名全局唯一**——同名再注册会抛Class already defined。所以用模块级变量缓存,只注册一次。 2. 传递原 Observer 的两种方式——用fields显式声明字段(如上)比闭包捕获更可靠(避免被 GC 提前回收)。 3. 每次调用新建实例:注册的是「类」,每次拦截$new()一个新实例即可。
5.3 服务端流式调用(同步 & 异步)
同步版返回的是
Iterator<RespT>,每次 next() 取一条流消息。不能直接 hook 实例方法,需要 hook 这个 Iterator 的具体实现类——gRPC-Java 中是 io.grpc.stub.ClientCalls$BlockingResponseStream:版本兼容提醒:BlockingResponseStream是 gRPC-Java 内部类名,在不同版本中可能微调。如果 hook 失败,先用Java.enumerateLoadedClassesSync过滤io.grpc.stub.ClientCalls$前缀确认实际类名。
异步流式调用复用代理 Observer 的思路,把计数器作为实例字段:
客户端流 / 双向流的 Hook 思路完全一样:asyncClientStreamingCall(call, observer)返回值本身就是一个 Observer——你需要代理这个返回的 Observer 来观察客户端发出的每条消息。双向流则是请求和响应都用代理 Observer。考虑到这两种模式在 App 中极少,本篇略过示例代码,思路对照异步流即可。
5.4 辅助函数 printProtobufMessage
**toString()输出为空?** 这是 protobuf-lite 的典型现象——lite 版本为减小体积去掉了TextFormat工具类。诊断方法:在反编译代码中搜索FileDescriptor或DescriptorProto,找到了说明是完整版,没找到就是 lite 版本。lite 版本下需要走 §七的字段级 Hook(CodedOutputStream.writeXxx)或者反射访问 getter 方法。
六、方案二:Hook gRPC Metadata(认证 Token、签名)
6.1 什么是 gRPC Metadata
类似 HTTP Headers,键值对形式的附加信息,App 通常在 Metadata 中传递:
authorization: Bearer eyJhbGc...(认证 Token)
x-device-id: abc123
x-request-sign: hmac-sha256:...(请求签名)
x-trace-id: <uuid>
这些信息在 Stub 层看不到——它们由
ClientInterceptor 在调用过程中注入到 Metadata 对象,最终随 HTTP/2 帧发送。6.2 Hook Metadata.put(请求侧)
6.3 Hook ClientInterceptor(先找实现类,再 Hook interceptCall)
很多 App 把 Token 注入逻辑封装成专门的
AuthInterceptor。Hook 它有两种思路:- 首选:jadx 静态搜定位——在反编译代码中搜
interceptCall(或authorization/Bearer关键字,10 秒就能锁定具体的AuthInterceptor类名,然后Java.use(className).interceptCall.implementation = ...精准 Hook。这是实战首选,\\直接跳到本节末的"单点 Hook 模板"\\即可。
- 兜底:动态扫描所有实现类——完全不知道类名时使用。注意
Java.choose("io.grpc.ClientInterceptor", ...)对接口不工作(ART 不维护按接口索引的活实例表,onMatch一次都不会触发)。正确做法是先用enumerateLoadedClassesSync+isAssignableFrom筛出实现类,再对实现类 HookinterceptCall:
单点 Hook 模板(已知类名时首选):
如果连 Hook 都没触发:说明 Interceptor 还没被实例化(懒加载场景)。让 App 先发起一次 gRPC 调用再 attach;或干脆改用Java.classFactory监听类加载事件,在拦截器类被加载后立即 Hook。
6.4 响应 Metadata 拦截
服务端可能在响应头中下发新 Token 或限流信息:
七、方案三:Hook Protobuf 序列化层(通用,兼容非 gRPC)
当 App 不使用标准 gRPC 框架(没有
io.grpc 包),但使用了 Protobuf 编码数据通过自定义 HTTP 客户端发送时,方案一/二无效。这时需要在 Protobuf 的序列化/反序列化层拦截——这套方案不依赖 gRPC,对所有使用 com.google.protobuf.* 库的应用都适用。
三种 Protobuf 序列化层 Hook 粒度对比
本节按粒度从粗到细介绍三种方法,读者可先看上图选定方案再读对应代码。
7.1 Hook toByteArray(拦截序列化)
完整版还是 Lite 版:如果 App 使用完整版 protobuf-java(继承GeneratedMessageV3),把上面的GeneratedMessageLite替换成GeneratedMessageV3即可。两者可同时 Hook。
7.2 Hook parseFrom(拦截反序列化)
parseFrom 是每个具体 Message 子类的静态方法,无法在基类上统一 Hook。两种策略:策略 A:Hook 特定的目标 Message 类
**策略 B:通过
enumerateLoadedClasses 找到所有 App Message 类批量 Hook****parseFrom有多个重载版本**(byte[]、CodedInputStream、InputStream等)。如果 Hookbyte[]版本没有触发,尝试 Hook 其他重载。可以用TargetMessage.parseFrom.overloads查看所有重载签名。
7.3 Hook CodedOutputStream(字段级精细拦截)
上面两节拦截的是整个 Message 级别的序列化/反序列化。如果需要精确到每个字段的写入,可以 Hook
CodedOutputStream 的各个 writeXxx 方法:最佳用途:这种字段级 Hook 特别适合在不知道 Message 类名的情况下使用——你不需要知道具体是哪个 Message,只需要知道所有 Protobuf 字段最终都会经过CodedOutputStream写出。输出结果可以直接用于还原.proto定义(因为writeXxx方法名直接映射到 proto 类型,对照表见 §九)。
7.4 批量枚举所有 Protobuf Message 类
在不知道目标 Message 类名的情况下,可以枚举 APK 中所有已加载的 Protobuf Message 类及其字段信息:
输出示例:
这些信息结合
writeTo 的 Hook 输出,足以还原出完整的 .proto 定义(见 §九)。7.5 篡改请求
通过 Hook Builder 的
build() 方法,可以在请求发出前篡改字段值——测试支付逻辑、权限校验、绕过客户端检查等场景中非常有用:防御视角:这也说明了为什么服务端不能信任客户端提交的价格字段——即使使用了 Protobuf 二进制编码,攻击者仍然可以通过 Frida 轻松篡改任何字段。价格、数量等敏感字段应在服务端重新计算和校验。
7.6 Frida + PC 端 protoc 实时解码联动
设备端把 Protobuf 二进制数据通过
send() 发送到 PC 端,由 Python 脚本接收并实时解码——实现「边操作边解码」的交互式分析:设备端 Frida 脚本:
PC 端 Python 接收脚本:
进阶优化:可以在 PC 端用 blackboxprotobuf 替代protoc --decode_raw,并维护一个 typedef 映射表,实现带字段名的实时解码。还可以把解码结果写入 SQLite 或 JSON 文件供后续批量分析。
八、离线解码 Protobuf 数据
拿到了 Protobuf 二进制数据之后(无论来自 Frida 保存的
.bin、抓包工具导出、还是手动复制的 hex),下一步就是把它解码成可读结构。根据手头信息的完整程度(有无 .proto 文件),方法从「裸解码」到「精确解码」有多个层次。8.1 protoc --decode_raw
最基础的裸解码方式,无需
.proto 文件,只需安装 protoc 即可:裸解码的局限性:
- 无法区分
int32/sint32/uint32——它们都是 Wire Type 0
- 无法区分
string/bytes/embedded message——它们都是 Wire Type 2
- 不知道字段名,只有 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。

Length-delimited 字段递归解码
8.2 Python 递归裸解码
当需要编程处理大量 Protobuf 数据时,可以用 Python 的 protobuf 库实现递归裸解码:
8.3 Blackboxprotobuf(交互式类型修正)
blackboxprotobuf 是 NCC Group 开发的专为逆向设计的 Python 库,最大亮点是支持交互式类型修正——你可以在裸解码的基础上手动指定每个字段的真实类型,逐步逼近真实的
.proto 定义:进阶用法:blackboxprotobuf 支持将 typedef 保存为 JSON 文件,方便在多次分析间复用。
8.4 protobuf-inspector(彩色终端输出)
protobuf-inspector 提供彩色的、层级化的终端输出,特别适合快速浏览复杂的嵌套 Protobuf 数据:
与 protoc 的区别:protobuf-inspector 的输出更适合人类阅读(有颜色和清晰的类型标注),而protoc --decode_raw的输出更适合脚本处理。快速分析阶段推荐 protobuf-inspector,自动化流水线推荐 protoc。
8.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 插件中,即可在抓包界面中看到带字段名的精确解码结果,大幅提升分析效率。
8.6 mitmproxy 自动解码 gRPC 流量
如果不想注入 Frida(如真机不便 root、或仅做流量回归分析),可以从 HTTP/2 层抓 gRPC 流量。前置条件是 SSL Pinning 已绕过(见第08篇)。

mitmproxy 抓取并解码 gRPC 流量
想要带字段名的精确解码:先用 §九的方法从 APK 还原.proto,再用protoc --python_out=. xxx.proto编译出_pb2.py,然后在 mitmproxy 插件里维护「路径 → Message 类」的映射,调用cls().ParseFromString(payload)即可。
8.7 在线工具
无需安装任何软件,直接在浏览器中解码:
- protobuf-decoder.netlify.app:在线粘贴 hex 或 base64 数据即可裸解码,支持嵌套 message 的递归展开
- protogen.marcgravell.com:在线
.proto编辑器,支持编码/解码测试
安全提醒:在线工具虽然方便,但你上传的数据可能被服务端记录。如果分析的是敏感应用的通信数据,强烈建议使用本地工具进行离线解码。
九、还原 .proto 与 service 定义
裸解码只能看到 field number 和原始值,无法得知字段名和精确类型。要构造/篡改 Protobuf 请求、或编写自动化测试脚本,必须还原出完整的
.proto 定义。
还原 .proto 的决策树
沿"省力路径"自上而下检查——下面 6 个小节按这棵决策树展开。
9.1 从 Java 生成类逆向还原(最精确)
这是最可靠的方法。Protobuf 编译器(protoc)生成的 Java 类有固定的代码模式——即使类名被混淆,代码结构也不会变。
#### 9.1.1 识别 Message 类
识别技巧:即使经过混淆,XXX_FIELD_NUMBER常量的值不会被混淆(混淆器不会修改常量值)。搜索所有值为小正整数且类型为static final int的常量,筛选出同一个类中有多个这样常量的,就很可能是 Protobuf 生成的 Message 类。
#### 9.1.2 识别
writeTo 方法(最关键)writeTo 方法是还原 .proto 的核心线索——它逐字段调用 CodedOutputStream 的类型化写入方法,每一行调用都精确对应一个 .proto 字段定义:由此精确还原出
.proto 定义:#### 9.1.3
writeTo 中的类型映射表| 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) | 值为枚举的整数值 |#### 9.1.4 识别 Enum
还原:
#### 9.1.5 识别 OneOf
还原:
#### 9.1.6 识别 Map
还原:
Wire Format 层面:map<K, V> field = N实际上等价于repeated MapEntry field = N,其中MapEntry是一个隐含的嵌套 message{ K key = 1; V value = 2; }。
9.2 处理混淆代码
当代码被 ProGuard / R8 混淆后,类名和方法名被替换为无意义的短名(如
a、b、c),但代码结构模式不变:混淆代码的还原技巧:
- **搜索
CodedOutputStream的调用**:protobuf 库本身通常不被混淆
- **搜索
writeXxx方法调用**:方法名是 protobuf 库的 API,不会被混淆
- **搜索
FIELD_NUMBER常量**:常量值不会被混淆
- **搜索
parseFrom方法**:签名特征不变
- **利用
getDescriptor()**:如果是完整版 protobuf-java,descriptor 包含完整的 schema 信息
9.3 从 Descriptor 还原(完整版 protobuf-java)
如果 APK 使用 protobuf-java 完整版,每个生成的 Java 文件中都包含一段序列化的 FileDescriptorProto——这是
.proto 文件的完整二进制表示,包括字段名、类型、注释等全部信息:提取并解码 descriptor,自动还原
.proto:重要提示:protobuf-lite 和 protobuf-nano 不包含 descriptor 信息(为了减小 APK 体积)。判断方法:在反编译代码中搜索FileDescriptor或DescriptorProto,如果找到则说明是完整版。
9.4 PBTK 自动化还原
PBTK (Protobuf Toolkit) 可以自动从 APK 中提取
.proto 文件定义:注意:PBTK 的提取效果依赖于应用是否包含 descriptor 信息。对于 protobuf-lite 应用,PBTK 可能只能提取到部分信息或完全失败。此时需要回退到手动分析writeTo方法。
9.5 从网络数据盲猜还原
当没有源码、只有二进制网络数据时,可以通过多样本对比分析来推断字段语义:
盲猜还原的经验法则: - 值为 0/1 的 Varint → 大概率是bool- 值递增的 Varint → 大概率是 ID - 值范围小且固定的 Varint → 大概率是enum- 13 位数字的 Varint → 大概率是毫秒级时间戳 - 10 位数字的 Varint → 大概率是秒级时间戳 - Length-delimited 且内容可读 →string- Length-delimited 且内容能被protoc --decode_raw成功解码 → 嵌套message- Length-delimited 且内容不可读也无法解码 →bytes(可能是加密数据、图片等)
9.6 从 .desc 描述符文件反编译
如果获取到了
.desc 描述符文件(也叫 FileDescriptorSet),可以直接反编译出 .proto 源文件:**获取.desc文件的途径**: 1. APK assets 目录中可能直接包含.desc文件 2. gRPC Server Reflection 服务(见 9.8) 3. 某些应用在初始化时会从服务端下载 descriptor,可以通过抓包获取
9.7 还原 gRPC service 定义
.proto 包含两类定义:message(数据结构)和 service(RPC 接口)。Message 的还原方法见 9.1-9.6,service 是 gRPC 特有的,需要单独处理。gRPC 编译器(
protoc-gen-grpc-java)会为每个 service 生成一个 XxxGrpc 类。在 jadx 中搜索 extends io.grpc.stub.AbstractStub 或文件名后缀 Grpc.java:从这段代码可以还原出:
每个
MethodDescriptor 的泛型参数直接对应请求和响应的 Message 类型;方法名去掉 get/Method 后缀即 RPC 名。9.8 grpcurl 探测开启 Reflection 的服务端
部分企业内部环境、测试服或调试残留可能开启了 gRPC Reflection——这是最省力的还原路径:
如果能拉到 service / message 描述,逆向工作量大幅缩减。先试一下没坏处。
十、常见对抗与绕过
在实际逆向中,应用可能采取各种措施来增加 Protobuf 分析的难度。以下是常见的对抗手段及对应的绕过策略。
10.1 自定义序列化
部分应用不使用标准 protobuf 库(
com.google.protobuf.*),而是自行实现 Protobuf 的编解码逻辑:绕过策略:
Wire Format 编码规范是公开标准,自定义实现必须遵循同样的编码规则(否则服务端无法解码)。因此:
- 搜索代码中的
writeVarint、<< 3、& 0x07等特征操作,定位自定义编码逻辑
- 数据层面完全不变,
protoc --decode_raw仍然能正常解码
- Hook 自定义的
encode()/decode()方法即可捕获数据
10.2 外层加密 / 压缩
很多应用会在 Protobuf 序列化之后、发送之前,对数据进行压缩和/或加密:
绕过策略——关键思路是找到加密前/解密后的节点进行 Hook:
- Hook protobuf 层(最可靠):在
writeTo/toByteArray/parseFrom层面 Hook——此时数据一定是明文 protobuf,无论外层套了多少层加密压缩
- Hook 压缩层:Hook
GZIPOutputStream.write()/GZIPInputStream.read()捕获压缩前/解压后的数据
- Hook 加密层:Hook
Cipher.doFinal()捕获加密前/解密后的数据
- 逐层剥离:如果不确定加密/压缩的具体实现,可以从网络层(OkHttp Interceptor)开始,逐步向内层 Hook,直到拿到可被
protoc --decode_raw成功解码的数据
10.3 字段名混淆
部分代码混淆工具会对
.proto 中的字段名进行混淆(将有意义的字段名替换为 a、b、c),但 field number 和 wire type 无法被混淆——它们是编码在二进制数据中的,改变它们会导致服务端无法解码:对逆向的影响:
- Wire Format 编码完全相同,不影响数据解码
- 丢失了有意义的字段名,需要通过业务语义推断
- 可结合多样本对比分析(9.5 节)和 UI 操作关联来还原字段名
10.4 Protobuf Lite / Nano 无 Descriptor
Android 应用最常用的是
protobuf-lite 或已废弃的 protobuf-nano,它们为了减小 APK 体积,不包含 descriptor 信息:- 无法通过 descriptor 自动还原
.proto(9.3 节方法不适用)
- PBTK 等自动化工具可能失效
Message.toString()输出为空或不可读
绕过策略:只能通过分析
writeTo / mergeFrom 方法手动还原(9.1 节方法)。虽然工作量更大,但还原结果是最精确的。如何判断是 lite 还是完整版: - 完整版:包含com.google.protobuf.Descriptors、FileDescriptor、getDescriptor()等类和方法 - Lite 版:只有com.google.protobuf.GeneratedMessageLite,不包含 Descriptor 相关类 - Nano 版:使用com.google.protobuf.nano.MessageNano基类(已废弃,但存量应用仍在)
10.5 Native 层 Protobuf
当 Protobuf 逻辑在
.so 文件中实现(C++ protobuf 库)时,Java 层的 Hook 方法不再适用,需要转向 native 层。静态分析:
Frida Hook native 层:直接解析
std::string 内部结构很脆弱——libc++ 有 SSO 短串优化(默认布局是 [capacity, size, data*],alternate ABI 又变成 [data*, size, capacity])、libstdc++ 又是完全不同的引用计数式布局,跨版本/跨编译器经常翻车。推荐改 Hook 一个返回裸字节指针的函数,从源头绕开 std::string 的内存布局问题:C++ 符号名查找技巧:如果.so文件没有被 strip,可以用nm -D直接搜索符号。如果被 strip 了,可以在 IDA/Ghidra 中通过字符串交叉引用(如"SerializeWithCachedSizes"错误信息)来定位函数。另外,c++filt工具可以将 mangled name(如_ZN6google8protobuf...)还原为可读的 C++ 签名。
**如果只能 HookSerializeToString(std::string*)**:那就必须解析std::string内部结构。但这里有三套布局要区分——① libc++ 默认(Android NDK 默认):长串[capacity, size, data*],capacity 最低位作 long/short 标志位;short 模式数据内联在对象前 23 字节、size 编码在最后一字节的高 7 位;② libc++ alternate(_LIBCPP_ABI_ALTERNATE_STRING_LAYOUT):顺序变成[data*, size, capacity];③ libstdc++:完全是另一套(COW / SSO 取决于 gcc 版本)。跨版本踩坑很多,能避就避,优先用上面的SerializeWithCachedSizesToArray方案。
十一、方案选择速查表与 Cheat Sheet
11.1 方案选择速查表
| 你的情况 | 推荐方案 | 对应章节 |
| :-- | :-- | :-- |
| App 走标准 gRPC(有
io.grpc 包) | Hook ClientCalls 四模式 | §五 |
| 需要看 Token / 签名 | \+ Hook Metadata / ClientInterceptor | §六 |
| App 用 Protobuf 但不走 gRPC | Hook toByteArray / parseFrom | §七 |
| 需要精确到字段的 Hook | Hook CodedOutputStream.writeXxx | §7.3 |
| 不知道目标类名 | 批量枚举 + 字段级 Hook | §7.3-7.4 |
| 需要篡改请求 | Hook Builder.build() | §7.5 |
| 不想注入 Frida,纯流量分析 | mitmproxy 插件(跳 5 字节帧头) | §8.6 |
| 只有原始二进制数据 | protoc / blackboxprotobuf / protobuf-inspector | §八 |
| 拿到 message / .proto 定义 | writeTo / Descriptor / PBTK / 盲猜 | §九 |
| 拿到 service / RPC 定义 | jadx 看 XxxGrpc 类 + grpcurl reflection | §9.7-9.8 |
| toString() 输出为空 | 多半是 protobuf-lite | §10.4 |
| 外层有加密/压缩 | Hook 加密前的 protobuf 序列化层 | §10.2 |
| Native 层 protobuf | Hook SerializeToString 等 mangled name | §10.5 |11.2 实战 Cheat Sheet
以下是日常逆向中最常用的命令和代码片段,建议收藏备用:
11.3 工具速查
解码工具:
| 工具 | 用途 | 安装方式 | 适用场景 |
| :-- | :-- | :-- | :-- |
|
protoc --decode_raw | 命令行裸解码 | brew install protobuf | 快速验证数据是否为 protobuf |
| blackboxprotobuf | Python 交互式解码 | pip install blackboxprotobuf | 逐步还原字段类型 |
| protobuf-inspector | 彩色层级化输出 | pip install protobuf-inspector | 快速浏览嵌套结构 |
| PBTK | APK 自动提取 .proto | git clone from GitHub | 完整版 protobuf-java |动态分析工具:
| 工具 | 用途 | 说明 |
| :-- | :-- | :-- |
| Frida | Hook Java/Native protobuf 调用 | 最灵活,实时捕获和篡改 |
| mitmproxy | 抓包 + 自定义 protobuf 解码脚本 | Python 脚本扩展 |
| Charles / Burp Suite | 配合插件解码 protobuf 流量 | GUI 友好 |
| Wireshark | 分析 gRPC/protobuf 网络层细节 | 支持 protobuf dissector |
小结
Protobuf 逆向的核心在于理解 Wire Format 编码——无论应用如何混淆和加密,Protobuf 数据最终都必须遵循
Tag(field_number + wire_type) + Value 的编码格式。掌握了这一点,剩下的工作就是选择合适的拦截点和工具。本篇覆盖的完整方法论:
- 认知层(§一-§二):理解 Protobuf 为何高效、Wire Format 的
Tag+Value编码结构、Varint 和 ZigZag 编码——这是一切后续操作的理论依据。
- 识别层(§三):从 APK 类名到 HTTP 流量特征,多维度快速判断目标应用是否使用 Protobuf / gRPC,特别注意 gRPC 的 5 字节帧头。
- gRPC 框架层 Hook(§四-§六):Hook
io.grpc.stub.ClientCalls覆盖四种调用模式;用Java.registerClass创建代理 Observer 处理异步回调;用Java.choose而非全量遍历找ClientInterceptor。
- Protobuf 序列化层 Hook(§七):当 App 不走标准 gRPC 时的通用方案——
toByteArray拦截序列化、parseFrom拦截反序列化、CodedOutputStream.writeXxx字段级精细拦截,配合 Frida + PC 端实时解码联动。
- 解码层(§八):从
protoc --decode_raw到 blackboxprotobuf 交互式类型修正,从 protobuf-inspector 可视化输出到 mitmproxy 插件,根据场景选用合适工具。
- 还原层(§九):六种方法按可靠性排序——
writeTo反推(最精确)、处理混淆代码、提取 Descriptor、PBTK 自动化、多样本盲猜、.desc反编译;gRPC 还有 service 还原和grpcurl探测。
- 对抗层(§十):自定义序列化、外层加密、字段混淆、无 Descriptor、Native 层实现——每种对抗手段都有对应的绕过思路。
- Author:24th
- URL:https://24th.top/article/399e5b08-46db-80fc-b678-dd01bac6c091
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!








