json-fuzz

July 27, 2026 · View on GitHub

A reusable, structure-aware JSON fuzzer for Go JSON parsers. It provides a grammar-based JSON generator, a suite of JSON-aware mutation operators, adversarial key-path generation, a seed corpus covering every known parser bug class, and correctness gates (assertions) that any parser project can wire into its fuzz harness in ~10 lines.

Extracted from the fuzz infrastructure that found real bugs in buger/jsonparser.

Why structure-aware fuzzing?

Blind libFuzzer mutation reaches the dangerous structural boundaries of a JSON parser (truncation right after a :, truncation inside a \uXXXX escape, an unmatched { with no closer) only after exponentially many trials. json-fuzz replaces blind mutation with a grammar-based generator that always emits valid JSON, then applies JSON-aware mutation operators that break that JSON at precisely the byte positions where the parser's state machine has to make a transition.

Bugs found in jsonparser

This fuzzer found the following real bugs in buger/jsonparser:

Bug classDetails
Empty-key panics (8 sites)Passing "" as a key path component caused nil-pointer dereferences in searchKeys, EachKey, createInsertComponent, calcAllocateSpace, and the parser.go:981 site.
Lone-surrogate synthesisThe escape decoder accepted lone surrogates (\uD800 alone) and synthesized invalid code points instead of rejecting them.
Delete trailing-comma malformationDelete on certain object keys left a trailing comma ({"a":1,}), producing invalid JSON.
Cross-type Set corruptionSet with an array-index path ([N]) on an object root appended ,value without a key, producing invalid JSON like {"co":1,42}.
Scalar-array data lossSet at an index beyond array length silently dropped data in certain configurations.
ArrayEach non-array-root spurious callbackArrayEach on a non-array root fired the callback with stale offsets.

Quick start

Implement ParserTarget (and the optional extension interfaces your parser supports), then wire the fuzzer into a _test.go file:

package myparser_test

import (
    "testing"

    "github.com/probelabs/json-fuzz"
)

// myTargetAdapter adapts your parser to the jsonfuzz.ParserTarget interface.
type myTargetAdapter struct{}

func (a *myTargetAdapter) Get(data []byte, path ...string) ([]byte, jsonfuzz.DataType, int, error) {
    val, dt, off, err := myparser.Get(data, path...)
    return val, jsonfuzz.DataType(dt), off, err
}
// ... implement NumberParser / StringParser / etc. as your parser supports.

func FuzzMyParser(f *testing.F) {
    jsonfuzz.AddCorpus(f) // seeds all dangerous inputs
    f.Fuzz(func(t *testing.T, data []byte, path []byte) {
        jsonfuzz.RunStructureAware(t, data, path, &myTargetAdapter{})
    })
}

Run it:

go test -fuzz=FuzzMyParser -fuzztime=60s

Manual gate wiring

For fine-grained control, use the Gates helper directly:

func FuzzMyParser(f *testing.F) {
    jsonfuzz.AddCorpus(f)
    f.Fuzz(func(t *testing.T, data []byte, path []byte) {
        gates := jsonfuzz.NewGates(t, data, path)
        gates.NoPanic(func() { myparser.Get(data, jsonfuzz.SplitPath(path)...) })
        if result, err := myparser.Set(data, val, jsonfuzz.SplitPath(path)...); gates.NoError(err) {
            gates.OutputValidJSON(result)
            gates.RoundTrip(result, path, val, func(d []byte, p ...string) ([]byte, error) {
                v, _, _, e := myparser.Get(d, p...)
                return v, e
            })
        }
    })
}

API reference

Generator

Grammar-based JSON generation. Always emits valid JSON (RFC 8259); the mutation operators then break it at known-dangerous boundaries.

FunctionDescription
GenJSON(r *rand.Rand) []byteGenerate a valid JSON document (random depth).
GenJSONAtDepth(r *rand.Rand, depth int) []byteGenerate at explicit depth.
GenValue(r, depth, &buf)Append one weighted-random JSON value.
GenObject(r, depth, &buf)Emit an object with 0–5 adversarial keys.
GenArray(r, depth, &buf)Emit an array with 0–5 elements.
GenKey(r, &buf)Emit an object key (16 cases: empty, unicode, surrogate, invalid-UTF-8, BOM, …).
GenString(r, &buf)Emit a string value (22 cases: escapes, multibyte UTF-8, surrogates, control chars, …).
GenNumber(r, &buf)Emit a number (20 cases: int64 boundaries, exponents, negative zero, Infinity text, …).
GenDeepNesting(depth, closed) []byteEmit deeply-nested [[[...]]] (CVE-2020-29652 class).
MaxGenDepthMaximum generation depth (5).

Mutations

JSON-aware mutation operators. Each returns a NEW slice (never mutates in place). ApplyMutation picks one at random; each operator is also exported for direct use.

OperatorTarget boundary
ApplyMutation(r, data) []bytePick one operator at random.
TruncateAtValueBoundaryCut right after : (OSS-Fuzz sentinel_value_boundary class).
TruncateMidKeyCut inside a string key.
TruncateMidStructureCut after unclosed { or [.
TruncateMidEscapeCut inside \uXXXX.
TruncateMidElementCut inside an array element.
DuplicateKeyInject duplicate object key.
ByteflipAtStructuralByteFlip byte at {,},[,],:,,,".
InjectInvalidUTF8Insert overlong/truncated/never-valid UTF-8 in a string.
InjectLoneSurrogateCorrupt \uXXXX into a lone surrogate.
InjectUnbalancedOpenInsert unmatched { or [.
InjectMismatchedCloserSwap } for ] or vice versa.

Path utilities

FunctionDescription
SplitPath(path []byte) []stringSplit fuzz path bytes on / (empty segments → "" components).
GenKeyPath(r) []stringGenerate an adversarial multi-component path.
GenPathComponent(r) stringGenerate one adversarial component (empty key, [99], unicode, …).
InjectPathHazard(r, path) []stringInject empty-key or OOB-index hazard.
PathShapeMatchesRoot(data, path) boolCheck root container-type compatibility.
PathShapeMatchesData(data, path, resolve) boolDeep per-level container-type check.

Gates

Correctness assertions. Construct via NewGates, then call methods as you exercise your parser.

MethodGateBug class caught
NewGates(t, data, path) *GatesConstruct for one fuzz iteration.
NoPanic(fn)1. NO-PANICEmpty-key panics, truncation panics, sentinel-deref.
OutputValidJSON(data)2. OUTPUT-VALIDITYSilent data corruption after Set/Delete.
RoundTrip(doc, path, expected, get)3. ROUND-TRIPSet/Get asymmetry, index-beyond-length data loss.
NumericOracle(token, got, err)4. NUMERIC-ORACLE (int)ParseInt overflow / leading-zero divergence.
NumericOracleFloat(token, got, err)4b. NUMERIC-ORACLE (float)ParseFloat precision / overflow divergence.
OffsetBounds(offset, dataLen)5. OFFSET-BOUNDSOOB index at call site.
Determinism(fn)6. DETERMINISMMap-iteration / stale-state bugs.
SliceAliasing(slice, input)7. ALIASINGSynthesized/stale slice corruption.
ParseStringDiff(raw, got, err)8. PARSESTRINGUnescape bugs on \uXXXX, surrogates, control chars.
ParseBooleanDiff(token, got, err)9. PARSEBOOLEANBoolean coercion divergence.
NoError(err)Convenience: gate post-conditions on op success.

Corpus

FunctionDescription
AddCorpus(f)Register ALL seeds (data + path) with a *testing.F.
AddCorpusDataOnly(f)Register data-only seeds (for single-arg fuzz functions).
StructureAwareSeeds() []SeedPairRaw structure-aware seed pairs.
PathMutationSeeds() []SeedPairRaw path-mutation seed pairs.
EncodingJSONSeeds() [][]byteRaw encoding/json data-only seeds.

Target interfaces

InterfaceMethodsGates unlocked
ParserTarget (required)GetNoPanic, OffsetBounds, Determinism
NumberParserParseInt, ParseFloatNumericOracle, NumericOracleFloat
StringParserParseStringParseStringDiff
BooleanParserParseBooleanParseBooleanDiff
IteratingParserArrayEach, ObjectEachNoPanic on iteration + NumericOracle on elements
AliasingParserAliasesInput() boolSliceAliasing (opt-in; byte-oriented parsers only)
MutatingTargetSet, DeleteOutputValidJSON, RoundTrip
OrchestratorDescription
RunStructureAware(t, data, path, target)Full structure-aware fuzz body (generator + mutations + all gates).
RunSequence(t, data, path, target)Multi-operation sequence fuzzer (S1–S5: Set→Get, Delete→Get, Set→Set, Delete→Delete, Set→Delete→Set). Requires MutatingTarget.

The encodingjson sub-package

github.com/probelabs/json-fuzz/encodingjson is a self-contained structure- aware fuzzer that probes Go's standard-library encoding/json for internal consistency. It does not require a ParserTarget — call Fuzz directly:

package stdlib_test

import (
    "testing"
    "github.com/probelabs/json-fuzz/encodingjson"
)

func FuzzEncodingJSON(f *testing.F) {
    encodingjson.Fuzz(f)
}

Gates exercised (each maps to a known stdlib bug class):

GateAssertion
A. NO-PANICjson.Unmarshal must never panic (recover-wrapped).
B. VALID CONSISTENCYjson.Valid and json.Unmarshal must agree.
C. ROUND-TRIPUnmarshal → Marshal → Unmarshal must DeepEqual (both interface{} and json.Number variants).
D. DEEP-NESTING[[[...]]] must error cleanly, never stack-overflow (CVE-2020-29652 class).
E. STREAMINGDecoder.Decode and Unmarshal must agree on valid JSON.
F. TRANSFORMSCompact/Indent/HTMLEscape must never panic.
H. TRANSFORM DoSCompact/Indent/HTMLEscape on deep nesting must be budget-bounded.
I. MARSHAL DETERMINISMMarshal on the same value is byte-identical.

How to run

# Run the self-tests (no -fuzz needed):
go test ./... -count=1 -race

# Fuzz your parser:
go test -fuzz=FuzzMyParser -fuzztime=60s

# Fuzz encoding/json:
go test -fuzz=FuzzEncodingJSON -fuzztime=60s

License

MIT, same as the Go ecosystem.