Hypothesis & Fuzzing

Structure-Aware Fuzzing with Atheris and Protobuf

Byte-level fuzzing is superb at finding bugs in parsers and weak at getting past them. Suppose a service accepts protobuf-encoded requests, validates the schema, checks a CRC over the payload, and only then runs the business logic that matters — pricing rules, permission checks, state transitions. A mutation that flips random bits almost never produces a message that decodes, passes validation and has a correct checksum. The fuzzer spends its whole budget exploring the error paths of the front door and never reaches the rooms behind it.

Structure-aware fuzzing changes what gets mutated. Instead of raw bytes, the fuzzer mutates a message: it changes a field, appends a repeated element, picks a different enum value, then re-encodes and fixes up the checksum. Almost every input is valid, so almost every execution reaches the application logic, and coverage guidance does its real job there. Atheris supports this in two ways — building structured inputs from a FuzzedDataProvider, and plugging in a custom mutator — and both work with protobuf messages without native tooling.

Prerequisites

  • atheris >= 2.3, protobuf >= 4.25, Python 3.11 or later.
  • A generated message module, here orders_pb2 with an OrderRequest message.
  • Background from Coverage-guided fuzzing with Atheris.

Solution

Approach 1 — build a message from the provider. Simple and effective when the message shape is small:

Python
# fuzz_orders_builder.py
import sys
import atheris

with atheris.instrument_imports():
    from orders import orders_pb2
    from orders.service import handle_order

def build(fdp: atheris.FuzzedDataProvider) -> orders_pb2.OrderRequest:
    req = orders_pb2.OrderRequest()
    req.customer_id = fdp.ConsumeUnicodeNoSurrogates(12)
    req.currency = fdp.PickValueInList(["EUR", "USD", "GBP", "JPY"])
    for _ in range(fdp.ConsumeIntInRange(0, 8)):
        line = req.lines.add()
        line.sku = fdp.ConsumeUnicodeNoSurrogates(8)
        line.quantity = fdp.ConsumeIntInRange(-5, 10_000)
        line.unit_price_cents = fdp.ConsumeIntInRange(-100, 10**9)
    req.coupon = fdp.ConsumeUnicodeNoSurrogates(10)
    return req

def TestOneInput(data: bytes) -> None:
    req = build(atheris.FuzzedDataProvider(data))
    try:
        handle_order(req)
    except orders.errors.ValidationError:
        pass

atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()

Approach 2 — a custom mutator over encoded messages. Better for large or evolving schemas, and it keeps the corpus as real serialised messages:

Python
# fuzz_orders_mutator.py
import random
import sys
import atheris

with atheris.instrument_imports():
    from orders import orders_pb2
    from orders.service import handle_order_bytes, frame

def CustomMutator(data: bytes, max_size: int, seed: int) -> bytes:
    rnd = random.Random(seed)
    msg = orders_pb2.OrderRequest()
    try:
        msg.ParseFromString(data)
    except Exception:
        msg = orders_pb2.OrderRequest()           # start fresh from junk
    choice = rnd.randrange(4)
    if choice == 0 and msg.lines:
        rnd.choice(msg.lines).quantity = rnd.choice([0, -1, 1, 2**31 - 1])
    elif choice == 1:
        msg.lines.add(sku="X", quantity=rnd.randint(-3, 3), unit_price_cents=rnd.randint(-1, 10**6))
    elif choice == 2:
        msg.currency = rnd.choice(["EUR", "USD", "", "XXX"])
    else:
        msg.coupon = atheris.Mutate(msg.coupon.encode(), 32).decode("utf-8", "ignore")
    return frame(msg.SerializeToString())[:max_size]   # frame() adds length + CRC

def TestOneInput(data: bytes) -> None:
    try:
        handle_order_bytes(data)
    except orders.errors.ValidationError:
        pass

atheris.Setup(sys.argv, TestOneInput, custom_mutator=CustomMutator)
atheris.Fuzz()
Byte mutation versus structure-aware mutation Two lanes feed a service with four layers: decode, schema validation, checksum and business logic. Byte-level mutation produces inputs that mostly stop at decode or checksum, so business logic sees few inputs. Structure-aware mutation produces valid, correctly framed messages that nearly all reach the business logic. How far each kind of input gets decode schema checksum business logic raw bytes 70% stop 20% stop ~all the rest almost none arrive structured valid, framed, checksummed most inputs arrive Coverage guidance only helps in code the inputs actually reach.
Byte mutation keeps testing the front door; structure-aware mutation spends the budget where the business rules are.

Why this works

libFuzzer calls the custom mutator in place of its built-in byte mutations. The mutator receives an existing corpus entry, decodes it, makes one structural change and returns a correctly framed encoding. Because the result is valid, the target runs the full request path, and coverage feedback now distinguishes inputs by which business branches they reach — a zero quantity, a negative price, an unknown currency, a coupon string that triggers a particular discount rule. Inputs that reach new branches join the corpus and become the starting point for further mutation, exactly as in byte-level fuzzing, but in a space where every point is meaningful.

The builder approach reaches the same place differently. FuzzedDataProvider consumes the fuzzer's bytes as a sequence of decisions — how many lines, which currency, what quantity — so byte mutations become structural changes one level up. It is simpler to write and requires no framing function, but its corpus entries are opaque byte strings that only make sense through the builder, and schema changes invalidate them.

