Decode Protobuf Without the .proto: Wire Types and gRPC Frames
On this page
You have a binary request body from your own app, application/x-protobuf or application/grpc-web+proto, and no .proto file to go with it. This guide shows what you can recover from the bytes alone, which parts are guesswork, and where protoc --decode_raw and the site’s protobuf decoder agree and where they differ.
Rather than explain the wire format in the abstract, we wrote a small schema, encoded one message with protoc, and then pretended we had lost the schema. Every output below comes from that run.
Setup we used: macOS 27.0.1 on arm64, protoc from Homebrew (brew install protobuf, which reports libprotoc 36.2), Node 22.22.3 to run the site’s decoder code from the repository. If you cannot install protoc, the browser tool prints the same text, so you can follow along with only that.
The message we encoded
The schema is a made-up “reading stats” request. It mixes the field types that cause trouble when the schema is missing:
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;
}
The values, in protobuf text format (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"
protoc --encode turns text format into binary when it has the schema:
protoc --encode=demo.reading.StatsRequest reading.proto < req.txtpb > req.bin
wc -c req.bin # 104 req.bin
104 bytes. From here on, assume reading.proto is gone.
What protoc prints with no schema
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
}
Some of that is right. Several lines are wrong in ways that are easy to believe if you do not know the original values. The reasons all come from how little the wire format records.
What the bytes actually say
Each field on the wire starts with a tag varint: field_number << 3 | wire_type. The wire type is one of only four that matter here:
| Wire type | Meaning | Could be (in the schema) |
|---|---|---|
| 0 | varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | 8 bytes | fixed64, sfixed64, double |
| 2 | length + bytes | string, bytes, nested message, packed repeated scalars, map entry |
| 5 | 4 bytes | fixed32, sfixed32, float |
(Types 3 and 4 are the deprecated proto2 groups.) The first bytes of req.bin are 0a 03 61 64 61: tag 0x0a is field 1, wire type 2, length 3, then ada. That is everything the decoder gets. No name, no type, only “field 1 holds three bytes”. Field names and enum names never reach the wire, which is why status: READING came back as 3: 2.
Varints: unsigned, two’s complement, or zigzag?
Field 3 printed as 599. The real value was -300, stored as sint32. sint types use zigzag encoding, which maps 0, -1, 1, -2 … to 0, 1, 2, 3 …, so -300 becomes 599. protoc has no way to know that and prints the unsigned value.
Field 4 printed as 18446744073709551615. That is -1 as an int32. Plain int32 and int64 store negative numbers as 64-bit two’s complement, so -1 takes ten bytes on the wire (20 ff ff ff ff ff ff ff ff ff 01 in the hex dump, tag included) and reads back as 2^64 - 1.
So a varint that looks huge is probably a small negative int32/int64, and an odd varint that looks small might be a negative sint. Neither guess is certain. When we ran the site’s decoder code on the same bytes, its tree view offers all three readings for each varint, and for these two fields our script logged:
3 varint 599n int64 599n zigzag -300n
4 varint 18446744073709551615n int64 -1n zigzag -9223372036854775808n
The right answer is the one that makes sense for your API: a timezone offset of -300 minutes is plausible, 599 is not.
Fixed-width fields: integer or float?
Field 6 printed as 0x4012000000000000. Read as a little-endian IEEE 754 double, that is 4.5, the rating we set. Field 7, 0x0000008c, is the integer 140. protoc shows both as hex because a 64-bit pattern is equally valid as fixed64, sfixed64 and double. The rule of thumb that worked here: if the “integer” is a strange large number with lots of trailing zero bytes, try it as a float.
Length-delimited: the real ambiguity
Wire type 2 is where most of the guessing happens. protoc tries every length-delimited value as a nested message first; if the bytes parse cleanly as fields, it prints braces, otherwise a quoted string. Our message hits all the cases:
- Field 2 (
books) parsed as a message, and it is one. Good. - Field 2.4 (
isbn13, bytes) starts with0x09, which would be field 1 wire type 1 (8 bytes follow), but only 6 bytes remain. Parsing fails, so protoc falls back to a string. Correct, by luck. - Field 5 (
daily_pages) came out as"\014\000#\254\002". In proto3,repeated uint32is packed by default: all values in one length-delimited field with no tags between them.0c 00 23 ac 02is the varints 12, 0, 35, 300. protoc never tries “packed” because it is impossible to tell packed varints from a string in general. The site’s tree view lists the packed reading next to the others, and for this field it shows[12n, 0n, 35n, 300n]. - Field 8 (
tags) printed as a message with fields 1 and 2. That is how amap<string, uint32>entry is encoded: a nested message with key as field 1 and value as field 2. If you see repeated{ 1: "…" 2: … }pairs, think map. - Field 9 (
note) is the string"hi", but protoc printed9 { 13: 105 }. The byte0x68(h) happens to be a valid tag (field 13, varint) and0x69(i) a valid value. Any short ASCII string can do this. The tree view offers"hi"as the UTF-8 reading.
The packed-list reading is also offered when it is nonsense: our decoder happily reads "ada" as the varints [97, 100, 97]. Every reading in the list is something the bytes allow, not a claim about what the field is. You decide by looking at several captures: a field that always decodes as clean UTF-8 is a string; one that changes length when you add an item to a list in the UI is probably packed.
gRPC bodies: strip the 5-byte prefix first
A gRPC or gRPC-Web body is not bare protobuf. Each message is preceded by a 1-byte flag and a 4-byte big-endian length. We built such a frame from req.bin (flag 0x00, length 0x68 = 104):
00000000: 0000 0000 680a 0361 6461 1225 0a15 4372 ....h..ada.%..Cr
Feeding that straight to protoc fails:
protoc --decode_raw < req.grpc
# Failed to parse input.
# exit status 1
The leading 0x00 is not a valid tag (field number 0), so protoc rejects the whole input and prints nothing. Dropping the first five bytes fixes it:
tail -c +6 req.grpc | protoc --decode_raw # identical to the output above
That works for one uncompressed frame. A streaming response has several frames back to back, a gRPC-Web response ends with a trailers frame (flag 0x80), and a compressed message has flag 0x01 and a gzip payload, so tail stops being enough. We built a gRPC-Web body with one gzip-compressed message frame (112 bytes of gzip) and a trailers frame, and ran the site’s decoder code on it as a file. It reported (condensed from our script’s log):
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"
If you turn frame detection off in the tool, it decodes the framed bytes as one message and stops at offset 0 with “a 0x00 byte where a field tag should be”, which is the same reason protoc gave up.
Base64 and hex input
DevTools and logs rarely hand you a binary file. gRPC-Web-text bodies are base64, and many apps put protobuf in a base64 header or JSON string. With protoc you decode first:
base64 -i req.bin
# CgNhZGESJQoVQ3JhZnRpbmcgSW50ZXJwcmV0ZXJzEIAFGAIiBwl4CZCIiQcSCwoEU0lDUBCRBRgDGNcE…
base64 -D -i req.b64 -o fromb64.bin && protoc --decode_raw < fromb64.bin
(-D is the macOS spelling; GNU coreutils uses -d.) The browser tool takes hex, standard base64 and URL-safe base64 directly. On our 104-byte message all three inputs printed text identical to protoc --decode_raw.
One trap we hit while testing: the tool guesses the encoding. Text made only of hex digits is read as hex. So 0801 is read as hex, 08 01, which is 1: 1. But 0801 is also valid base64 for three different bytes. Forcing Base64 on the same text gave a malformed message (GROUP_MISMATCH at offset 3), which is how you notice you picked the wrong one. If your base64 happens to contain only 0-9a-f, choose the encoding by hand.
When protoc refuses but you still want the fields
We appended three 0x00 bytes to req.bin, which is what you get when a capture tool pads a buffer:
protoc --decode_raw < padded.bin
# Failed to parse input.
protoc prints nothing at all, not even the 10 good fields before the padding. The site’s decoder prints those 10 fields and then reports ZERO_TAG at offset 104, which tells you exactly where the protobuf ends. The same applies to truncated captures: you get the valid prefix and the offset where it stopped.
Working back towards a schema
Once you have a raw dump, writing a draft .proto is mostly bookkeeping:
- Decode three or four captures of the same call, ideally with values you control (change one setting in the app between captures).
- For each field number, write down the wire type and which reading made sense: unsigned, zigzag, two’s complement, double, string, packed.
- Turn that into a
.protoand check it.protoc --decode=demo.reading.StatsRequest reading.proto < req.binshould print names and plausible values. With our schema back in place it printedtimezone_offset_min: -300,balance_delta: -1,daily_pages: 12…300andstatus: READING, which confirms every guess above.
Field numbers stay the same across app versions (changing them breaks compatibility), so a draft schema built this way keeps working until a field is added.
Where REA fits
REA, the open-source project by morluto, reads saved HAR and Fiddler .saz captures through its inspect_web_network_capture tool (see the HAR guide). In the 6.7.0 package, the SAZ reader states its own limit: “WebSocket and gRPC members retain opaque message-file bytes, not decoded protocol events.” So REA finds the request and hands you the body bytes; decoding the protobuf inside is the step this guide covers. For the wider workflow from a captured request back to the front-end code that builds it, see tracing a website’s API calls.
Capture and decode traffic only from apps and services you own or are authorized to test. See the disclaimer.
Limits of schema-less decoding
- Names, enum labels and the
stringvsbytesdistinction are gone for good. Only the schema has them. - Default values are not on the wire in proto3. A field set to
0,""orfalseis simply missing, so a field you never see might still exist. - A message nested more than 10 levels deep prints as a string in
protoc --decode_raw, and the browser tool copies that behaviour so the text views match. - The tool decompresses gzip and deflate frames only; snappy or zstd frames are flagged, not decoded.
Next: if the protobuf body came from a minified or obfuscated web app and you want to find the code that builds it, read how we deobfuscated javascript-obfuscator output or recover JavaScript source from a bundle.