Protocol Buffers for Forensic Examiners: The Varint Trap

Introduction

If you’ve parsed SQLite databases and you’re working in mobile forensics, you’ve encountered variable-length integers (varints). You’ve seen how SQLite uses them to encode payload lengths, row IDs, and serial type codes in record headers.

Then one day you pull a BLOB from an Apple Note, decompress it, and try to parse the protobuf inside.

You see what appear to be varints… but when you decode them, your decoder gives you completely wrong answers. But they look correct. The lengths don’t match. The fields are wrong. Everything breaks.

Protobuf varints are NOT SQLite varints.

They share a name. They both use a continuation bit in the most significant bit position. But that’s where the similarities end. The byte order is reversed. And if you don’t know that, you’ll silently get the wrong answers that look plausible enough to waste hours of your time.

Welcome to Protocol Buffers.

In this post, we’re going to parse a protobuf blob from scratch — a core skill in mobile forensics. First by hand, then in Python. No libraries, no shortcuts.

By the end, you’ll extract a timestamp, identify an XOR key, and decrypt a hidden message. If you get the right answer, you know you understood every byte.

Where You'll Encounter Protobuf

Protocol Buffers (protobuf) is Google’s binary serialization format, and it has become the standard for structured data in mobile applications. As forensic examiners, you’ll find protobuf-encoded data in:

  • Apple Notes: The ‘ZDATA’ BLOB in ‘ZICNOTEDATA’ is a gzip-compressed protobuf
  • WhatsApp: Message metadata and media references
  • Signal: Encrypted message payloads, after decryption
  • Google apps: Maps timeline, Chrome sync data, Play Store records
  • iCloud sync BLOBS: Various Apple services

 

If you are parsing modern mobile forensic artifacts and you’re not comfortable with protobuf, you have a blind spot in your toolkit. Protobuf varint forensics is a skill every examiner needs.

The Challenge

Here’s a decompressed protobuf BLOB. It represents a simplified message record, the kind of structure you would find inside an Apple Notes ‘ZDATA’ BLOB or an app’s internal message store.

Your job: decode every field, extract the timestamp, find the encryption key, and decrypt the message. This is protobuf varint forensics in action.

  0000: 08 E9 07 12 05 4C 61 6E 63 65 18 D2 CF 8D BB 06  .....Lance......
  0010: 22 04 DE AD BE EF 2A B5 01 9A C8 CE 80 AD C4 CA  ".....*........
  0020: CF AA C5 DB CF B8 D8 D0 8B AD 8D D7 81 AA C2 9E  ................
  0030: 8E BD CE D1 9A B0 D9 9E DB EA 95 8F C2 EC 9F 87  ................
  0040: DF F3 9A 8F DA E8 8D DC 8A B8 C2 CC 8A FE EB CC  ................
  0050: 86 BA CC C7 C1 FE F8 CD 8A FE D9 D6 8A FE DE DF  ................
  0060: 82 BB 8D D3 8A AA C5 D1 8B FE CC CD CF B2 CC CD  ................
  0070: 9B FE D9 D7 82 BB 83 9E AB B1 8D D0 80 AA 8D DD  ................
  0080: 80 B0 D9 DF 8C AA 8D D3 8A FE C2 D0 CF AA C5 D7  ................
  0090: 9C FE C3 CB 82 BC C8 CC CF BF CA DF 86 B0 8D 93  ................
  00A0: CF AB DE DB CF AA C5 DB CF AD C8 DD 80 B0 C9 DF  ................
  00B0: 9D A7 8D DD 87 BF C3 D0 8A B2 83 9E AB BB DE CA  ................
  00C0: 9D B1 D4 9E 9B B6 C4 CD CF B0 C2 CA 8A F0        ..............

If you squint at offset 0x0005, you can see the hex representation of the ASCII characters “Lance”. But most of the bytes are opaque. The structure holding them together is protobuf, and to read it, we need to understand two things: protobuf varints and wire types.

