In short

Write a small harness that passes fuzzer-generated bytes to the real parser and asserts what an accepted result must mean. Build native code with AddressSanitizer and UndefinedBehaviorSanitizer, seed it with small valid and truncated records, and run it under libFuzzer, AFL++ or a language engine such as go test -fuzz. Minimize each crash, fix the violated invariant rather than the specimen, keep the input as a regression test, and run the fuzzer continuously in CI.

  • Crashes are not the only bugs: add a semantic oracle so an accepted input must mean exactly what its header says.
  • Sanitizers make silent memory errors visible; without them an over-read may never crash.
  • libFuzzer is in maintenance mode, but its LLVMFuzzerTestOneInput harness also runs under AFL++, honggfuzz, OSS-Fuzz and ClusterFuzzLite.
  • Go, Rust, Python and the JVM have their own engines: go test -fuzz, cargo-fuzz, Atheris and Jazzer.
  • Fix the invariant behind a minimized crash and keep the input in the regression suite.
  • Execution counts and coverage describe the experiment; they are not proof of absence.

Verify it

  1. Run the fixed parser under the sanitizers for a set time, for example 60 seconds, and confirm zero crashes.
  2. Run the same harness against the old version and confirm it finds the known bug, which proves the harness reaches it.
  3. Minimize every crash with -minimize_crash=1 and add the result to the unit-test fixtures.
  4. Check coverage, for example with -print_coverage=1 or the coverage report, to confirm payload handling is reached.
  5. Run the harness in CI on each pull request, for example with ClusterFuzzLite, and keep the corpus between runs.
Applies to

Fuzzing bounded parsers and preserving regressions. Harness quality and instrumentation determine what defects are observable.

Learning goals and prerequisitesAbout 30 minutes: 8 to read, 4 on the code and 18 for the exercise

What you will learn

  • Express parser bounds before memory access.
  • Turn a minimized fault into complete boundary regressions.
  • Distinguish generated-input counts from coverage or proof.

Before you start

Developers who maintain parsers or decoders in C, C++, Go or other languages, and security engineers setting up continuous fuzzing.

  • Read a parser contract and distinguish valid rejection from a crash or invariant violation.

Refresh first: Keep untrusted values out of executable syntax

Go to the guided practice

Define the parser invariant before choosing a fuzzer

A fuzzer repeatedly supplies generated or mutated inputs to a program and looks for failures. Coverage-guided fuzzing uses execution feedback to favor inputs that reach new paths. It does not know what your file format means unless the harness or assertions express those rules. A parser that silently accepts malformed data can be wrong without crashing, while a correct parser should reject ordinary malformed input without terminating the process.

The format here is deliberately small: two unsigned big-endian bytes encode a payload length from 0 through 4096, followed by exactly that many bytes and no trailing data. For 00 08 41 42, the header declares eight bytes but only two remain. A parser that copies eight bytes from data+2 reads beyond the buffer. It must first establish a two-byte header, then validate the declared maximum and exact remaining size before accessing the payload. Checking only the destination buffer size does not prove the source has enough bytes.

Use a deterministic target and explicit semantic checks

The complete Python program below is a bounded parser regression and mutation exercise, requiring only Python 3. Save it as parser_demo.py and run python3 parser_demo.py. It tests fixed boundary cases, exhaustively checks two-byte headers for a fixed payload and separately applies one-byte mutations, and verifies the meaning of every accepted result. It is deliberately not described as coverage-guided native fuzzing: Python's managed byte slices and this enumerator have different failure detection from C++ instrumentation.

For a native production parser, write a harness that implements LLVMFuzzerTestOneInput and calls the same parser used by the application. Build both harness and parser with coverage and sanitizer instrumentation, such as AddressSanitizer, record the compiler version and flags, and give the process input-size, time and memory budgets. Keep iteration state independent: free results, reset mutable globals and avoid filesystem or network effects. Seed with minimal valid records and truncations so the engine can reach payload handling instead of spending every iteration on an early magic-header rejection, and add a dictionary of format tokens when the format has tagged fields.

Testparser_demo.pyPython
# Save as parser_demo.py; run: python3 parser_demo.py
import random

def parse(data):
    if len(data) < 2:
        return None
    size = int.from_bytes(data[:2], 'big')
    if size > 4096 or size != len(data) - 2:
        return None
    return data[2:]

