当前位置:首页>学习笔记>Frida学习笔记(十):gRPC、Protobuf 协议逆向

Frida学习笔记(十):gRPC、Protobuf 协议逆向

  • 2026-05-19 10:34:01
Frida学习笔记(十):gRPC、Protobuf 协议逆向

本篇目标:越来越多的 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

特性JSONProtobuf
格式文本,人类可读二进制,不可直接阅读
体积较大(含字段名、引号、花括号)压缩 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 风格
syntax = "proto2";
message User {
  required string name = 1;
  optional int32 age = 2;
}

// proto3 风格
syntax = "proto3";
message User {
  string name = 1;
  int32 age = 2;
}

// Editions 风格 (2024+)
edition = "2024";
message User {
  string name = 1 [features.field_presence = EXPLICIT];
  int32 age = 2;
}

逆向视角:无论应用使用 proto2、proto3 还是 Editions,Wire Format 编码格式完全一致。逆向时只需关注编码层面,不必纠结版本差异。

二、Wire Format 编码原理

理解 Wire Format 是逆向 Protobuf 的核心基础。所有 Protobuf 数据——无论用什么语言生成、什么版本的 proto 语法——在二进制层面都遵循相同的编码规则。掌握了 Wire Format,你就能「裸眼」读懂 Protobuf 的二进制数据。

2.1 编码结构

每个字段由 Tag + Value 组成,Tag 本身是一个 Varint,编码了字段编号和类型信息:

