Decoding a Protobuf-Encoded Base64 JSON Wrapper Without a Schema

I was working on a digital pathology viewer where pathologists can annotate whole-slide images and submit those annotations for algorithmic analysis. The analysis service returns geometric data—coordinates describing detected regions, along with metadata such as colors representing different analysis or classification results. Those overlays are then rendered directly on top of the slide.

Because a single analysis can contain thousands of contours and hundreds of thousands of coordinate pairs, the response payloads became enormous. To reduce payload size, network transfer time, and serialization overhead, we switched from JSON to Protocol Buffers.

That worked great, except that nobody could tell me what the Protobuf schema looked like.

The engineer who generated it knew it was "protobuf serialized from C#" and not much else. There wasn't a .proto file or documentation, and there definitely wasn't anyone who knew what a wire type was. Everyone I asked told me that they just called "Serializer.serialize, I don't know, just deserialize it."

This turned into an unexpectedly fun exercise in reverse engineering.

I used protobufjs, but I needed the .proto schema to deserialize the data against. To get an idea of what I was looking at, I just logged the response object to the console, and ran it through atob() right there in the browser. Not elegant, but fast and easy.

The First Clue

The Base64 string decoded into something that looked promising.

þ¯[{"id":"...","contours":[...]

There were a few garbage characters at the beginning, but after that was what looked like perfectly normal JSON.

My first thought was that the sender had simply serialized a JSON string inside a protobuf message.

So I wrote the obvious schema:

message Wrapper {
  string payload = 1;
}

No luck.

The "JSON" wasn't actually JSON, it was just what happened when binary protobuf bytes were interpreted as UTF-8.


Building a Schema From Scratch

Fortunately, I was already familiar with the object heirarchy from when the payload was simple JSON, and although the decoded string wasn't usable, I could still confirm the expected object properties were present.

[
  {
    id,
    contours: [
      {
        id,
        type,
        lineCoords: [
          { x, y }
        ]
      }
    ],
    colorValue,
    nuclearCentroid,
    weightedIntensity
  }
]

That became my first attempt:

message Coordinate {
  float x = 1;
  float y = 2;
}

message Contour {
  string id = 1;
  string type = 2;
  repeated Coordinate lineCoords = 3;
}

message TopLevelItem {
  string id = 1;
  repeated Contour contours = 2;
}

It didn't break, but it also didn't decode.

Learning Wire Types

The decoder began throwing messages like

invalid wire type 7

If you've never worked with protobuf internals before, wire types are the encoding format used for each field.

Only six exist

IDNameUsed For
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, embedded messages, packed repeated fields
3SGROUPgroup start (deprecated)
4EGROUPgroup end (deprecated)
5I32fixed32, sfixed32, float

There is no wire type 6 or 7.

If you see one, it almost always means that you're not actually reading a field boundary anymore. Some earlier field was declared incorrectly, causing the parser to lose synchronization with the message and interpret every subsequent byte incorrectly.

False Leads

At various points I became convinced that:

  • the data was compressed
  • the data wasn't protobuf at all
  • the data contained JSON
  • the RGB value should be a nested message
  • the coordinates should be float
  • the coordinates should be double
  • the top-level object was a wrapper
  • the top-level object wasn't a wrapper
  • protobuf-net serialized lists differently than protobuf.js expected

Building the Schema Incrementally

After a little while of banging my head against trying to assemble a complete .proto file all at once, I decided to start with one field.

message TopLevelItem {
    string id = 1;
}

Once that was confirmed to not throw an error (the error changed), I started adding more. Each time, I made my best guess and then attempted to deserialize the payload. It was pretty much all educated trial-and-error, but I got there eventually.

Eventually the schema looked more like:

message Coordinate {
    double x = 1;
    double y = 2;
}

message Contour {
    string id = 1;
    string type = 2;
    repeated Coordinate lineCoords = 3;
}

message TopLevelItem {
    string id = 1;
    repeated Contour contours = 2;
    repeated uint32 colorValue = 3;
    Coordinate nuclearCentroid = 4;
    string weightedIntensity = 5;
}

Learning to Read the Errors

The most useful thing I learned was that protobuf errors are surprisingly informative once you understand them.

For example:

invalid wire type 7 at offset 48

doesn't mean

"Field 48 is wrong."

It means

"Something before offset 48 is wrong, and now the decoder is lost."

Once I learned that, I stopped fixing the field where the decoder crashed and started fixing the field immediately before it. Progress sped up dramatically.

Data Types Can Be Misleading

One of the stranger bugs involved weightedIntensity.

I knew the JSON representation looked like

"weightedIntensity": "101.5"

So naturally it was...

...not obvious.

Trying double produced numbers like

1.6933885183179138e-52

Trying int64 produced enormous integers. And wouldn't have been sufficient anyway; in a medical setting, you need exact float precision, not integers.

I asked one of the data scientists about it because hey, maybe weighted intensity can be a reallllly big or small number. But he confirmed that those numbers were bonkers. Seeing numbers where I expected there to be numbers, even if the numbers were implausible, threw me off for a bit, but eventually it occurred to me that the protobuf field might not be a number at all, and might instead be a string of a number.

The Final Result

Eventually, after enough iterations, enough field reordering, enough type changes, and enough tiny experiments, I arrived at a .proto file that matched the original C# serialization, and was able to get clean structured data for the frontend. It took a while, but I'm really happy that I was able to figure it out, and the application runs much faster.