rea-reverse

没有 .proto 怎么解 protobuf:wire type、varint 与 gRPC 帧

发布者 rea-reverse更新于 约 7 分钟读完
本页目录

抓到自己应用的一个请求,Content-Type 是 application/x-protobuf 或 application/grpc-web+proto,body 是一串二进制,而对应的 .proto 在另一个仓库里,或者干脆找不到了。这篇讲清楚三件事:光凭字节能还原出什么;哪些地方只能靠猜;protoc --decode_raw 和本站的 protobuf 解码工具在哪些地方结果一致、在哪些地方不同。

我们没有空讲编码格式,而是自己写了一个 schema,用 protoc 编码一条消息,然后假装 schema 丢了,从字节往回读。下面所有输出都来自这次实测。

实测环境: macOS 27.0.1(arm64),protoc 用 Homebrew 装(brew install protobuf,protoc --version 输出 libprotoc 36.2),Node 22.22.3 用来直接跑本站仓库里的解码代码。装不了 protoc 也没关系,浏览器工具的文本视图输出和它一样,只用工具也能跟着做。

编码的是什么

schema 是随手编的“阅读统计”请求,故意把没有 schema 时最容易读错的类型都放了进去:

syntax = "proto3";
package demo.reading;

message Book {
  string title = 1;
  uint32 pages = 2;
  Status status = 3;
  bytes isbn13 = 4;
}
enum Status { STATUS_UNSPECIFIED = 0; TODO = 1; READING = 2; DONE = 3; }

message StatsRequest {
  string user = 1;
  repeated Book books = 2;
  sint32 timezone_offset_min = 3;
  int32 balance_delta = 4;
  repeated uint32 daily_pages = 5;
  double avg_rating = 6;
  fixed32 client_build = 7;
  map<string, uint32> tags = 8;
  string note = 9;
}

取值用 protobuf 文本格式写在 req.txtpb 里:

user: "ada"
books { title: "Crafting Interpreters" pages: 640 status: READING isbn13: "\x09\x78\x09\x90\x88\x89\x07" }
books { title: "SICP" pages: 657 status: DONE }
timezone_offset_min: -300
balance_delta: -1
daily_pages: [12, 0, 35, 300]
avg_rating: 4.5
client_build: 140
tags { key: "cs" value: 2 }
note: "hi"

有 schema 时,protoc --encode 能把文本格式转成二进制:

protoc --encode=demo.reading.StatsRequest reading.proto < req.txtpb > req.bin
wc -c req.bin     # 104 req.bin

一共 104 字节。从这里开始,当 reading.proto 不存在。

不给 schema,protoc 打印出什么

protoc --decode_raw < req.bin
1: "ada"
2 {
  1: "Crafting Interpreters"
  2: 640
  3: 2
  4: "\tx\t\220\210\211\007"
}
2 {
  1: "SICP"
  2: 657
  3: 3
}
3: 599
4: 18446744073709551615
5: "\014\000#\254\002"
6: 0x4012000000000000
7: 0x0000008c
8 {
  1: "cs"
  2: 2
}
9 {
  13: 105
}

有几行是对的,另外几行错得很像样:不知道原值的话,很容易就信了。原因都在于 wire format 记录的信息实在太少。

字节里到底记了什么

每个字段开头是一个 tag varint,值为 字段号 << 3 | wire type。这里要关心的 wire type 只有四种:

wire type 含义 schema 里可能是
0 varint int32、int64、uint32、uint64、sint32、sint64、bool、enum
1 8 字节 fixed64、sfixed64、double
2 长度 + 内容 string、bytes、嵌套消息、packed 的 repeated 标量、map 条目
5 4 字节 fixed32、sfixed32、float

(3 和 4 是 proto2 里已废弃的 group。)req.bin 开头是 0a 03 61 64 61:tag 0x0a 表示字段 1、wire type 2,长度 3,内容 ada。解码器能拿到的就这么多:没有字段名,没有类型,只知道“字段 1 里有 3 个字节”。字段名和枚举名根本不会写进字节,所以 status: READING 读回来只剩 3: 2。

varint:无符号、补码,还是 zigzag?

字段 3 打印成了 599,真实值是 sint32 类型的 -300。sint 用 zigzag 编码,把 0、-1、1、-2…… 依次映射成 0、1、2、3……,于是 -300 变成了 599。protoc 没法知道这一点,只能按无符号数打印。