fixtures = [
    (b'', None), (b'\x00', None), (b'\x00\x00', b''),
    (b'\x00\x02AB', b'AB'), (b'\x00\x08AB', None),
    (b'\x00\x02A', None), (b'\x00\x01AB', None),
    ((4096).to_bytes(2, 'big') + b'A' * 4096, b'A' * 4096),
    ((4097).to_bytes(2, 'big') + b'A' * 4097, None),
]
for data, expected in fixtures:
    assert parse(data) == expected

rng = random.Random(7)
seeds = [data for data, _ in fixtures[:7]]
checked = 0
for first in range(256):
    for second in range(256):
        data = bytes([first, second]) + b'AB'
        out = parse(data)
        if out is not None:
            assert out == b'AB' and data[:2] == b'\x00\x02'
        checked += 1
for _ in range(2000):
    data = bytearray(rng.choice(seeds))
    if data:
        data[rng.randrange(len(data))] = rng.randrange(256)
    out = parse(bytes(data))
    if out is not None:
        assert len(out) <= 4096
        assert len(out) == int.from_bytes(data[:2], 'big')
        assert len(data) == len(out) + 2
    checked += 1
print(f'PASS: 9 boundary fixtures and {checked} generated inputs')

Build a native harness with sanitizers

The C file below contains the repaired parser and its libFuzzer harness. The harness adds a semantic oracle: any accepted record must have exactly the declared length, so a parser that silently ignored trailing bytes would abort even though nothing overflowed. Built with clang 21 and -fsanitize=fuzzer,address,undefined, it ran 89 million inputs in 60 seconds without a failure. The same harness pointed at the original excerpt failed on the first, empty input with a heap-buffer-overflow reading data[1]; after adding only the header check, it found the record 00 02, a declared two-byte payload with no bytes, as a memcpy over-read within seconds, and -minimize_crash=1 kept it at two bytes.

libFuzzer's original authors have moved to Centipede, whose code now lives in the Google FuzzTest repository, and LLVM describes libFuzzer as receiving bug fixes but no major new features. A harness written to this entry point is not tied to it: AFL++ builds it with its own compiler wrappers, and OSS-Fuzz and ClusterFuzzLite run it continuously. The native-memory-safety project below applies the same sanitizer workflow to a parser you repair yourself, and /domains/appsec/platform-security/memory-safety-c-cpp/ covers the defect classes the sanitizers report.

Testfuzz_record.cC
// fuzz_record.c: the repaired parser and a libFuzzer harness in one file.
// Build: clang -g -O1 -fsanitize=fuzzer,address,undefined fuzz_record.c -o fuzz_record
// Run:   mkdir -p corpus && ./fuzz_record -max_len=4100 -max_total_time=60 corpus/
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define MAX_PAYLOAD 4096

// Returns the payload length, or -1 for a malformed record. out holds MAX_PAYLOAD bytes.
int parse_record(const uint8_t *data, size_t size, uint8_t *out) {
    if (size < 2) return -1;                 // the header must exist before it is read
    size_t length = ((size_t)data[0] << 8) | data[1];
    if (length > MAX_PAYLOAD) return -1;     // the format maximum
    if (length != size - 2) return -1;       // exact remaining bytes; size >= 2 here
    memcpy(out, data + 2, length);
    return (int)length;
}

// The same entry point works with AFL++, honggfuzz, OSS-Fuzz and ClusterFuzzLite.
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    uint8_t out[MAX_PAYLOAD];
    int n = parse_record(data, size, out);
    // Semantic oracle: an accepted record means exactly what its header says.
    if (n >= 0 && (size != (size_t)n + 2 || n != ((data[0] << 8) | data[1]))) abort();
    return 0;
}

Use the fuzzing engine your language provides

Memory-safe languages still need fuzzing for panics, excessive allocation, hangs and wrong results, and unsafe FFI needs its ownership and length assumptions checked on both sides of the boundary (/domains/appsec/platform-security/rust-unsafe-ffi/). Go has native fuzzing since 1.18: the test below seeds the exercise records and checks the same oracle. Run for 60 seconds with Go 1.27, it executed about 48 million inputs without a failure, and against a version that accepted trailing data it failed within a fraction of a second on the seed 00 01 41 42, saving the input under testdata/fuzz as a regression.

