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_pb2with anOrderRequestmessage. - 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:
# 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:
# 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()
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.
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.
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
seedfor 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_sizecan 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 Exceptionhides 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.
Related
- Coverage-Guided Fuzzing with Atheris — the fuzzing loop and corpora.
- Fuzzing a C Extension with Atheris and ASan — sanitizers for native code.
- Running Fuzz Targets in CI with Time Budgets — scheduling both targets.
- Round-Trip Properties for Serializers and Parsers — property tests for the same codecs.
← Back to Coverage-Guided Fuzzing with Atheris