没有 .proto 怎么解 protobuf:wire type、varint 与 gRPC 帧
本页目录
抓到自己应用的一个请求,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 基本就是记账:
- 对同一个接口抓三四次,最好每次只改一个你能控制的值(比如在应用里改一项设置)。
- 按字段号记下 wire type,以及哪种读法说得通:无符号、zigzag、补码、double、字符串、packed。
- 把这些整理成
.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 打包后的源码。