Use the fuzzing engine your language provides
LanguageEngineRun in CI with
C and C++libFuzzer, AFL++, honggfuzz, Google FuzzTestOSS-Fuzz or ClusterFuzzLite
Gogo test -fuzz (Go 1.18+)A time-boxed go test -fuzz job; ClusterFuzzLite
Rustcargo-fuzz (libFuzzer) or cargo-aflOSS-Fuzz or ClusterFuzzLite
PythonAtheris (Python 3.11 to 3.14)OSS-Fuzz or ClusterFuzzLite
Java and other JVM languagesJazzerOSS-Fuzz or ClusterFuzzLite
Testrecord_test.goGo
package record

import (
	"bytes"
	"encoding/binary"
	"testing"
)

// Run: go test -fuzz=FuzzParse -fuzztime=60s
func FuzzParse(f *testing.F) {
	for _, seed := range [][]byte{{}, {0}, {0, 0}, {0, 2, 'A', 'B'}, {0, 8, 'A', 'B'}, {0, 1, 'A', 'B'}} {
		f.Add(seed)
	}
	f.Fuzz(func(t *testing.T, data []byte) {
		out, err := Parse(data)
		if err != nil {
			return
		}
		// Semantic oracle: an accepted record means exactly what its header says.
		if len(out) > maxPayload || len(data) != len(out)+2 ||
			int(binary.BigEndian.Uint16(data)) != len(out) || !bytes.Equal(out, data[2:]) {
			t.Fatalf("accepted %x as %x", data, out)
		}
	})
}

Reduce a failure to the rule it violates

When a native run reports a fault, preserve the exact input bytes, source revision, binary and compiler configuration, sanitizer report and reproduction command. Reproduce with that single input before assuming every crash is the same defect. Minimization removes bytes while preserving the same failure, making the missing precondition visible. The tiny failing record 00 08 41 42 already explains an eight-byte request from a two-byte source payload.

Fix the invariant, not the specimen. Special-casing 00 08 or requiring four total bytes would leave empty, one-byte and larger truncated inputs unresolved. Check header availability before reading either length byte. After that check, comparing length to size - 2 avoids addition overflow patterns common in native size arithmetic. Enforce the format maximum separately, then copy only after all conditions hold. Re-run the minimized fixture, ordinary successful records and nearby boundaries, including integer-overflow and zero-length cases where relevant; keep the reproducer as a lasting regression even after the fuzz corpus changes.

Interpret a clean run as bounded evidence

The supplied program's PASS result means nine named cases and 67,536 generated inputs satisfied these assertions. It does not mean all possible inputs or all production parsers are safe. The exhaustive loop covers every two-byte length for a fixed AB payload, while the mutation loop changes only small supplied seeds. It does not explore native memory ownership, streaming state, Unicode decoding or archive nesting because this format contains none of them.

Likewise, native coverage and execution count describe exercised code, not a proof of security. Track whether meaningful format states were reached, and add semantic or differential oracles when crashes alone cannot identify wrong results. Limit allocation before attempting expensive work. A timeout can signal a complexity defect rather than a memory error. Group failures by cause and assess shipped exposure separately from the mechanics of reproducing a crash in an isolated harness.

A real-world failure

Coverage-guided fuzzing finds exactly this class of bug. CVE-2022-37434 was a heap buffer over-read and overflow in zlib's inflate when processing a large gzip-header extra field, the kind of input a fuzzer reaches quickly. Fuzzing the parser with sanitizers enabled would have caught it before release.

Improve the oracle before increasing execution count

The parser contract includes semantic properties beyond memory safety. If the parser returns only the first declared bytes while ignoring trailing input, it may never crash but still accept E incorrectly. If it returns the length header as part of the payload, a memory sanitizer can remain silent while the decoded value is wrong. Add assertions about accepted structure and exact output; the supplied program checks both length and payload for its exhaustive AB family.

Differential testing can compare independent implementations of the same documented format, but disagreement is a question to investigate, not automatic proof that one chosen reference is correct. Keep the specification as the authority and minimize the differing input. For stateful parsers, reset or explicitly model state between iterations, then include multi-message sequences when the production interface carries state. A stateless harness cannot reveal a bug that requires a prior message to set a mode.

