---
key: "parser-fuzzing"
title: "Fuzz a parser and turn crashes into lasting regressions"
url: "https://softwaresecurity.com/domains/appsec/testing/parser-fuzzing/"
api: "https://softwaresecurity.com/api/v1/articles/parser-fuzzing.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-130","CWE-125","CWE-1284","V1.4.1","V1.4.2","CAPEC-28"]
tags: ["Input validation and parsing","Memory safety","Security testing","C, C++ and Rust"]
stages: ["test"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Fuzz a parser and turn crashes into lasting regressions

Build a narrow harness, observe memory and resource failures, minimize the input, and test the repaired parser.

Canonical URL: https://softwaresecurity.com/domains/appsec/testing/parser-fuzzing/

## 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.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| C and C++ build | `clang -g -O1 -fsanitize=fuzzer,address,undefined` | Record the compiler version and flags with every crash. | LLVM libFuzzer documentation |
| libFuzzer run budget | `-max_len` near the format maximum, `-max_total_time`, `-rss_limit_mb` (default 2048) and `-timeout` (default 1200 s) | — | LLVM libFuzzer documentation |
| Engine status | libFuzzer receives bug fixes only; AFL++ and Google FuzzTest are actively developed | — | LLVM libFuzzer documentation: status |
| Go | `go test -fuzz=FuzzName -fuzztime=60s` (Go 1.18 and later) | — | Go fuzzing documentation |
| Continuous fuzzing | OSS-Fuzz for open-source projects; ClusterFuzzLite in CI for any repository | — | OSS-Fuzz and ClusterFuzzLite documentation |

## 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.

## 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.

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

Before you start:

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

Time: About 30 minutes: 8 to read, 4 on the code and 18 for the exercise

## 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.

```python
# 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.

```c
// 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.

```go
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)
		}
	})
}

```

| Language | Engine | Run in CI with |
| --- | --- | --- |
| C and C++ | libFuzzer, AFL++, honggfuzz, Google FuzzTest | OSS-Fuzz or ClusterFuzzLite |
| Go | `go test -fuzz` (Go 1.18+) | A time-boxed `go test -fuzz` job; ClusterFuzzLite |
| Rust | cargo-fuzz (libFuzzer) or cargo-afl | OSS-Fuzz or ClusterFuzzLite |
| Python | Atheris (Python 3.11 to 3.14) | OSS-Fuzz or ClusterFuzzLite |
| Java and other JVM languages | Jazzer | OSS-Fuzz or ClusterFuzzLite |

## 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 material

```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. Calculate the declared and remaining lengths

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

Worked answer: A: 2/2 -&gt; AB. B: 8/2 -&gt; reject. C lacks a complete header -&gt; reject before reading `data[1]`. D: 0/0 -&gt; empty payload. E: 1/2 -&gt; reject trailing data.

### 2. Apply the maximum boundary

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

Worked answer: `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. 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.

Worked answer: 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. 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.

Worked answer: 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.

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

Scope: 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.

## Executable practice

- [Parse bounded JSON and XML invoices safely: project ZIP](https://softwaresecurity.com/downloads/projects/bounded-data-parsing-aa166a936b50b8b5.zip) — Python 3.13+ · Expat 2.6.0+ · standard library
- [Repair a native parser using sanitizer evidence: project ZIP](https://softwaresecurity.com/downloads/projects/native-memory-safety-c6897059c95e033a.zip) — Clang + Python 3.11+

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [LLVM libFuzzer documentation](https://llvm.org/docs/LibFuzzer.html)
- [Clang AddressSanitizer documentation](https://clang.llvm.org/docs/AddressSanitizer.html)
- [AFL++ documentation](https://aflplus.plus/docs/)
- [Google FuzzTest](https://github.com/google/fuzztest)
- [OSS-Fuzz documentation](https://google.github.io/oss-fuzz/)
- [ClusterFuzzLite documentation](https://google.github.io/clusterfuzzlite/)
- [Go: fuzzing](https://go.dev/doc/security/fuzz/)
- [NVD: CVE-2022-37434, heap overflow in zlib inflate](https://nvd.nist.gov/vuln/detail/cve-2022-37434)