A Quick SQLite Varint Refresher

Before we get to protobuf, let’s make sure we’re on the same page about SQLite varints. In SQLite encoding:

The most significant bit (MSB) of each byte is the continuation flag. It’s either 1 which means more bytes follow or it’s 0 which means this is the last byte.

The remaining 7 bits carry the actual data or payload

Bytes are read big-endian meaning the first byte carries the most significant data bits.

There’s a maximum of 9 bytes (the 9th byte uses all 8 bits).

Here's the integer 300 encoded as a SQLite varint:
300 in binary: 00000001 00101100
256 128 64 32 16 8 4 2 1
1 0 0 1 0 1 1 0 0
256 + 32 + 8 + 4 = 300
Split into 7-bit groups (from the most significant):
Group 1: 0000010 (high bits)
Group 2: 0101100 (low bits)
Add continuation bits:
Byte 1: 1 | 0000010 = 0x82 (MSB = 1, more bytes follow)
Byte 2: 0 | 0101100 = 0x2C (MSB = 0, last byte)
SQLite varint for 300: 0x82 0x2C

The key concept: you read left to right, each byte’s 7-bit payload slots into the high end of your accumulator, and you shift the accumulator left to make room for the next byte.

But this is not how all varints work.

The Protobuf Difference: LEB128

Protobuf uses a varint encoding called LEB128: Little-Endian Base 128. Same continuation bit, same 7-bits-per-byte concept, but the byte order is reversed.

The first byte carries the least significant 7 bits

Each subsequent byte carries progressively higher-order bits

The continuation bit still works the same: ‘1’ = more bytes, ‘0’ = the last byte.

Here's 300 encoded as a protobuf varint (LEB128):
300 in binary: 00000001 00101100
256 128 64 32 16 8 4 2 1
1 0 0 1 0 1 1 0 0
256 + 32 + 8 + 4 = 300
Split into 7-bit groups (from the LEAST significant):
Group 1: 0101100 (low bits -- goes FIRST)
Group 2: 0000010 (high bits -- goes SECOND)
Add continuation bits:
Byte 1: 1 | 0101100 = 0xAC (MSB = 1, more bytes follow)
Byte 2: 0 | 0000010 = 0x02 (MSB = 0, last byte)
Protobuf varint for 300: 0xAC 0x02
Compare them side by side:
Encoding Byte 1 Byte 2 Value
SQLite 0x82 0x2C 300
Protobuf 0xAC 0x02 300

Same integer. Completely different bytes. Feed 0xAC02 into a SQLite varint decoder and you’ll get a different number. Feed 0x822C into a protobuf decoder – same problem. This is the trap.

Protobuf Wire Types: The Road Map

Every protobuf message is a sequence of field tag + value pairs. No delimiters, no overall length header. You read fields sequentially until you run out of bytes.

Each field starts with a tag, which is itself a varint. The tag packs two things together using bit manipulation:

tag = (field_number << 3) | wire_type

The low 3 bits are the wire type. They tell you how to read the value that follows. The remaining upper bits are the field number that tells us which field this is. To unpack a tag:

wire_type    = tag & 0x07      # low 3 bits
field_number = tag >> 3        # everything above the low 3

The & symbol is the bitwise AND operator. We use it to mask the value we are working with to isolate the 1 bits. Let’s say our tag is 0x0A. The binary equivalent is: 0000 1010

We’ll break down exactly how AND, shift, and OR work at the bit level later in this post.

Here are the wire types you will encounter:

Wire Type Name What Follows Use Case
0 Varint A single varint value Integers, Booleans, enums, timestamps
1 64-bit Exactly 8 bytes Double-precision floats, fixed int64
2 Length-delimited A varint length, then that many bytes Strings, byte arrays, nested messages
5 32-bit Exactly 4 bytes Single-precision floats, fixed int32