Native sanitizer reports also need careful triage. A use-after-free and a source-buffer over-read may produce failures at the same copy function but have different causes and fixes. Preserve allocation and lifetime evidence, not just the top stack frame. After repair, keep the smallest meaningful regression and resume broader fuzzing to look for adjacent faults. More CPU time helps only when the harness reaches relevant code and the oracle can recognize the failures that matter.

Guided practice

Run or manually evaluate the complete parser above; all input values are provided.

Supplied scenario material
Plain text
Format: 2-byte big-endian length; payload 0..4096; exact length; no trailing data.
A=00 02 41 42; B=00 08 41 42; C=00; D=00 00; E=00 01 41 42.
F=10 00 followed by 4096 bytes 41. G=10 01 followed by 4097 bytes 41.
The full runnable file and command are supplied in the article. No external packages are required.
  1. 1. Calculate the declared and remaining lengths

    For A–E, write header length, remaining bytes and the expected result.

    Result for each input

    A = 00 02 41 42
    B = 00 08 41 42
    C = 00
    D = 00 00
    E = 00 01 41 42

    Expected result and reasoning

    A: 2/2 -> AB. B: 8/2 -> reject. C lacks a complete header -> reject before reading data[1]. D: 0/0 -> empty payload. E: 1/2 -> reject trailing data.

  2. 2. Apply the maximum boundary

    Compute hexadecimal 10 00 and 10 01 and decide F/G.

    Expected result and reasoning

    10 00 is 4096 and F is accepted. 10 01 is 4097 and G is rejected despite containing exactly the declared payload. Remaining-length correctness and permitted maximum are separate checks.

  3. 3. Run the bounded experiment

    Copy the complete code into parser_demo.py and run python3 parser_demo.py. Compare the result with the claimed experiment scope.

    Expected result and reasoning

    Expected output: PASS: 9 boundary fixtures and 67536 generated inputs. The generated count is 65536 header pairs plus 2000 deterministic mutations; it is not a coverage percentage.

  4. 4. Reject a specimen-only patch

    A proposed repair rejects length 8 but otherwise copies the declared length. Use C and E to explain remaining defects.

    Expected result and reasoning

    C can still read a missing header byte in native code. E still violates exact-length/no-trailing-data semantics. The repair must enforce the complete preconditions for every input rather than block the observed value 8.

Your deliverable: A passing bounded parser exercise plus a length/acceptance table and an explanation of its limits.

Requires Python 3 for execution; the paper calculation uses only the supplied hex values. The C and Go harnesses above fuzz the same format with real engines.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Python 3.13+ · Expat 2.6.0+ · standard library · 60–90 min

Parse bounded JSON and XML invoices safely

Repair a real JSON/XML parser boundary: reject duplicate fields, coercion, unknown schema, DTDs and oversized documents while keeping valid invoices usable.

In the extracted folder, run:

Shell
python3 -m unittest -v test_parsing.py

24 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.

Actual bounded JSON and Expat parsing; no arbitrary object deserialization, external entities, network requests or invoice authorization. Narrow invoice vocabulary, not a general XML schema engine.

Clang + Python 3.11+ · 45–75 min

Repair a native parser using sanitizer evidence

Compile a C parser with AddressSanitizer, observe a real out-of-bounds access, and repair allocation, length and output-capacity checks against boundary tests and 80 generated frames.

Needs: A local Clang with the AddressSanitizer and UndefinedBehaviorSanitizer runtimes.

In the extracted folder, run:

Shell
python3 reproduce.py
python3 test_parser.py

11 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Actual Clang address/undefined-behavior instrumentation on generated local files; 4 KiB harness input cap. Requires a compiler and sanitizer runtime.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. LLVM libFuzzer documentation
  2. Clang AddressSanitizer documentation
  3. AFL++ documentation
  4. Google FuzzTest
  5. OSS-Fuzz documentation
  6. ClusterFuzzLite documentation
  7. Go: fuzzing
  8. NVD: CVE-2022-37434, heap overflow in zlib inflate

Reference information

SDLC phase
Testing
ASVS 5.0.0 requirements
v5.0.0-1.4.1 · v5.0.0-1.4.2
CAPEC attack patterns

Download this guide as Markdown