字段 4 打印成了 18446744073709551615,其实是 int32 的 -1。普通的 int32/int64 用 64 位补码存负数,所以 -1 在线上要占 10 个字节(十六进制里那段 20 ff ff ff ff ff ff ff ff ff 01,含 tag),读回来就是 2^64 - 1。

经验是:特别大的 varint 多半是一个小负数的 int32/int64;一个看起来不大的奇数,可能是负的 sint。两种猜测都不保证对。我们用本站解码器的代码跑同一份字节,它的树形视图对每个 varint 都给出三种读法,这两个字段在脚本日志里是:

3 varint 599n  int64 599n                  zigzag -300n
4 varint 18446744073709551615n  int64 -1n  zigzag -9223372036854775808n

选哪个,取决于你的接口语义:时区偏移 -300 分钟说得通,599 分钟说不通。

定长字段:整数还是浮点?

字段 6 打印成 0x4012000000000000,按小端 IEEE 754 double 读就是我们设的评分 4.5。字段 7 是 0x0000008c,整数 140。protoc 两个都打十六进制,因为同一段 64 位既可以是 fixed64,也可以是 sfixed64 或 double。这次管用的判断方法:如果“整数”大得离谱、低位一串 0 字节,就按浮点数试一下。

length-delimited:真正的歧义在这里

wire type 2 是猜测最多的地方。protoc 会先把每个 length-delimited 值当嵌套消息解析,能完整解析成字段就打印花括号,否则打印成带引号的字符串。我们这条消息把几种情况都碰到了:

  • 字段 2(books) 解析成消息,它确实是消息。
  • 字段 2.4(isbn13,bytes) 第一个字节是 0x09,按 tag 读是字段 1、wire type 1,后面应该跟 8 个字节,可只剩 6 个。解析失败,回退成字符串。结果对了,但纯属运气。
  • 字段 5(daily_pages) 打印成 "\014\000#\254\002"。proto3 里 repeated uint32 默认是 packed:所有值放在同一个 length-delimited 字段里,中间没有 tag。0c 00 23 ac 02 其实是 12、0、35、300 四个 varint。protoc 从不尝试“packed”这种读法,因为一般情况下根本分不清 packed varint 和字符串。本站工具的树形视图把 packed 读法和其他读法并列给出,这个字段显示的是 [12n, 0n, 35n, 300n]。
  • 字段 8(tags) 打印成含字段 1、2 的消息。map<string, uint32> 的条目就是这样编码的:key 是字段 1,value 是字段 2 的嵌套消息。看到成串重复的 { 1: "…" 2: … },就往 map 上想。
  • 字段 9(note) 是字符串 "hi",protoc 却打印成 9 { 13: 105 }。0x68(h)恰好是合法的 tag(字段 13、varint),0x69(i)是合法的值。任何短的 ASCII 字符串都可能这样被误读。树形视图里可以切到 UTF-8 读法看到 "hi"。

packed 读法在没意义的时候也会列出来:我们的解码器照样能把 "ada" 读成 varint [97, 100, 97]。列表里每种读法都只表示“字节允许这样读”,不代表字段就是那个类型。要定下来,得多看几次抓包:每次都能干净解成 UTF-8 的是字符串;在界面上往列表里加一项、长度跟着变的,多半是 packed。

gRPC 的 body:先去掉 5 字节帧头

gRPC 和 gRPC-Web 的 body 不是裸的 protobuf。每条消息前面有 1 字节标志位和 4 字节大端长度。我们用 req.bin 拼了一个这样的帧(标志 0x00,长度 0x68 即 104):

00000000: 0000 0000 680a 0361 6461 1225 0a15 4372  ....h..ada.%..Cr

直接丢给 protoc 会失败:

protoc --decode_raw < req.grpc
# Failed to parse input.
# 退出码 1

开头的 0x00 不是合法 tag(字段号为 0),protoc 直接拒绝整段输入,什么也不打印。去掉前 5 个字节就好了:

tail -c +6 req.grpc | protoc --decode_raw   # 和上面的输出完全一致

这只适用于一个未压缩的帧。流式响应里是好几个帧首尾相接;gRPC-Web 响应最后还有一个 trailers 帧(标志 0x80);压缩过的消息标志是 0x01、payload 是 gzip。这时候一个 tail 就不够用了。我们拼了一个 gRPC-Web body:一个 gzip 压缩的消息帧(gzip 后 112 字节)加一个 trailers 帧,作为文件交给本站解码器的代码,结果如下(从脚本日志整理):