Wire type 2 is by far the most common in forensic artifacts. It covers strings, raw bytes, and embedded sub-messages. And critically, its length prefix is a protobuf varint. Decode it with a SQLite decoder and every field boundary after that point will be wrong.

Parsing Protobuf Varints: Field by Field

Now let’s walk through the hex dump and parse every field by hand. This is exactly what your Python script will automate. But doing it manually first is how you build the intuition. Let’s put protobuf varint forensics to work.

Field 1: Record ID (Varint)

Starting at offset 0x0000:
08 E9 07
Read the tag — first byte is 0x08:
0x08 = 0000 1000
MSB is 0, so this is a single-byte varint. Tag value = 8.
wire_type = 8 & 0x07 = 0 (varint)
field_number = 8 >> 3 = 1
Field 1, wire type 0. The value is a varint — read it next.
Read the value — bytes E9 07:
Byte 1: 0xE9 = 1110 1001
MSB = 1 (more bytes coming)
Payload: 110 1001 = 0x69 = 105
Byte 2: 0x07 = 0000 0111
MSB = 0 (last byte)
Payload: 000 0111 = 0x07 = 7
Reconstruct (LEB128 — least significant first):
result = 105 << 0 = 105
result |= 7 << 7 = 105 | 896 = 1001
First byte stays in place (shift left 0):
0000000 1101001 = 105
Second byte shifted left 7 bits:
0000111 0000000 = 896
512 256 128 64 32 16 8 4 2 1
1 1 1 0 0 0 0 0 0 0
512 + 256 + 128 = 896
Bitwise OR the two values:
    0000000 1101001 (105)
  | 0000111 0000000 (896)
    0000111 1101001 (1001)
512 256 128 64 32 16 8 4 2 1
1 1 1 1 1 0 1 0 0 1
512 + 256 + 128 + 64 + 32 + 8 + 1 = 1001
Field 1 = 1001 (a record ID)

Field 2: Sender Name (Length-Delimited String)

Next bytes at offset 0x0003:
12 05 4C 61 6E 63 65
Tag 0x12 = 18:
wire_type = 18 & 0x07 = 2 (length-delimited)
field_number = 18 >> 3 = 2
Field 2, wire type 2. Read a length varint, then that many bytes of data.
Length: 0x05 = 5 — single byte, MSB is 0. Five bytes of data follow.
Data: 4C 61 6E 63 65 = "Lance" in ASCII/UTF-8

Field 3: Timestamp (Varint) – The Five-Byte Monster