Inside the custom mutator The custom mutator takes a corpus entry of encoded bytes, parses it into an OrderRequest message, makes one structural change such as altering a quantity or adding a line, serialises the message, and frames it with a length and checksum before returning it to libFuzzer. Decode, change one thing, re-encode corpus entry framed bytes ParseFromString OrderRequest mutate a field qty · lines · currency Serialize protobuf bytes frame() length + CRC Every returned input passes decode and checksum; the target sees real requests.
The mutator owns validity, so the fuzzer can spend every execution exploring behaviour instead of rediscovering the frame format.

Choosing mutations that find bugs

The custom mutator's choices encode your guesses about where bugs hide, so make them boundary-seeking. For integer fields, favour 0, -1, 1, the type's maximum and values just past business limits — a quantity of 10_001 when the limit is 10_000. For repeated fields, favour empty lists, a single element, and duplicates of an existing element, since duplicate SKUs in one order are a classic source of double-counting. For strings, delegate to atheris.Mutate, which applies libFuzzer's byte mutations to the field's contents, so coupon codes and identifiers still get the fuzzer's full creativity.

Enum fields deserve special attention: protobuf accepts unknown enum numbers on the wire, so setting a field to a value outside the declared range tests how the service handles clients built against a newer schema. Keep one change per call. Several simultaneous changes make it harder for coverage feedback to credit the change that mattered, and the corpus evolves more slowly. libFuzzer calls the mutator many thousands of times per second, so small steps accumulate quickly.

Seeds matter here as much as for byte-level fuzzing. A handful of realistic orders — a single-line order, a large multi-line order, one with a coupon, one in each supported currency — serialised and framed into the corpus directory gives the mutator real material to edit from the first second, instead of building every message up from an empty request one field at a time.

Also keep the byte-level target. The structure-aware target deliberately never sends malformed frames, so it cannot find bugs in the frame parser, the protobuf decoding path or the checksum verification. Running both targets — one for the front door, one for the rooms behind it — covers the whole path.

Measuring whether it reaches deeper

The reason to build a structure-aware target is coverage of the business logic, so measure that directly rather than trusting that it must be better. Atheris targets can be run under coverage.py to replay a corpus: python -m coverage run fuzz_orders_mutator.py corpus_structured/ -runs=0 executes every corpus entry once and exits, and coverage report --include='orders/service/*' then shows which lines of the service were reached. Do the same with the byte-level target's corpus and compare.

The typical result is lopsided in both directions. The byte-level corpus covers the decoder and framing code almost completely, including many error branches, and reaches only a thin slice of the service. The structure-aware corpus barely touches the decoder's error paths and covers most of the service: discount calculation, currency conversion, stock reservation, the branches for zero and negative quantities. The two are complementary, which is the argument for running both.

The comparison also shows where the structure-aware mutator is still missing something. A pricing branch that neither corpus reaches usually needs an input the mutator never produces — a combination of a specific coupon with a specific currency, say — and the fix is to add that as a mutation choice or a seed. Repeating the measurement after each change turns the mutator from a guess into something tuned against evidence.

Running this comparison once a month, or whenever the service gains a significant feature, keeps the fuzzing effort pointed at the code that changed. A new rule that is not covered by the structured corpus after a nightly run is a clear signal that the mutator needs to learn about the new field.

Complementary coverage of two targets Bars compare line coverage for two modules. For the decoder and framing module, the byte-level target covers about ninety percent and the structured target about forty. For the business service module, the byte-level target covers about fifteen percent and the structured target about eighty. Together they cover both. Each target covers what the other misses decoder + frame bytes 90% structured 40% order service bytes 15% structured 80% Replay each corpus under coverage.py to get these numbers for your own service.
Measured side by side, the two targets divide the work cleanly — which is why neither replaces the other.

Edge cases and failure modes

  • Mutator exceptions. An exception in the custom mutator aborts the run. Wrap parsing, as above, and fall back to a fresh message on junk input.
  • Non-deterministic mutators. Use the provided seed for all randomness; libFuzzer relies on reproducible mutations.
  • Unknown fields dropped. Parsing and re-serialising drops fields the generated module does not know about. Regenerate the module when the schema changes, or the fuzzer silently stops exercising new fields.
  • Size limit. Truncating the framed output to max_size can break the frame. Prefer to skip mutations that would exceed it.
  • Validation errors swallowed too broadly. Catch only the documented validation exception; a bare except Exception hides the crashes you are looking for.

Frequently Asked Questions

What is structure-aware fuzzing? Fuzzing where inputs are built or mutated according to the format's structure rather than as raw bytes. Mutations then change fields, add repeated elements or alter enum values, so most inputs are valid enough to reach the logic behind the parser.

When is byte-level fuzzing enough? When the parser itself is the target, or the format is forgiving. Byte-level mutation is excellent at finding parser bugs. It struggles when inputs must pass checksums, strict schemas or several layers of validation before reaching the code of interest.

Do I need libprotobuf-mutator to fuzz protobuf inputs from Python? No. A custom mutator in Python can decode the bytes into a message, mutate fields with the protobuf API, and re-serialise. libprotobuf-mutator is more thorough but adds a native build dependency.

← Back to Coverage-Guided Fuzzing with Atheris