Tag = (field_number << 3| wire_type

其中:

  • 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 类型读取方式
Varint0变长整数int32, int64, uint32, uint64, sint32, sint64, bool, enum逐字节读取,MSB=0 时结束
64-bit1固定 8 字节fixed64, sfixed64, double直接读取 8 字节
Length-delimited2长度前缀string, bytes, embedded messages, packed repeated先读 Varint 长度,再读对应字节
32-bit5固定 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 编码:

ZigZag 编码公式:
  ZigZag(n) = (n << 1) ^ (n >> 31) // sint32 (算术右移)
  ZigZag(n) = (n << 1) ^ (n >> 63) // sint64 (算术右移)

ZigZag 解码公式:
  原始值 = (encoded >>> 1) ^ -(encoded & 1)

映射表:
  原始值 → ZigZag 编码值 → Varint 字节数
   0 → 0 → 1 字节
  -1 → 1 → 1 字节
   1 → 2 → 1 字节
  -2 → 3 → 1 字节
   2 → 4 → 1 字节
  -64 → 127 → 1 字节
   64 → 128 → 2 字节
  2147483647 → 4294967294 → 5 字节
 -2147483648 → 4294967295 → 5 字节

逆向提示:当你在裸解码数据中看到一个 Varint 字段值为 1,它可能是真正的整数 1(int32),也可能是 ZigZag 编码的 -1(sint32)。仅从二进制数据无法区分 int32 和 sint32,必须结合业务语义(例如温度、坐标等可能为负的字段更可能是 sint)或反编译 writeTo 方法中的 writeInt32 vs writeSInt32 调用来判断。

ZigZag 把负数交织到自然数

2.5 手动解码示例

原始十六进制数据:

08 96 01 12 07 74 65 73 74 69 667

逐步解析:

第一个字段:
  Tag 字节: 08 = 0000_1000
    field_number = 0000_1000 >> 3 = 0000_0001 = 1
    wire_type    = 0000_1000 & 0x07 = 0 (Varint)
  Value: 96 01
    0x96 = 1_0010110  (MSB=1, 继续; 有效位: 0010110)
    0x01 = 0_0000001  (MSB=0, 结束; 有效位: 0000001)
    拼接: 0000001 ++ 0010110 = 10010110 = 150
  结果: field 1 = 150 (Varint)

第二个字段:
  Tag 字节: 12 = 0001_0010
    field_number = 0001_0010 >> 3 = 0000_0010 = 2
    wire_type    = 0001_0010 & 0x07 = 2 (Length-delimited)
  Length: 07 = 7 字节
  Value: 74 65 73 74 69 667
    UTF-8 解码: t(74) e(65) s(73) t(74) i(69) n(6E) g(67) = "testing"
  结果: field 2 = "testing" (string)

对应 .proto 定义:

message Example {
  int32 id = 1;     // 值为 150
  string name = 2;  // 值为 "testing"
}
12 字节手动解码全过程

实际操作建议:手动解码看似繁琐,但在遇到解码工具报错或数据被截断时,手动逐字节分析是最可靠的手段。建议至少练习几次,直到能快速心算 Tag 的 field_number 和 wire_type。一个快速心算技巧:Tag 字节除以 8 得到 field_number,对 8 取余得到 wire_type。例如 0x12 = 1818 / 8 = 2(field_number),18 % 8 = 2(wire_type = Length-delimited)。

三、识别 Protobuf 与 gRPC

在逆向分析中,第一步是判断目标应用是否使用了 Protobuf / gRPC。以下从静态分析和网络流量两个维度提供识别方法。

3.1 APK 中的类名特征

反编译 APK 后搜索以下关键字,命中任何一项即可确认使用了 Protobuf:

com.google.protobuf                 #  protobuf-java 
com.google.protobuf.nano            # Nano  ()
com.google.protobuf.lite            # Lite Android 
com.squareup.wire                   # Square  Wire  (protobuf )
com.squareup.wire.Message           # Wire  Message 
GeneratedMessageLite                # protobuf-lite 
GeneratedMessageV3                  # protobuf-java 
MessageLite                         # Lite 
CodedInputStream                    # Protobuf 
CodedOutputStream                   # Protobuf 
io.grpc                             # gRPC 
ManagedChannelBuilder               # gRPC Channel 
AbstractStub                        # gRPC Stub 

在 smali 代码中,Protobuf 生成的消息类有非常明显的模式:

# protobuf-lite 生成的 message 类通常继承:
.super Lcom/google/protobuf/GeneratedMessageLite;

# 包含以下关键方法 (即使类名被混淆, 方法签名中的 protobuf 类型不会变):
.method public static parseFrom([B)Lcom/example/MyMessage;
    # 从 byte[] 反序列化, 参数签名 [B 表示 byte 数组
.method public writeTo(Lcom/google/protobuf/CodedOutputStream;)V
    # 序列化到 CodedOutputStream, 这是还原 .proto 的关键方法
.method public getSerializedSize()I
    # 返回序列化后的字节数, 用于预分配缓冲区

# 字段编号常量 (即使变量名被混淆, 值不会变):
.field public static final ID_FIELD_NUMBER:I = 0x1
.field public static final NAME_FIELD_NUMBER:I = 0x2

逆向技巧:即使应用经过 ProGuard/R8 混淆,Protobuf 库本身的类名(如 com.google.protobuf.CodedOutputStream)通常不会被混淆(因为它们是外部依赖库)。因此,搜索这些库类名是识别 Protobuf 最可靠的方式。

3.2 SO 层面的特征

如果 Protobuf 编译进了 native 层(C++ 实现),可以在 .so 文件中搜索符号和字符串:

# 在 .so 文件中搜索字符串
strings libnative.so | grep -i "protobuf"
# 搜索 C++ 命名空间下的 protobuf 符号
strings libnative.so | grep "google::protobuf"
# 搜索 .proto 文件路径残留 (有时编译时会嵌入源文件路径)
strings libnative.so | grep "\.proto"

常见的 C++ protobuf 导出符号:

google::protobuf::MessageLite::SerializeToString
google::protobuf::MessageLite::ParseFromString
google::protobuf::internal::WireFormatLite
google::protobuf::io::CodedInputStream
google::protobuf::io::CodedOutputStream
google::protobuf::DescriptorPool

注意:如果 .so 文件被 strip 过(去除符号表),nmreadelf 可能找不到符号。此时应改用 strings 搜索字符串——Protobuf 的 C++ 实现中包含大量错误信息字符串(如 "Message type ... has no field named ..."),这些字符串无法被 strip 去除。

3.3 .proto / .desc 文件残留

部分 APK 会直接打包 .proto 源文件(开发者疏忽或使用了动态加载 descriptor 的机制):

# 解压 APK 后搜索 .proto 源文件
find . -name "*.proto"
# 直接列出 APK 包内的 .proto 文件 (无需解压)
unzip -l app.apk | grep ".proto"
# 搜索 .desc / .pb 描述符文件 (编译后的 .proto 二进制格式)
find . -name "*.desc" -o -name "*.pb" -o -name "*.protobin"
# 搜索 assets 目录下的可疑二进制文件
find ./assets -name "*.bin" -o -name "*.dat"

意外收获:如果在 APK 中找到了 .proto 源文件或 .desc 描述符文件,逆向工作量将大幅减少——可以直接获得完整的消息定义,无需手动还原。在实际逆向中,约有 10~20% 的应用会不小心打包这些文件。

3.4 网络流量特征

抓包时,以下 Content-Type 值明确指向 Protobuf:

Content-Type: application/x-protobuf       # 标准 protobuf MIME 类型
Content-Type: application/protobuf # 简写形式
Content-Type: application/grpc # gRPC 框架 (底层使用 protobuf)
Content-Type: application/grpc+proto # gRPC 显式声明使用 protobuf
Content-Type: application/vnd.google.protobuf # Google 厂商特定 MIME

需要注意的「伪装」情况:

Content-Type: application/octet-stream      # 部分应用用通用二进制类型隐藏
Content-Type: application/x-binary # 另一种通用二进制类型
Content-Type: application/json # 极少数情况下, protobuf 被 Base64 编码后放在 JSON 字段中

经验提示:当你看到 application/octet-stream 且响应体不是常见的文件格式(如 ZIP、APK、图片)时,值得尝试用 protoc --decode_raw 解码一下——很多应用为了避免被轻易识别协议格式,会故意使用通用 Content-Type。

当 Content-Type 不明确时,可通过以下二进制数据特征判断:

  1. 首字节通常是 08:这是 field_number=1, wire_type=0(Varint)的 Tag,是 Protobuf 消息最常见的开头(大多数消息的第一个字段为整数/bool/enum)
  2. 不以 {< 开头:区别于 JSON({ = 0x7B)和 XML(< = 0x3C
  3. 不包含大量 00 填充:区别于固定长度二进制协议
  4. 数据较紧凑:没有字段名、没有分隔符、没有引号
  5. Tag 字节的低 3 位只能是 0, 1, 2, 5:如果首字节的 byte & 0x07 不在 {0, 1, 2, 5} 中,则一定不是合法的 Protobuf
# 快速判断脚本
import subprocess
from typing import Optional


def looks_like_protobuf(data: bytes-> bool:
    if not data or len(data) < 2:
        return False
    first_byte = data[0]
    wire_type = first_byte & 0x07
    field_number = first_byte >> 3

    if wire_type not in (0125):
        return False
    if field_number == 0:
        return False

    try:
        result = subprocess.run(
            ['protoc''--decode_raw'],
            input=data,
            capture_output=True,
            timeout=5
        )
        return result.returncode == 0 and len(result.stdout) > 0
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return True


def detect_protobuf_in_response(
    content_type: Optional[str], body: bytes
-> str:
    if content_type:
        ct = content_type.lower()
        if 'grpc' in ct:
            return 'grpc'
        if 'protobuf' in ct or 'x-protobuf' in ct:
            return 'protobuf'
    if looks_like_protobuf(body):
        return 'protobuf'
    return 'unlikely'

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 / CronetHTTP/2 frame,含所有 Metadata1-3 都失效时的终极方案(见第09篇)
5. SSL_read/write加密前明文字节流自定义传输栈时兜底(见第08篇)
gRPC 各层拦截点对比

4.3 gRPC 的四种调用模式

模式描述ClientCalls 方法频率
Unary(一元)1 请求 → 1 响应blockingUnaryCall / asyncUnaryCall最常见
Server Streaming1 请求 → N 响应(流)blockingServerStreamingCall / asyncServerStreamingCall推送场景
Client StreamingN 请求 → 1 响应asyncClientStreamingCall较少
Bidirectional StreamingN 请求 ↔ N 响应asyncBidiStreamingCall实时通信

移动 App 中 90% 以上的 gRPC 调用是 Unary 模式;Server Streaming 用于消息推送/增量同步;双向流多见于音视频信令。

五、方案一:Hook ClientCalls(gRPC 框架层)

io.grpc.stub.ClientCalls 是所有生成 Stub 代码最终都会调用的入口——拦截它就能覆盖所有调用模式。

5.1 同步一元调用 blockingUnaryCall

// grpc_capture.js
// 通用 gRPC 拦截脚本——覆盖全部调用模式
Java.perform(function() {

    var separator = "═".repeat(55);

    // ====== Hook Channel 创建:获取服务器地址 ======
    try {
        var Builder = Java.use("io.grpc.ManagedChannelBuilder");
        Builder.forAddress.overload("java.lang.String""int")
            .implementation = function(host, port) {
            console.log("[gRPC] Channel 目标: " + host + ":" + port);
            return this.forAddress(host, port);
        };
        Builder.forTarget.overload("java.lang.String")
            .implementation = function(target) {
            console.log("[gRPC] Channel 目标: " + target);
            return this.forTarget(target);
        };
    } catch(e) {}

    // ====== 同步一元调用 ======
    try {
        var ClientCalls = Java.use("io.grpc.stub.ClientCalls");

        ClientCalls.blockingUnaryCall.overload(
            "io.grpc.Channel",
            "io.grpc.MethodDescriptor",
            "io.grpc.CallOptions",
            "java.lang.Object"
        ).implementation = function(channel, method, callOptions, request) {

            var fullMethod = method.getFullMethodName();

            console.log("\n" + separator);
            console.log("[gRPC Unary] " + fullMethod);
            console.log("─".repeat(55));
            printProtobufMessage("请求", request);

            var startTime = Date.now();
            var response;
            try {
                response = this.blockingUnaryCall(channel, method, callOptions, request);
            } catch(e) {
                console.log("[!] 调用失败: " + e.getMessage());
                console.log(separator + "\n");
                throw e;
            }

            var elapsed = Date.now() - startTime;
            printProtobufMessage("响应", response);
            console.log("[耗时] " + elapsed + "ms");
            console.log(separator + "\n");

            return response;
        };

        console.log("[OK] blockingUnaryCall");
    } catch(e) {
        console.log("[--] blockingUnaryCall: " + e.message);
    }

    // ... 异步与流式 Hook 见后续小节 ...
});

5.2 异步一元调用 + 代理 Observer 模式

异步调用的难点是:请求在 asyncUnaryCall 出发,响应在 StreamObserver.onNext 回调里到达。要拿到完整的请求-响应对,必须同时 Hook 这两个点——通过 Java.registerClass 创建一个代理 Observer,把回调转交给真正的 Observer 之前先打印日志。

异步 Observer 代理拦截 6 步时序
// ===== 异步一元调用 =====
// 关键点:代理类必须在 Java.perform 顶层"只注册一次"——
// 如果放在 implementation 内部,第二次调用会因为类名重复而崩溃。
var ProxyObserverClass = null;  // 模块级缓存
function getOrCreateProxyObserver() {
    if (ProxyObserverClass !== nullreturn ProxyObserverClass;
    var StreamObserver = Java.use("io.grpc.stub.StreamObserver");
    ProxyObserverClass = Java.registerClass({
        name: "com.frida.GrpcUnaryProxyObserver",
        implements: [StreamObserver],
        fields: { delegate: "io.grpc.stub.StreamObserver" },
        methods: {
            onNext: function(response) {
                printProtobufMessage("异步响应", response);
                this.delegate.value.onNext(response);
            },
            onError: function(throwable) {
                console.log("[!] gRPC 错误: " + throwable.getMessage());
                this.delegate.value.onError(throwable);
            },
            onCompleted: function() { this.delegate.value.onCompleted(); }
        }
    });
    return ProxyObserverClass;
}

try {
    var ClientCalls = Java.use("io.grpc.stub.ClientCalls");

    ClientCalls.asyncUnaryCall.implementation = function(call, request, responseObserver) {
        console.log("\n[gRPC Async Unary] 请求:");
        printProtobufMessage("请求", request);

        var Proxy = getOrCreateProxyObserver();
        var proxy = Proxy.$new();
        proxy.delegate.value = responseObserver;

        this.asyncUnaryCall(call, request, proxy);
    };
    console.log("[OK] asyncUnaryCall");
catch(e) {
    console.log("[--] asyncUnaryCall: " + e.message);
}

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

// ===== 同步服务端流式 =====
try {
    var ClientCalls = Java.use("io.grpc.stub.ClientCalls");

    ClientCalls.blockingServerStreamingCall.overload(
        "io.grpc.Channel""io.grpc.MethodDescriptor",
        "io.grpc.CallOptions""java.lang.Object"
    ).implementation = function(channel, method, callOptions, request) {
        var fullMethod = method.getFullMethodName();
        console.log("\n[gRPC Server Stream] " + fullMethod);
        printProtobufMessage("请求", request);
        return this.blockingServerStreamingCall(channel, method, callOptions, request);
    };

    // 真正拦截流消息——hook 内部 Iterator 的 next()
    var BlockingResp = Java.use("io.grpc.stub.ClientCalls$BlockingResponseStream");
    BlockingResp.next.implementation = function() {
        var msg = this.next();
        printProtobufMessage(" 流消息", msg);
        return msg;
    };
    console.log("[OK] blockingServerStreamingCall");
catch(e) {
    console.log("[--] blockingServerStreamingCall: " + e.message);
}

版本兼容提醒BlockingResponseStream 是 gRPC-Java 内部类名,在不同版本中可能微调。如果 hook 失败,先用 Java.enumerateLoadedClassesSync 过滤 io.grpc.stub.ClientCalls$ 前缀确认实际类名。

异步流式调用复用代理 Observer 的思路,把计数器作为实例字段:

// ===== 异步服务端流式 =====
try {
    var ClientCalls = Java.use("io.grpc.stub.ClientCalls");
    var StreamObserver = Java.use("io.grpc.stub.StreamObserver");

    var StreamProxyClass = Java.registerClass({
        name: "com.frida.GrpcStreamProxyObserver",
        implements: [StreamObserver],
        fields: {
            delegate: "io.grpc.stub.StreamObserver",
            count: "int"
        },
        methods: {
            onNext: function(response) {
                this.count.value = this.count.value + 1;
                console.log("[Stream #" + this.count.value + "]");
                printProtobufMessage(" 消息", response);
                this.delegate.value.onNext(response);
            },
            onError: function(throwable) {
                console.log("[Stream Error] " + throwable.getMessage());
                this.delegate.value.onError(throwable);
            },
            onCompleted: function() {
                console.log("[Stream 完成] 共 " + this.count.value + " 条");
                this.delegate.value.onCompleted();
            }
        }
    });

    ClientCalls.asyncServerStreamingCall.implementation = function(call, request, observer) {
        console.log("\n[gRPC Async Server Stream]");
        printProtobufMessage("请求", request);

        var proxy = StreamProxyClass.$new();
        proxy.delegate.value = observer;
        proxy.count.value = 0;
        this.asyncServerStreamingCall(call, request, proxy);
    };
    console.log("[OK] asyncServerStreamingCall");
catch(e) {
    console.log("[--] asyncServerStreamingCall: " + e.message);
}

客户端流 / 双向流的 Hook 思路完全一样asyncClientStreamingCall(call, observer) 返回值本身就是一个 Observer——你需要代理这个返回的 Observer 来观察客户端发出的每条消息。双向流则是请求和响应都用代理 Observer。考虑到这两种模式在 App 中极少,本篇略过示例代码,思路对照异步流即可。

5.4 辅助函数 printProtobufMessage

function printProtobufMessage(label, message) {
    if (message === null || message === undefined) {
        console.log("[" + label + "] null");
        return;
    }
    console.log("[" + label + "] 类型: " + message.getClass().getName());

    var text = message.toString();
    if (text.length === 0) {
        console.log(" (空消息或 protobuf-lite 未生成 toString)");
        return;
    }

    var maxLen = 2000;
    if (text.length > maxLen) text = text.substring(0, maxLen) + "\n ...(截断)";
    text.split("\n").forEach(function(line) { console.log(" " + line); });
}

toString() 输出为空? 这是 protobuf-lite 的典型现象——lite 版本为减小体积去掉了 TextFormat 工具类。诊断方法:在反编译代码中搜索 FileDescriptorDescriptorProto,找到了说明是完整版,没找到就是 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(请求侧)

// grpc_metadata.js
Java.perform(function() {
    try {
        var Metadata = Java.use("io.grpc.Metadata");
        // Metadata.put 目前只有一个签名: <T> void put(Key<T> key, T value)
        Metadata.put.overload("io.grpc.Metadata$Key""java.lang.Object")
            .implementation = function(key, value) {
            console.log("[gRPC Metadata] " + key.name() + ": " + value);
            return this.put(key, value);
        };
        console.log("[OK] Metadata.put");
    } catch(e) {
        console.log("[--] Metadata.put: " + e.message);
    }
});

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 筛出实现类,再对实现类 Hook interceptCall
// 仅在拦截器已经被构造之后才有效
// 建议:先让 App 跑起来打开任意页面,再 attach 注入此脚本
Java.perform(function() {
    var Interceptor = Java.use("io.grpc.ClientInterceptor");

    // ① 找出所有实现了 ClientInterceptor 的具体类(一次性, 比逐类 try Java.use 快很多)
    var implClasses = [];
    Java.enumerateLoadedClassesSync().forEach(function(name) {
        if (name.indexOf("io.grpc."=== 0return;        // 排除 gRPC 自带
        if (name.indexOf("$"!== -1return;              // 排除内部类
        try {
            var Cls = Java.use(name);
            if (Interceptor.class.isAssignableFrom(Cls.class)) {
                implClasses.push(name);
            }
        } catch(e) {}
    });
    console.log("[*] 发现 ClientInterceptor 实现类 " + implClasses.length + " 个");

    // ② 对具体类 Hook interceptCall(hook 接口方法不稳, 必须 hook 实现)
    implClasses.forEach(function(className) {
        try {
            var Cls = Java.use(className);
            Cls.interceptCall.implementation = function(method, callOptions, next) {
                console.log("[Interceptor] " + className +
                            " → " + method.getFullMethodName());
                return this.interceptCall(method, callOptions, next);
            };
            console.log("[OK] Hook " + className);
        } catch(e) {
            console.log("[--] " + className + ": " + e.message);
        }
    });
});

单点 Hook 模板(已知类名时首选):

Java.perform(function() {
    var Cls = Java.use("com.example.app.grpc.AuthTokenInterceptor");
    Cls.interceptCall.implementation = function(method, callOptions, next) {
        console.log("[AuthInterceptor] → " + method.getFullMethodName());
        return this.interceptCall(method, callOptions, next);
    };
});

如果连 Hook 都没触发:说明 Interceptor 还没被实例化(懒加载场景)。让 App 先发起一次 gRPC 调用再 attach;或干脆改用 Java.classFactory 监听类加载事件,在拦截器类被加载后立即 Hook。

6.4 响应 Metadata 拦截

服务端可能在响应头中下发新 Token 或限流信息:

try {
    var Listener = Java.use(
        "io.grpc.ForwardingClientCallListener$SimpleForwardingClientCallListener");
    Listener.onHeaders.implementation = function(headers) {
        console.log("[gRPC 响应头]\n " + headers.toString());
        return this.onHeaders(headers);
    };
catch(e) {}

七、方案三:Hook Protobuf 序列化层(通用,兼容非 gRPC)

当 App 不使用标准 gRPC 框架(没有 io.grpc 包),但使用了 Protobuf 编码数据通过自定义 HTTP 客户端发送时,方案一/二无效。这时需要在 Protobuf 的序列化/反序列化层拦截——这套方案不依赖 gRPC,对所有使用 com.google.protobuf.* 库的应用都适用。

三种 Protobuf 序列化层 Hook 粒度对比

本节按粒度从粗到细介绍三种方法,读者可先看上图选定方案再读对应代码

7.1 Hook toByteArray(拦截序列化)

// hook_protobuf_writeto.js
// 拦截所有 protobuf message 的序列化, 捕获序列化后的二进制数据
Java.perform(function() {

    // Hook GeneratedMessageLite.toByteArray (protobuf-lite 最常用的序列化方法)
    var MessageLite = Java.use("com.google.protobuf.GeneratedMessageLite");

    MessageLite.toByteArray.implementation = function() {
        var result = this.toByteArray();
        var className = this.getClass().getName();

        // 过滤系统内部调用
        if (className.indexOf("com.google.protobuf"!== -1 ||
            className.indexOf("io.grpc"!== -1) {
            return result;
        }

        console.log("\n[*] Protobuf Serialize: " + className);
        console.log("[*] Size: " + result.length + " bytes");

        // 将 byte[] 转为十六进制字符串 (限制最多打印 512 字节, 避免刷屏)
        var hex = "";
        for (var i = 0; i < result.length && i < 512; i++) {
            hex += ("0" + (result[i] & 0xFF).toString(16)).slice(-2+ " ";
        }
        console.log("[*] Hex: " + hex.trim());

        // 尝试打印 toString (完整版 Message 类会生成可读输出)
        try {
            console.log("[*] Content: " + this.toString());
        } catch(e) {}

        // 将原始二进制数据保存到文件, 方便后续用 protoc 离线分析
        var ts = Date.now();
        var path = "/data/local/tmp/pb_out_" + ts + ".bin";
        var fos = Java.use("java.io.FileOutputStream").$new(path);
        fos.write(result);
        fos.close();
        console.log("[*] Saved to: " + path);

        return result;
    };
});

完整版还是 Lite 版:如果 App 使用完整版 protobuf-java(继承 GeneratedMessageV3),把上面的 GeneratedMessageLite 替换成 GeneratedMessageV3 即可。两者可同时 Hook。

7.2 Hook parseFrom(拦截反序列化)

parseFrom 是每个具体 Message 子类的静态方法,无法在基类上统一 Hook。两种策略:

策略 A:Hook 特定的目标 Message 类

// hook_protobuf_parsefrom.js
Java.perform(function() {

    var TargetMessage = Java.use("com.example.app.proto.UserResponse");

    TargetMessage.parseFrom.overload('[B').implementation = function(data) {
        console.log("\n[*] parseFrom called on: " + this.getClass().getName());

        // 打印原始二进制数据
        var hex = "";
        for (var i = 0; i < data.length && i < 512; i++) {
            hex += ("0" + (data[i] & 0xFF).toString(16)).slice(-2+ " ";
        }
        console.log("[*] Raw data (" + data.length + " bytes): " + hex.trim());

        // 保存原始数据到文件
        var path = "/data/local/tmp/pb_in_" + Date.now() + ".bin";
        var fos = Java.use("java.io.FileOutputStream").$new(path);
        fos.write(data);
        fos.close();

        // 调用原方法
        var result = this.parseFrom(data);
        try {
            console.log("[*] Parsed: " + result.toString());
        } catch(e) {}

        return result;
    };
});

策略 B:通过 enumerateLoadedClasses 找到所有 App Message 类批量 Hook

Java.perform(function() {
    var protoClasses = [];

    Java.enumerateLoadedClassesSync().forEach(function(name) {
        if (name.indexOf("com.google.protobuf"!== -1return;
        if (name.indexOf("io.grpc"!== -1return;
        try {
            var klass = Java.use(name);
            var superName = klass.class.getSuperclass().getName();
            if (superName.indexOf("GeneratedMessage"!== -1) {
                protoClasses.push(name);
            }
        } catch(e) {}
    });

    console.log("[*] 发现 " + protoClasses.length + " 个 App Protobuf Message 类");

    protoClasses.forEach(function(className) {
        try {
            var klass = Java.use(className);
            klass.parseFrom.overload("[B").implementation = function(data) {
                var result = this.parseFrom(data);
                console.log("\n[*] parseFrom: " + className.split(".").pop());
                console.log(" 输入: " + data.length + " bytes");
                try { console.log(" " + result.toString()); } catch(e) {}
                return result;
            };
        } catch(e) {}
    });
});

parseFrom 有多个重载版本byte[]CodedInputStreamInputStream 等)。如果 Hook byte[] 版本没有触发,尝试 Hook 其他重载。可以用 TargetMessage.parseFrom.overloads 查看所有重载签名。

7.3 Hook CodedOutputStream(字段级精细拦截)

上面两节拦截的是整个 Message 级别的序列化/反序列化。如果需要精确到每个字段的写入,可以 Hook CodedOutputStream 的各个 writeXxx 方法:

// hook_coded_output.js
Java.perform(function() {

    var CodedOutputStream = Java.use("com.google.protobuf.CodedOutputStream");

    CodedOutputStream.writeString.implementation = function(fieldNumber, value) {
        console.log("[PB] writeString field=" + fieldNumber + " value=\"" + value + "\"");
        return this.writeString(fieldNumber, value);
    };

    CodedOutputStream.writeInt32.implementation = function(fieldNumber, value) {
        console.log("[PB] writeInt32 field=" + fieldNumber + " value=" + value);
        return this.writeInt32(fieldNumber, value);
    };

    CodedOutputStream.writeInt64.implementation = function(fieldNumber, value) {
        console.log("[PB] writeInt64 field=" + fieldNumber + " value=" + value);
        return this.writeInt64(fieldNumber, value);
    };

    CodedOutputStream.writeBool.implementation = function(fieldNumber, value) {
        console.log("[PB] writeBool field=" + fieldNumber + " value=" + value);
        return this.writeBool(fieldNumber, value);
    };

    CodedOutputStream.writeEnum.implementation = function(fieldNumber, value) {
        console.log("[PB] writeEnum field=" + fieldNumber + " value=" + value);
        return this.writeEnum(fieldNumber, value);
    };

    CodedOutputStream.writeBytes.implementation = function(fieldNumber, value) {
        console.log("[PB] writeBytes field=" + fieldNumber + " len=" + value.size());
        return this.writeBytes(fieldNumber, value);
    };

    CodedOutputStream.writeMessage.implementation = function(fieldNumber, value) {
        console.log("[PB] writeMessage field=" + fieldNumber +
                    " class=" + value.getClass().getName());
        return this.writeMessage(fieldNumber, value);
    };
});

最佳用途:这种字段级 Hook 特别适合在不知道 Message 类名的情况下使用——你不需要知道具体是哪个 Message,只需要知道所有 Protobuf 字段最终都会经过 CodedOutputStream 写出。输出结果可以直接用于还原 .proto 定义(因为 writeXxx 方法名直接映射到 proto 类型,对照表见 §九)。

7.4 批量枚举所有 Protobuf Message 类

在不知道目标 Message 类名的情况下,可以枚举 APK 中所有已加载的 Protobuf Message 类及其字段信息:

// enum_protobuf_classes.js
Java.perform(function() {

    Java.enumerateLoadedClasses({
        onMatch: function(className) {
            try {
                if (className.indexOf("$"!== -1return;  // 跳过 Builder 等内部类

                var clz = Java.use(className);
                var superClass = clz.class.getSuperclass();

                if (superClass != null) {
                    var superName = superClass.getName();
                    if (superName.indexOf("GeneratedMessageLite"!== -1 ||
                        superName.indexOf("GeneratedMessageV3"!== -1 ||
                        superName.indexOf("GeneratedMessage"!== -1) {

                        console.log("[PROTO] " + className);

                        var fields = clz.class.getDeclaredFields();
                        for (var i = 0; i < fields.length; i++) {
                            var name = fields[i].getName();
                            if (name.endsWith("_FIELD_NUMBER")) {
                                fields[i].setAccessible(true);
                                var val = fields[i].getInt(null);
                                console.log(" " + name + " = " + val);
                            }
                        }
                    }
                }
            } catch(e) {}
        },
        onComplete: function() { console.log("[*] 枚举完成"); }
    });
});

输出示例:

[PROTO] com.example.app.proto.UserInfo
  ID_FIELD_NUMBER = 1
  NAME_FIELD_NUMBER = 2
  EMAIL_FIELD_NUMBER = 3
[PROTO] com.example.app.proto.LoginRequest
  TOKEN_FIELD_NUMBER = 1
  DEVICE_ID_FIELD_NUMBER = 2

这些信息结合 writeTo 的 Hook 输出,足以还原出完整的 .proto 定义(见 §九)。

7.5 篡改请求

通过 Hook Builder 的 build() 方法,可以在请求发出前篡改字段值——测试支付逻辑、权限校验、绕过客户端检查等场景中非常有用:

// tamper_protobuf.js
Java.perform(function() {

    var Builder = Java.use("com.example.app.proto.PurchaseRequest$Builder");

    Builder.build.implementation = function() {
        console.log("[*] Original price: " + this.getPrice());
        console.log("[*] Original item_id: " + this.getItemId());

        // 篡改价格为 0
        this.setPrice(0);
        console.log("[*] Tampered price: " + this.getPrice());

        return this.build();
    };
});

防御视角:这也说明了为什么服务端不能信任客户端提交的价格字段——即使使用了 Protobuf 二进制编码,攻击者仍然可以通过 Frida 轻松篡改任何字段。价格、数量等敏感字段应在服务端重新计算和校验。

7.6 Frida + PC 端 protoc 实时解码联动

设备端把 Protobuf 二进制数据通过 send() 发送到 PC 端,由 Python 脚本接收并实时解码——实现「边操作边解码」的交互式分析:

设备端 Frida 脚本

// realtime_decode.js
Java.perform(function() {

    var MessageLite = Java.use("com.google.protobuf.GeneratedMessageLite");

    MessageLite.toByteArray.implementation = function() {
        var result = this.toByteArray();
        var className = this.getClass().getName();

        var Base64 = Java.use("android.util.Base64");
        var b64 = Base64.encodeToString(result, 0);  // 0 = NO_WRAP

        send({
            type: "protobuf",
            class: className,
            data: b64
        });

        return result;
    };
});

PC 端 Python 接收脚本

import frida
import base64
import subprocess
import sys


def on_message(message: dict, data: bytes-> None:
    if message['type'== 'send' and message['payload'].get('type'== 'protobuf':
        cls = message['payload']['class']
        raw = base64.b64decode(message['payload']['data'])

        result = subprocess.run(
            ['protoc''--decode_raw'],
            input=raw,
            capture_output=True,
            text=True,
            timeout=5
        )

        print(f"\n{'=' * 60}")
        print(f"Class: {cls}")
        print(f"Size: {len(raw)} bytes")
        print(f"Decoded:\n{result.stdout}")
    elif message['type'== 'error':
        print(f"[ERROR] {message['stack']}")


def main() -> None:
    device = frida.get_usb_device()
    session = device.attach("com.example.app")

    with open("realtime_decode.js"as f:
        script = session.create_script(f.read())

    script.on('message', on_message)
    script.load()

    print("[*] Listening for protobuf messages... Press Ctrl+C to quit.")
    try:
        sys.stdin.read()
    except KeyboardInterrupt:
        session.detach()


if __name__ == '__main__':
    main()

进阶优化:可以在 PC 端用 blackboxprotobuf 替代 protoc --decode_raw,并维护一个 typedef 映射表,实现带字段名的实时解码。还可以把解码结果写入 SQLite 或 JSON 文件供后续批量分析。

八、离线解码 Protobuf 数据

拿到了 Protobuf 二进制数据之后(无论来自 Frida 保存的 .bin、抓包工具导出、还是手动复制的 hex),下一步就是把它解码成可读结构。根据手头信息的完整程度(有无 .proto 文件),方法从「裸解码」到「精确解码」有多个层次。

8.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
  • 无法区分 string / bytes / embedded message——它们都是 Wire Type 2
  • 不知道字段名,只有 field number(1: 150 而不是 id: 150
  • 无法识别 repeated 字段——相同 field number 的多次出现会被分别显示
  • 无法识别 packed repeated 字段——packed 数据会被当作一整段 bytes 显示

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

Length-delimited 字段递归解码

8.2 Python 递归裸解码

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

from google.protobuf.internal.decoder import _DecodeVarint
from google.protobuf.internal.wire_format import (
    WIRETYPE_VARINT,
    WIRETYPE_FIXED64,
    WIRETYPE_LENGTH_DELIMITED,
    WIRETYPE_FIXED32,
)
import struct
from typing import Any


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

    while pos < len(data):
        tag, new_pos = _DecodeVarint(data, pos)
        field_number = tag >> 3
        wire_type = tag & 0x07
        pos = new_pos

        if wire_type == WIRETYPE_VARINT:
            value, pos = _DecodeVarint(data, pos)
            results.append({
                'field': field_number,
                'wire_type''varint',
                'value': value,
                'zigzag': (value >> 1^ -(value & 1),
                'as_bool'bool(value) if value in (01else None,
            })

        elif wire_type == WIRETYPE_FIXED64:
            raw = data[pos:pos + 8]
            value = struct.unpack('<q', raw)[0]
            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:
            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:
            raw = data[pos:pos + 4]
            value = struct.unpack('<i', raw)[0]
            float_value = struct.unpack('<f', raw)[0]
            pos += 4
            results.append({
                'field': field_number,
                'wire_type''fixed32',
                'value': value,
                'as_float': float_value,
            })
        else:
            break

    return results


# 使用示例
if __name__ == '__main__':
    data = bytes.fromhex("089601120774657374696e67")
    for field in decode_protobuf_raw(data):
        print(field)

8.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'}

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

# 第二步: 手动修正类型定义
typedef['2']['type'= 'string'
typedef['2']['name'= 'username'

# 第三步: 用修正后的类型定义重新解码
message, _ = blackboxprotobuf.decode_message(data, typedef)
print(message)
# {'1': 150, 'username': 'testing'}

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

进阶用法:blackboxprotobuf 支持将 typedef 保存为 JSON 文件,方便在多次分析间复用。

8.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。

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篇)。

# decode_grpc.py
# 用法: mitmproxy -s decode_grpc.py -p 8080
# 依赖: pip install blackboxprotobuf mitmproxy
import gzip
import blackboxprotobuf
from mitmproxy import http


def _strip_grpc_frames(body: bytes):
    """gRPC body 可能有多个连续帧,逐帧解析。"""
    offset = 0
    while offset + 5 <= len(body):
        compressed = body[offset]
        length = int.from_bytes(body[offset + 1:offset + 5], "big")
        payload = body[offset + 5:offset + 5 + length]
        if compressed:
            payload = gzip.decompress(payload)
        yield payload
        offset += 5 + length


class GrpcDecoder:
    def response(self, flow: http.HTTPFlow):
        ct = flow.request.headers.get("content-type""")
        if "grpc" not in ct:
            return
        print(f"\n{'=' * 60}\ngRPC: {flow.request.path}")
        for label, body in [("请求", flow.request.content),
                            ("响应", flow.response.content if flow.response else b"")]:
            if not body:
                continue
            for payload in _strip_grpc_frames(body):
                try:
                    decoded, _ = blackboxprotobuf.decode_message(payload)
                    print(f"\n{label}: {decoded}")
                except Exception as e:
                    print(f"{label} 解码失败: {e}")
        print(f"{'=' * 60}\n")


addons = [GrpcDecoder()]
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 类

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

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

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

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

9.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;
    }
    if (!name_.isEmpty()) {
        output.writeString(2, name_);         // → string name = 2;
    }
    if (!email_.isEmpty()) {
        output.writeString(3, email_);        // → string email = 3;
    }
    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;
    }
    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;
  Status status = 6;
}

9.1.3 writeTo 中的类型映射表

CodedOutputStream 方法Proto 类型Wire Type说明
writeInt32(n, v)int320 (Varint)负数会占 10 字节
writeInt64(n, v)int640 (Varint)负数会占 10 字节
writeUInt32(n, v)uint320 (Varint)无符号
writeUInt64(n, v)uint640 (Varint)无符号
writeSInt32(n, v)sint320 (Varint)ZigZag 编码
writeSInt64(n, v)sint640 (Varint)ZigZag 编码
writeFixed32(n, v)fixed325 (32-bit)固定 4 字节
writeFixed64(n, v)fixed641 (64-bit)固定 8 字节
writeSFixed32(n, v)sfixed325 (32-bit)有符号固定 4 字节
writeSFixed64(n, v)sfixed641 (64-bit)有符号固定 8 字节
writeFloat(n, v)float5 (32-bit)IEEE 754 单精度
writeDouble(n, v)double1 (64-bit)IEEE 754 双精度
writeBool(n, v)bool0 (Varint)值只有 0 和 1
writeString(n, v)string2 (LEN)UTF-8 编码
writeBytes(n, v)bytes2 (LEN)原始字节
writeMessage(n, v)嵌套 message2 (LEN)递归编码
writeEnum(n, v)enum0 (Varint)值为枚举的整数值

9.1.4 识别 Enum

public enum Status implements com.google.protobuf.ProtocolMessageEnum {
    UNKNOWN(0),      // protobuf enum 的第一个值必须是 0
    ACTIVE(1),
    INACTIVE(2),
    BANNED(3);

    private final int value;
    public final int getNumber() { return value; }
    public static Status forNumber(int value) { ... }
}

还原:

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

9.1.5 识别 OneOf

// 1. 一个 xxxCase_ int 字段
private int payloadCase_ = 0;
// 2. 一个 Object 字段存储当前活跃分支的值
private Object payload_;

// 3. 一个 Case enum 列出所有分支
public enum PayloadCase {
    TEXT(1),
    IMAGE(2),
    VIDEO(3),
    PAYLOAD_NOT_SET(0);
}

还原:

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

9.1.6 识别 Map

private MapField<String, Integer> tags_;

public Map<String, Integer> getTagsMap() { ... }
public int getTagsCount() { ... }
public boolean containsTags(String key) { ... }

还原:

message Foo {
  map<stringint32tags = 7;
}

Wire Format 层面map<K, V> field = N 实际上等价于 repeated MapEntry field = N,其中 MapEntry 是一个隐含的嵌套 message { K key = 1; V value = 2; }

9.2 处理混淆代码

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

// 混淆后的 writeTo 方法
public void a(CodedOutputStream var1) throws IOException {
    // writeXxx 方法名属于 protobuf 库, 不会被混淆!
    if (this.a != 0) {
        var1.writeInt32(1this.a);      // field 1: int32 (确定)
    }
    if (!this.b.isEmpty()) {
        var1.writeString(2this.b);      // field 2: string (确定)
    }
    if (this.c != null) {
        var1.writeMessage(3this.c);     // field 3: message
    }
}

混淆代码的还原技巧

  1. 搜索 CodedOutputStream 的调用:protobuf 库本身通常不被混淆
  2. 搜索 writeXxx 方法调用:方法名是 protobuf 库的 API,不会被混淆
  3. 搜索 FIELD_NUMBER 常量:常量值不会被混淆
  4. 搜索 parseFrom 方法:签名特征不变
  5. 利用 getDescriptor():如果是完整版 protobuf-java,descriptor 包含完整的 schema 信息

9.3 从 Descriptor 还原(完整版 protobuf-java)

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

static {
    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" + ...
    };
}

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

from google.protobuf import descriptor_pb2

descriptor_data = open("descriptor.bin""rb").read()

file_desc = descriptor_pb2.FileDescriptorProto()
file_desc.ParseFromString(descriptor_data)

# 注意:file_desc.syntax 本身就是字符串 "proto2" / "proto3" / "editions",
# 直接拼接即可;为空时按 proto2 兜底(proto2 时 syntax 字段常被省略)。
syntax = file_desc.syntax or "proto2"
print(f'syntax = "{syntax}";')
if file_desc.package:
    print(f'package {file_desc.package};')

for dep in file_desc.dependency:
    print(f'import "{dep}";')

for msg in file_desc.message_type:
    print(f'\nmessage {msg.name}{{')
    for field in msg.field:
        type_name = descriptor_pb2.FieldDescriptorProto.Type.Name(field.type)
        type_str = type_name.lower().replace("type_""")
        label = descriptor_pb2.FieldDescriptorProto.Label.Name(field.label)
        label_str = label.lower().replace("label_""")

        if field.type in (1114):  # TYPE_MESSAGE=11, TYPE_ENUM=14
            type_str = field.type_name.lstrip('.')

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

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 体积)。判断方法:在反编译代码中搜索 FileDescriptorDescriptorProto,如果找到则说明是完整版。

9.4 PBTK 自动化还原

PBTK (Protobuf Toolkit) 可以自动从 APK 中提取 .proto 文件定义:

git clone https://github.com/marin-m/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 方法。

9.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
# field 3: 小整数且不递增 → 可能是 enum 或 age

_, typedef = blackboxprotobuf.decode_message(samples[0])
typedef['1']['name'= 'user_id'
typedef['2']['type'= 'string'
typedef['2']['name'= 'name'
typedef['3']['name'= 'level'

盲猜还原的经验法则

  • 值为 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 源文件:

# 使用 protoc 从描述符文件中解码特定消息
protoc --descriptor_set_in=descriptors.desc --decode=package.MessageName

# 利用 descriptor.proto 自描述反编译描述符
protoc --decode=google.protobuf.FileDescriptorSet \
  google/protobuf/descriptor.proto < descriptors.desc

获取 .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

public final class UserServiceGrpc {
    private static final String SERVICE_NAME = "com.example.api.UserService";

    public static final MethodDescriptor<LoginRequest, LoginResponse> getLoginMethod() { ... }
    public static final MethodDescriptor<LogoutRequest, LogoutResponse> getLogoutMethod() { ... }
    public static final MethodDescriptor<ProfileRequest, UserProfile> getGetProfileMethod() { ... }
}

从这段代码可以还原出:

service UserService {
  rpc Login(LoginRequest) returns (LoginResponse);
  rpc Logout(LogoutRequest) returns (LogoutResponse);
  rpc GetProfile(ProfileRequest) returns (UserProfile);
}

每个 MethodDescriptor 的泛型参数直接对应请求和响应的 Message 类型;方法名去掉 get/Method 后缀即 RPC 名。

9.8 grpcurl 探测开启 Reflection 的服务端

部分企业内部环境、测试服或调试残留可能开启了 gRPC Reflection——这是最省力的还原路径:

# 列出所有服务
grpcurl -plaintext api.example.com:443 list

# 列出服务的所有方法
grpcurl -plaintext api.example.com:443 list com.example.api.UserService

# 描述 service 结构
grpcurl -plaintext api.example.com:443 describe com.example.api.UserService

# 描述 message 结构
grpcurl -plaintext api.example.com:443 describe com.example.api.LoginRequest

如果能拉到 service / message 描述,逆向工作量大幅缩减。先试一下没坏处。

十、常见对抗与绕过

在实际逆向中,应用可能采取各种措施来增加 Protobuf 分析的难度。以下是常见的对抗手段及对应的绕过策略。

10.1 自定义序列化

部分应用不使用标准 protobuf 库(com.google.protobuf.*),而是自行实现 Protobuf 的编解码逻辑:

// 自定义的轻量 protobuf 编码 (不依赖 Google protobuf 库)
public byte[] encode() {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    // 手动构造 Tag: (field_number << 3) | wire_type
    writeVarint(bos, (1 << 3| 0);  // field 1, wire_type=0 (Varint)
    writeVarint(bos, this.userId);
    writeVarint(bos, (2 << 3| 2);  // field 2, wire_type=2 (Length-delimited)
    writeBytes(bos, this.name.getBytes("UTF-8"));
    return bos.toByteArray();
}

绕过策略

Wire Format 编码规范是公开标准,自定义实现必须遵循同样的编码规则(否则服务端无法解码)。因此:

  • 搜索代码中的 writeVarint<< 3& 0x07 等特征操作,定位自定义编码逻辑
  • 数据层面完全不变,protoc --decode_raw 仍然能正常解码
  • Hook 自定义的 encode() / decode() 方法即可捕获数据

10.2 外层加密 / 压缩

很多应用会在 Protobuf 序列化之后、发送之前,对数据进行压缩和/或加密:

数据流: [原始 protobuf] → [gzip/zstd 压缩] → [AES/ChaCha20 加密] → [网络发送]
解码流: [网络接收] → [解密] → [解压] → [protobuf 反序列化]

绕过策略——关键思路是找到加密前/解密后的节点进行 Hook:

  1. Hook protobuf 层(最可靠):在 writeTo / toByteArray / parseFrom 层面 Hook——此时数据一定是明文 protobuf,无论外层套了多少层加密压缩
  2. Hook 压缩层:Hook GZIPOutputStream.write() / GZIPInputStream.read() 捕获压缩前/解压后的数据
  3. Hook 加密层:Hook Cipher.doFinal() 捕获加密前/解密后的数据
  4. 逐层剥离:如果不确定加密/压缩的具体实现,可以从网络层(OkHttp Interceptor)开始,逐步向内层 Hook,直到拿到可被 protoc --decode_raw 成功解码的数据

10.3 字段名混淆

部分代码混淆工具会对 .proto 中的字段名进行混淆(将有意义的字段名替换为 abc),但 field number 和 wire type 无法被混淆——它们是编码在二进制数据中的,改变它们会导致服务端无法解码:

// 混淆前
message UserInfo {
  string username = 1;
  int32 age = 2;
}

// 混淆后 (字段名被替换, 但 field number 不变)
message a {
  string a = 1;
  int32 b = 2;
}

对逆向的影响

  • 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.DescriptorsFileDescriptorgetDescriptor() 等类和方法
  • Lite 版:只有 com.google.protobuf.GeneratedMessageLite,不包含 Descriptor 相关类
  • Nano 版:使用 com.google.protobuf.nano.MessageNano 基类(已废弃,但存量应用仍在)

10.5 Native 层 Protobuf

当 Protobuf 逻辑在 .so 文件中实现(C++ protobuf 库)时,Java 层的 Hook 方法不再适用,需要转向 native 层。

静态分析

# 搜索动态符号表中的 protobuf 相关符号
nm -D libnative.so | grep -i protobuf
readelf -s libnative.so | grep -i protobuf

# 在 IDA Pro / Ghidra 中搜索的关键符号:
# google::protobuf::MessageLite::SerializeToString
# google::protobuf::MessageLite::ParseFromString
# google::protobuf::io::CodedOutputStream::WriteTag
# google::protobuf::io::CodedInputStream::ReadTag

Frida Hook native 层:直接解析 std::string 内部结构很脆弱——libc++ 有 SSO 短串优化(默认布局是 [capacity, size, data*],alternate ABI 又变成 [data*, size, capacity])、libstdc++ 又是完全不同的引用计数式布局,跨版本/跨编译器经常翻车。推荐改 Hook 一个返回裸字节指针的函数,从源头绕开 std::string 的内存布局问题:

// hook_native_protobuf.js
// 推荐方案: Hook MessageLite::SerializeWithCachedSizesToArray(uint8_t* target)
// 该函数直接把序列化后的字节写入调用方提供的缓冲区, 返回写入末尾指针
// 配合 ByteSizeLong() 拿长度, 完全不依赖 std::string 内存布局
var serializeSym =
    "_ZNK6google8protobuf11MessageLite32SerializeWithCachedSizesToArrayEPh";
var byteSizeSym =
    "_ZNK6google8protobuf11MessageLite12ByteSizeLongEv";

var byteSizeFn = new NativeFunction(
    Module.findExportByName("libnative.so", byteSizeSym),
    "size_t", ["pointer"]);

Interceptor.attach(Module.findExportByName("libnative.so", serializeSym), {
    onEnter: function(args) {
        this.thisPtr = args[0];        // MessageLite*
        this.bufStart = args[1];       // uint8_t* target
        this.size = byteSizeFn(this.thisPtr).toNumber();
    },
    onLeave: function(retval) {
        if (this.size <= 0 || this.size > 10 * 1024 * 1024return;
        var buf = Memory.readByteArray(this.bufStart, this.size);
        console.log("[Native PB] size=" + this.size);
        console.log(hexdump(buf, { length: Math.min(this.size, 256) }));

        var path = "/data/local/tmp/native_pb_" + Date.now() + ".bin";
        var file = new File(path, "wb");
        file.write(buf);
        file.flush();
        file.close();
    }
});

C++ 符号名查找技巧:如果 .so 文件没有被 strip,可以用 nm -D 直接搜索符号。如果被 strip 了,可以在 IDA/Ghidra 中通过字符串交叉引用(如 "SerializeWithCachedSizes" 错误信息)来定位函数。另外,c++filt 工具可以将 mangled name(如 _ZN6google8protobuf...)还原为可读的 C++ 签名。

如果只能 Hook SerializeToString(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 但不走 gRPCHook toByteArray / parseFrom§七
需要精确到字段的 HookHook 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 层 protobufHook SerializeToString 等 mangled name§10.5

11.2 实战 Cheat Sheet

以下是日常逆向中最常用的命令和代码片段,建议收藏备用:

# ============================================================
# 1. 快速判断抓包数据是否是 protobuf
# ============================================================
echo -n "YOUR_HEX_DATA" | xxd -r -p | protoc --decode_raw

# ============================================================
# 2. Base64 编码的 protobuf 解码
# ============================================================
echo "BASE64_DATA" | base64 -d | protoc --decode_raw

# ============================================================
# 3. gRPC 数据解码 (跳过前 5 字节的 gRPC 帧头)
# ============================================================
dd if=grpc_body.bin bs=1 skip=5 | protoc --decode_raw

# ============================================================
# 4. 搜索 APK 中的 protobuf 类 (jadx 反编译后)
# ============================================================
jadx -d output/ target.apk
grep -r "GeneratedMessageLite\|GeneratedMessageV3\|FIELD_NUMBER" output/

# ============================================================
# 5. 搜索 APK 中残留的 .proto / descriptor 文件
# ============================================================
unzip -l target.apk | grep -iE "\.proto$|\.desc$|\.pb$"

# ============================================================
# 6. 使用 blackboxprotobuf 快速解码二进制文件
# ============================================================
python3 -c "
import blackboxprotobuf, sys
data = open(sys.argv[1], 'rb').read()
msg, td = blackboxprotobuf.decode_message(data)
print(msg)
"
 captured.bin

# ============================================================
# 7. Frida 一键 Hook protobuf (附加到目标进程)
# ============================================================
frida -U -l hook_protobuf_writeto.js com.target.app

# ============================================================
# 8. 从 Frida 保存的 .bin 文件批量解码
# ============================================================
for f in /data/local/tmp/pb_*.bin; do
    echo "=== $f ==="
    protoc --decode_raw < "$f"
    echo ""
done

# ============================================================
# 9. 使用 grpcurl 探测 gRPC 服务 (需服务端开启 Reflection)
# ============================================================
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 list com.example.UserService
grpcurl -plaintext localhost:50051 describe com.example.UserRequest

# ============================================================
# 10. 将 .desc 描述符文件反编译为 .proto
# ============================================================
protoc --descriptor_set_in=descriptors.desc \
  --decode=google.protobuf.FileDescriptorSet \
  google/protobuf/descriptor.proto

11.3 工具速查

解码工具

工具用途安装方式适用场景
protoc --decode_raw命令行裸解码brew install protobuf快速验证数据是否为 protobuf
blackboxprotobufPython 交互式解码pip install blackboxprotobuf逐步还原字段类型
protobuf-inspector彩色层级化输出pip install protobuf-inspector快速浏览嵌套结构
PBTKAPK 自动提取 .protogit clone from GitHub完整版 protobuf-java

动态分析工具

工具用途说明
FridaHook 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 的编码格式。掌握了这一点,剩下的工作就是选择合适的拦截点和工具。

本篇覆盖的完整方法论:

  1. 认知层(§一-§二):理解 Protobuf 为何高效、Wire Format 的 Tag+Value 编码结构、Varint 和 ZigZag 编码——这是一切后续操作的理论依据。
  2. 识别层(§三):从 APK 类名到 HTTP 流量特征,多维度快速判断目标应用是否使用 Protobuf / gRPC,特别注意 gRPC 的 5 字节帧头。
  3. gRPC 框架层 Hook(§四-§六):Hook io.grpc.stub.ClientCalls 覆盖四种调用模式;用 Java.registerClass 创建代理 Observer 处理异步回调;用 Java.choose 而非全量遍历找 ClientInterceptor
  4. Protobuf 序列化层 Hook(§七):当 App 不走标准 gRPC 时的通用方案——toByteArray 拦截序列化、parseFrom 拦截反序列化、CodedOutputStream.writeXxx 字段级精细拦截,配合 Frida + PC 端实时解码联动。
  5. 解码层(§八):从 protoc --decode_raw 到 blackboxprotobuf 交互式类型修正,从 protobuf-inspector 可视化输出到 mitmproxy 插件,根据场景选用合适工具。
  6. 还原层(§九):六种方法按可靠性排序——writeTo 反推(最精确)、处理混淆代码、提取 Descriptor、PBTK 自动化、多样本盲猜、.desc 反编译;gRPC 还有 service 还原和 grpcurl 探测。
  7. 对抗层(§十):自定义序列化、外层加密、字段混淆、无 Descriptor、Native 层实现——每种对抗手段都有对应的绕过思路。

最新文章

随机文章

基本 文件 流程 错误 SQL 调试
  1. 请求信息 : 2026-05-19 12:34:27 HTTP/2.0 GET : https://67808.cn/a/489679.html
  2. 运行时间 : 0.143929s [ 吞吐率:6.95req/s ] 内存消耗:4,716.93kb 文件加载:140
  3. 缓存信息 : 0 reads,0 writes
  4. 会话信息 : SESSION_ID=2fc042ab5a6d279b6d4722b00c8e27be
  1. /yingpanguazai/ssd/ssd1/www/no.67808.cn/public/index.php ( 0.79 KB )
  2. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/autoload.php ( 0.17 KB )
  3. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/composer/autoload_real.php ( 2.49 KB )
  4. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/composer/platform_check.php ( 0.90 KB )
  5. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/composer/ClassLoader.php ( 14.03 KB )
  6. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/composer/autoload_static.php ( 4.90 KB )
  7. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
  8. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
  9. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
  10. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
  11. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
  12. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
  13. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
  14. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
  15. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
  16. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
  17. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
  18. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
  19. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
  20. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
  21. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
  22. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/provider.php ( 0.19 KB )
  23. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
  24. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
  25. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
  26. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/common.php ( 0.03 KB )
  27. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/helper.php ( 18.78 KB )
  28. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
  29. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/app.php ( 0.95 KB )
  30. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/cache.php ( 0.78 KB )
  31. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/console.php ( 0.23 KB )
  32. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/cookie.php ( 0.56 KB )
  33. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/database.php ( 2.48 KB )
  34. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
  35. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/filesystem.php ( 0.61 KB )
  36. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/lang.php ( 0.91 KB )
  37. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/log.php ( 1.35 KB )
  38. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/middleware.php ( 0.19 KB )
  39. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/route.php ( 1.89 KB )
  40. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/session.php ( 0.57 KB )
  41. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/trace.php ( 0.34 KB )
  42. /yingpanguazai/ssd/ssd1/www/no.67808.cn/config/view.php ( 0.82 KB )
  43. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/event.php ( 0.25 KB )
  44. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
  45. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/service.php ( 0.13 KB )
  46. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/AppService.php ( 0.26 KB )
  47. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
  48. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
  49. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
  50. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
  51. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
  52. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/services.php ( 0.14 KB )
  53. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
  54. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
  55. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
  56. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
  57. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
  58. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
  59. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
  60. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
  61. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
  62. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
  63. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
  64. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
  65. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
  66. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
  67. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
  68. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
  69. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
  70. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
  71. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
  72. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
  73. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
  74. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
  75. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
  76. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
  77. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
  78. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
  79. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
  80. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
  81. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
  82. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
  83. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/Request.php ( 0.09 KB )
  84. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
  85. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/middleware.php ( 0.25 KB )
  86. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
  87. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
  88. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
  89. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
  90. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
  91. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
  92. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
  93. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
  94. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
  95. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
  96. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
  97. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
  98. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
  99. /yingpanguazai/ssd/ssd1/www/no.67808.cn/route/app.php ( 1.72 KB )
  100. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
  101. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
  102. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
  103. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/controller/Index.php ( 4.81 KB )
  104. /yingpanguazai/ssd/ssd1/www/no.67808.cn/app/BaseController.php ( 2.05 KB )
  105. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
  106. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
  107. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
  108. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
  109. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
  110. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
  111. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
  112. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
  113. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
  114. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
  115. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
  116. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
  117. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
  118. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
  119. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
  120. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
  121. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
  122. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
  123. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
  124. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
  125. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
  126. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
  127. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
  128. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
  129. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
  130. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
  131. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
  132. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
  133. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
  134. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
  135. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
  136. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
  137. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
  138. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
  139. /yingpanguazai/ssd/ssd1/www/no.67808.cn/runtime/temp/6df755f970a38e704c5414acbc6e8bcd.php ( 12.06 KB )
  140. /yingpanguazai/ssd/ssd1/www/no.67808.cn/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
  1. CONNECT:[ UseTime:0.000833s ] mysql:host=127.0.0.1;port=3306;dbname=no_67808;charset=utf8mb4
  2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.000842s ]
  3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000289s ]
  4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.000280s ]
  5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.000491s ]
  6. SELECT * FROM `set` [ RunTime:0.000201s ]
  7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.000548s ]
  8. SELECT * FROM `article` WHERE `id` = 489679 LIMIT 1 [ RunTime:0.000683s ]
  9. UPDATE `article` SET `lasttime` = 1779165267 WHERE `id` = 489679 [ RunTime:0.003987s ]
  10. SELECT * FROM `fenlei` WHERE `id` = 65 LIMIT 1 [ RunTime:0.000233s ]
  11. SELECT * FROM `article` WHERE `id` < 489679 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.000399s ]
  12. SELECT * FROM `article` WHERE `id` > 489679 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.000341s ]
  13. SELECT * FROM `article` WHERE `id` < 489679 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.001563s ]
  14. SELECT * FROM `article` WHERE `id` < 489679 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.010699s ]
  15. SELECT * FROM `article` WHERE `id` < 489679 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.008924s ]
0.145561s