Offset 0x000A:
18 D2 CF 8D BB 06
Tag 0x18 = 24:
wire_type = 24 & 0x07 = 0 (varint)
field_number = 24 >> 3 = 3
Field 3, wire type 0. Now read the varint value — and this is where it gets interesting. Five bytes:
Byte 1: 0xD2 = 1101 0010
MSB = 1 (more coming)     Payload: 101 0010 = 82
Byte 2: 0xCF = 1100 1111
MSB = 1 (more coming)     Payload: 100 1111 = 79
Byte 3: 0x8D = 1000 1101
MSB = 1 (more coming)     Payload: 000 1101 = 13
Byte 4: 0xBB = 1011 1011
MSB = 1 (more coming)     Payload: 011 1011 = 59
Byte 5: 0x06 = 0000 0110
MSB = 0 (LAST byte)      Payload: 000 0110 = 6
Reconstruct (LEB128 — each byte's payload shifts 7 bits higher):
  Byte 1:  82 << 0  =            82
  Byte 2:  79 << 7  =        10,112     running total:        10,194
  Byte 3:  13 << 14 =       212,992     running total:       223,186
  Byte 4:  59 << 21 =   123,731,968     running total:   123,955,154
  Byte 5:   6 << 28 = 1,610,612,736     running total: 1,734,567,890
Field 3 = 1,734,567,890 — a Unix timestamp.
Converting with Python:
  >>> from datetime import datetime, timezone
  >>> datetime.fromtimestamp(1734567890, tz=timezone.utc)
  datetime.datetime(2024, 12, 19, 0, 24, 50, tzinfo=datetime.timezone.utc)
December 19, 2024 at 00:24:50 UTC
What Happens if You Use a SQLite Decoder Instead?
If you mistakenly decoded D2CF8DBB06 as a SQLite varint (big-endian, shift-accumulator-left), you would calculate:
SQLite-style:
  acc = 82
  acc = (82 << 7)          | 79 = 10,575
  acc = (10,575 << 7)      | 13 = 1,353,613
  acc = (1,353,613 << 7)   | 59 = 173,262,523
  acc = (173,262,523 << 7) |  6 = 22,177,602,950
22,177,602,950 as a Unix timestamp? That's the year 2672.

Field 4: The XOR Key (Length-Delimited Bytes)

Offset 0x0010:
22 04 DE AD BE EF
Tag 0x22 = 34:
wire_type = 34 & 0x07 = 2 (length-delimited)
field_number = 34 >> 3 = 4
Field 4, wire type 2. Length varint: 0x04 = 4. Four bytes of data follow:
DE AD BE EF

If you’ve been around hex editors, you should recognize ‘0xDEADBEEF’ as a classic magic number used as a placeholder. In our case, it’s a 4-byte XOR key. We’ll need it for the next field.

Field 5: The Encrypted Message (Length-Delimited, Multi-Byte)

Offset 0x0016:
2A B5 01 [181 bytes of encrypted data...]
Tag 0x2A = 42:
wire_type = 42 & 0x07 = 2 (length-delimited)
field_number = 42 >> 3 = 5
Field 5, wire type 2. Now read the length varint — and this one is two bytes:
Byte 1: 0xB5 = 1011 0101
MSB = 1 (more bytes)      Payload: 011 0101 = 53
Byte 2: 0x01 = 0000 0001
MSB = 0 (last byte)       Payload: 000 0001 = 1
Length = 53 | (1 << 7) = 53 + 128 = 181
The remaining 181 bytes are the encrypted message content.

Protobuf Varint Forensics: Bitwise Operations Behind the Decoder

Three operations do all of the work. If you understand these, you can read any varint:

AND (‘&’) – Stripping the continuation bit

payload = byte & 0x7F    # 0x7F = 0111 1111

The mask ‘0x7F’ has the MSB cleared and all other bits set. ANDing any byte with this mask zeros out the continuation flag and keeps the 7 data bits. Think of it as a stencil, only the bits where the mask has a ‘1’ come through:

      1101 0010   (0xD2 -- our timestamp byte 1)
  AND 0111 1111   (0x7F -- the mask)
    = 0101 0010   (0x52 = 82 -- just the data bits)

We also use AND with 0x80 to test the continuation bit without stripping it:

				
					if (byte & 0x80) == 0:    # MSB is 0 -- this is the last byte
     break

				
			

Left shift (‘<<‘) – Positioning the Payload

payload << shift    # move bits into their correct lane

Left shift moves bits to higher positions. Each shift of 1 doubles the value. In LEB128, each successive byte carries bits 7 positions higher than the last.

  Byte 1 payload: 82 << 0  =           82    (bits 0-6)
  Byte 2 payload: 79 << 7  =       10,112    (bits 7-13)
  Byte 3 payload: 13 << 14 =      212,992    (bits 14-20)
  Byte 4 payload: 59 << 21 =  123,731,968    (bits 21-27)
  Byte 5 payload:  6 << 28 = 1,610,612,736   (bits 28-30)

No overlaps. Each payload occupies its own 7-bit lane.

OR (‘|’) – Combining the Pieces

result |= payload << shift

OR merges bits. Where either input has a `1`, the output has a `1`. Since our shifted payloads never overlap, OR glues them together into the final value:

      0000 0000 0000 0000 0000 0000 0101 0010   (byte 1: 82)
   OR 0000 0000 0000 0000 0010 0111 1000 0000   (byte 2: 79 << 7)
   OR 0000 0000 0000 0011 0100 0000 0000 0000   (byte 3: 13 << 14)
   OR 0000 0111 0111 0000 0000 0000 0000 0000   (byte 4: 59 << 21)
   OR 0110 0000 0000 0000 0000 0000 0000 0000   (byte 5: 6 << 28)
    = 0110 0111 0111 0011 0110 0111 1101 0010   = 1,734,567,890

These three operations are the engine behind protobuf varint forensics.

What is a Protobuf "Message"?

Before we write the script, one clarification that trips people up: in protobuf, “message” is a structural term. It doesn’t mean a chat message or a text. A protobuf *message* is simply a container or a collection of typed fields. Think of it like a row in a database, or a dictionary in Python.

A protobuf message can contain another message as one of its fields (wire type 2, length-delimited). The outer message’s field value is itself a sequence of tag+value pairs that you parse the same way. Apple Notes does exactly this: the top-level Note message contains a nested AttributedString message, which contains the note text and formatting runs as its own fields.

In our challenge blob, all five fields are at the top level. There is no nesting. But in the real world, you’ll encounter nested messages frequently. The parsing logic is the same: read the length-delimited bytes, then feed them back into your parser.

The Complete Python Script

Now let’s put it all together. This script takes the raw protobuf bytes, parses every field, converts the timestamp, extracts the XOR key, and decrypts the message:

				
					from datetime import datetime, timezone


def decode_varint(data, offset=0):
    """
    Decode a protobuf varint (LEB128) from bytes.
    Returns (value, bytes_consumed).
    """
    result = 0
    shift = 0
    bytes_consumed = 0

    while True:
        byte = data[offset + bytes_consumed]
        bytes_consumed += 1

        # AND with 0x7F: strip MSB, keep 7 data bits
        payload = byte & 0x7F

        # OR with shifted payload: merge into result
        result |= payload << shift

        # AND with 0x80: test continuation bit
        if (byte & 0x80) == 0:
            break

        shift += 7

    return result, bytes_consumed


def parse_fields(data):
    """
    Parse raw protobuf bytes into fields.
    Returns list of (field_number, wire_type, value) tuples.
    """
    offset = 0
    fields = []

    while offset < len(data):
        # Read the field tag (varint)
        tag, consumed = decode_varint(data, offset)
        offset += consumed

        # Unpack: low 3 bits = wire type, upper bits = field number
        wire_type    = tag & 0x07
        field_number = tag >> 3

        if wire_type == 0:          # Varint
            value, consumed = decode_varint(data, offset)
            offset += consumed

        elif wire_type == 1:        # 64-bit fixed
            value = data[offset:offset + 8]
            offset += 8

        elif wire_type == 2:        # Length-delimited
            length, consumed = decode_varint(data, offset)
            offset += consumed
            value = data[offset:offset + length]
            offset += length

        elif wire_type == 5:        # 32-bit fixed
            value = data[offset:offset + 4]
            offset += 4

        else:
            print(f"Unknown wire type {wire_type} at offset {offset}")
            break

        fields.append((field_number, wire_type, value))

    return fields


def xor_decrypt(data, key):
    """XOR each byte of data with the repeating key."""
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))