frame 0: flag 1, length 112, compressed (gzip)  -> decoded, same text as req.bin
frame 1: flag 128, length 32, trailers          -> "grpc-status: 0\r\ngrpc-message: \r\n"

在工具里关掉帧检测,它就把带帧头的字节当成一条消息来解,在偏移 0 处停下,报“本该是字段 tag 的位置出现 0x00”,和 protoc 放弃的原因一样。

base64 和十六进制输入

DevTools 和日志很少直接给你二进制文件。gRPC-Web-text 的 body 是 base64,不少应用也会把 protobuf 塞进 base64 的请求头或 JSON 字符串里。用 protoc 得先自己解码:

base64 -i req.bin
# CgNhZGESJQoVQ3JhZnRpbmcgSW50ZXJwcmV0ZXJzEIAFGAIiBwl4CZCIiQcSCwoEU0lDUBCRBRgDGNcE…
base64 -D -i req.b64 -o fromb64.bin && protoc --decode_raw < fromb64.bin

(-D 是 macOS 的写法,GNU coreutils 用 -d。)浏览器工具能直接吃十六进制、标准 base64 和 URL-safe base64。对这条 104 字节的消息,三种输入打印的文本都和 protoc --decode_raw 一字不差。

测试时踩到一个坑:工具会自动判断编码,只由十六进制字符组成的文本按十六进制读。所以 0801 被当成十六进制 08 01,也就是 1: 1。可 0801 同时也是合法的 base64,对应另外 3 个字节。我们手动选 Base64 再解同一段文本,得到的是一条残缺消息(偏移 3 处 GROUP_MISMATCH),看到这种结果就该意识到编码选错了。如果你的 base64 恰好只含 0-9a-f,请手动指定编码。

protoc 拒绝时,仍然想看到字段

我们在 req.bin 后面补了 3 个 0x00,抓包工具填充缓冲区时就会出现这种尾巴:

protoc --decode_raw < padded.bin
# Failed to parse input.

protoc 什么都不输出,连填充之前那 10 个好好的字段也不给。本站解码器会打印这 10 个字段,再报告偏移 104 处 ZERO_TAG,正好告诉你 protobuf 在哪里结束。抓包被截断时也一样:你能拿到合法的前缀,以及解析停下的偏移。

反推出一份 schema

有了原始解码结果,写一份草稿 .proto 基本就是记账:

  1. 对同一个接口抓三四次,最好每次只改一个你能控制的值(比如在应用里改一项设置)。
  2. 按字段号记下 wire type,以及哪种读法说得通:无符号、zigzag、补码、double、字符串、packed。
  3. 把这些整理成 .proto,然后验证。protoc --decode=demo.reading.StatsRequest reading.proto < req.bin 应该打出字段名和合理的值。我们把 schema 放回去后,它打印出 timezone_offset_min: -300、balance_delta: -1、daily_pages: 12 …… 300 和 status: READING,上面每一个猜测都得到了印证。

字段号在应用升级时不会变(变了就不兼容),所以这样反推出来的 schema 可以一直用,直到接口加了新字段。

REA 在哪一步

REA(morluto 的开源项目)可以通过 inspect_web_network_capture 读取保存下来的 HAR 和 Fiddler .saz 抓包(见 HAR 文件分析指南)。6.7.0 版 npm 包里,SAZ 读取器自己写明了限制:“WebSocket and gRPC members retain opaque message-file bytes, not decoded protocol events.”,也就是 gRPC 内容只保留原始字节,不做协议解码。所以 REA 负责找到请求、交给你 body 字节,里面的 protobuf 怎么解,就是本文这一步。从抓到的请求一路追到构造它的前端代码,可以看追踪网站 API 调用。

只抓取和解码你自己拥有或已获授权测试的应用与服务的流量,参见免责声明。

不靠 schema 解码的局限

  • 字段名、枚举名,以及 string 和 bytes 的区别,丢了就是丢了,只有 schema 里有。
  • proto3 不会把默认值写进字节。值为 0、"" 或 false 的字段直接不出现,所以没见过的字段号不代表不存在。
  • 嵌套超过 10 层的消息,protoc --decode_raw 会打印成字符串,浏览器工具也照这个规则来,保证文本视图和 protoc 一致。
  • 工具只解压 gzip 和 deflate 帧,snappy 或 zstd 帧只标出来,不解码。

下一步:如果这个 protobuf body 来自一个压缩或混淆过的 Web 应用,想找到构造它的代码,可以看 javascript-obfuscator 混淆代码的还原实测,或者 JS 逆向实战:还原 webpack 打包后的源码。