# ============================================================
# THE PROTOBUF BLOB (raw bytes)
# ============================================================

raw = bytes.fromhex(
    "08E90712054C616E636518D2CF8DBB06"
    "2204DEADBEEF2AB5019AC8CE80ADC4CA"
    "CFAAC5DBCFB8D8D08BAD8DD781AAC29E"
    "8EBDCED19AB0D99EDBEA958FC2EC9F87"
    "DFF39A8FDAE88DDC8AB8C2CC8AFEEBCC"
    "86BACCC7C1FEF8CD8AFED9D68AFEDEDF"
    "82BB8DD38AAAC5D18BFECCCDCFB2CCCD"
    "9BFED9D782BB839EABB18DD080AA8DDD"
    "80B0D9DF8CAA8DD38AFEC2D0CFAAC5D7"
    "9CFEC3CB82BCC8CCCFBFCADF86B08D93"
    "CFABDEDBCFAAC5DBCFADC8DD80B0C9DF"
    "9DA78DDD87BFC3D08AB2839EABBBDECA"
    "9DB1D49E9BB6C4CDCFB0C2CA8AF0"
)

print(f"Parsing {len(raw)} bytes of protobuf data.\n")

# Parse all protobuf fields
fields = parse_fields(raw)

wire_type_names = {
    0: "Varint",
    1: "64-bit fixed",
    2: "Length-delimited",
    5: "32-bit fixed"
}

xor_key = None
encrypted_msg = None

for field_num, wire_type, value in fields:
    wt_name = wire_type_names.get(wire_type, f"Unknown({wire_type})")
    print(f"--- Field {field_num} | Wire Type {wire_type} ({wt_name}) ---")

    if wire_type == 0:
        print(f"  Value: {value}")
        if field_num == 3:
            # Convert Unix timestamp
            dt = datetime.fromtimestamp(value, tz=timezone.utc)
            print(f"  As datetime: {dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")

    elif wire_type == 2:
        print(f"  Length: {len(value)} bytes")
        # Try to display as text
        try:
            text = value.decode("utf-8")
            if all(c.isprintable() or c in "\n\r\t" for c in text):
                print(f'  As text: "{text}"')
            else:
                raise ValueError
        except (UnicodeDecodeError, ValueError):
            print(f"  Hex: {value.hex()}")

        # Capture key and ciphertext
        if field_num == 4:
            xor_key = value
            print(f"  XOR Key: 0x{value.hex().upper()}")
        elif field_num == 5:
            encrypted_msg = value

    print()

# Decrypt the message
if xor_key and encrypted_msg:
    print("=" * 60)
    print("DECRYPTING MESSAGE")
    print("=" * 60)
    plaintext = xor_decrypt(encrypted_msg, xor_key)
    print(f"\n  {plaintext.decode('utf-8')}\n")

				
			

Expected Output:

Parsing 206 bytes of protobuf data.

  --- Field 1 | Wire Type 0 (Varint) ---
      Value: 1001

  --- Field 2 | Wire Type 2 (Length-delimited) ---
      Length: 5 bytes
      As text: "Lance"

  --- Field 3 | Wire Type 0 (Varint) ---
      Value: 1734567890
      As datetime: 2024-12-19 00:24:50 UTC

  --- Field 4 | Wire Type 2 (Length-delimited) ---
      Length: 4 bytes
      Hex: deadbeef
      XOR Key: 0xDEADBEEF

  --- Field 5 | Wire Type 2 (Length-delimited) ---
      Length: 181 bytes
      Hex: 9ac8ce80adc4cacf...

  ============================================================
  DECRYPTING MESSAGE
  ============================================================

    Deposit the funds into account 4481-2290-7156
    before Friday. Use the same method as last time.
    Do not contact me on this number again - use the
    secondary channel. Destroy this note.

Verify it Yourself in CyberChef

If you want to cross-check the XOR decryption without trusting the Python script, and as examiners, you shouldn’t blindly trust any script, paste the encrypted hex bytes from Field 5 into CyberChef:

Input (the 181 bytes of encrypted data from Field 5):

  9A C8 CE 80 AD C4 CA CF AA C5 DB CF B8 D8 D0 8B
  AD 8D D7 81 AA C2 9E 8E BD CE D1 9A B0 D9 9E DB
  EA 95 8F C2 EC 9F 87 DF F3 9A 8F DA E8 8D DC 8A
  B8 C2 CC 8A FE EB CC 86 BA CC C7 C1 FE F8 CD 8A
  FE D9 D6 8A FE DE DF 82 BB 8D D3 8A AA C5 D1 8B
  FE CC CD CF B2 CC CD 9B FE D9 D7 82 BB 83 9E AB
  B1 8D D0 80 AA 8D DD 80 B0 D9 DF 8C AA 8D D3 8A
  FE C2 D0 CF AA C5 D7 9C FE C3 CB 82 BC C8 CC CF
  BF CA DF 86 B0 8D 93 CF AB DE DB CF AA C5 DB CF
  AD C8 DD 80 B0 C9 DF 9D A7 8D DD 87 BF C3 D0 8A
  B2 83 9E AB BB DE CA 9D B1 D4 9E 9B B6 C4 CD CF
  B0 C2 CA 8A F0

Recipe:

  1. `Remove whitespace` (spaces, carriage returns, line feeds, tabs)
  2. `From Hex` (delimiter: Auto)
  3. `XOR` (key: `DEADBEEF`, key type: Hex, scheme: Standard)
CyberChef recipe showing XOR decryption of protobuf encrypted message using DEADBEEF hex key
CyberChef recipe: Remove whitespace, From Hex, then XOR with key DEADBEEF (Hex)

You should see the plaintext message in the output. If you get garbled output, double-check that the XOR key type is set to “Hex” and not “UTF8”. The key is the 4-byte sequence `0xDE 0xAD 0xBE 0xEF`, not the ASCII characters D-E-A-D-B-E-E-F.

Why this Matters

As forensic examiners, we don’t get to choose how application developers store their data. We have to meet the data where it is. Increasingly, that data is wrapped in Protocol Buffers, whether you recognize it or not.

This is exactly the kind of problem we built ED SQLite Visualizer to solve. When you’re staring at a BLOB column in `NoteStore.sqlite`, the challenge is not accessing the data. The challenge is understanding it. SQLite Visualizer lets you go from compressed binary to structured protobuf fields in seconds. You can apply a Gunzip decoder, then chain a Protobuf decoder, and immediately see field numbers, wire types, and values without writing a single line of code. It is schema-agnostic, so it works on any protobuf data you encounter, whether it is Apple Notes, WhatsApp, or an app nobody has written a parser for yet.

But tools do not replace understanding. When you are on the stand explaining how you recovered evidence from a protobuf blob, “I clicked a button” is not a methodology. It is a liability. Knowing what LEB128 encoding is, why the first byte carries the least significant bits, and how wire types determine field boundaries is what makes your analysis defensible. The tool gets you there faster. The knowledge is what keeps you there.

If you learned varints from SQLite and assumed protobuf worked the same way, you are not alone. It is one of the most common mistakes examiners make. Understanding why they differ is what protobuf varint forensics is really about.

Take it Further

If you can decode this manually, you understand it.

If you need to do this at scale, across dozens of databases and thousands of records, you need the right tools.

To go deeper into protobuf varint forensics, check out our courses…

ED SQLite Visualizer — Our forensic database analysis platform is built for exactly this kind of work. Decode gzip, protobuf, base64, plists, and more — directly inside the tool. No external scripts. No context switching. Just raw data, fully exposed and ready for analysis.

Python for Mobile Forensics — Our 3-day hands-on course teaches you how to build your own parsers from scratch. You’ll go beyond tools and learn how to handle unsupported apps, reverse custom formats, and confidently decode artifacts like the one in this post.

Advanced SQLite Forensics — Take a deeper dive into SQLite internals — file headers, page structure, record formats, freelist analysis, WAL frames, and recovery of deleted data at the byte level.

Previously: Decrypt Locked Apple Notes on iOS 16.x — follow the full workflow that leads to the protobuf blob you just parsed.

Facebook
Twitter
Email
Print

Leave a Reply

Your email address will not be published. Required fields are marked *

Subscribe to stay updated!

Never miss a post – get notified whenever we publish a new blog article, forensic guide, or share important updates.

Latest Post