Coverage-guided fuzzing of structured inputs (libFuzzer, AFL++, cargo-fuzz, arbitrary, grammar fuzzing, parser resource limits)
Document status:
reviewed. Canonical source.
Summary
Coverage-guided fuzzers (libFuzzer, AFL++) mutate a corpus of byte inputs and keep mutations that reach new coverage. For structured inputs, three approaches exist: custom mutators that parse, mutate and re-serialise (libFuzzer LLVMFuzzerCustomMutator, AFL++ afl_custom_fuzz, libprotobuf-mutator, Grammar-Mutator); grammar-based generators with tree mutation and coverage feedback (Nautilus); and parametric generators that decode the fuzzer’s byte stream into a typed value so that byte mutations become structural mutations (Zest; Rust arbitrary with cargo fuzz). Parser fuzz targets must be deterministic, fast and bounded; libFuzzer enforces -timeout, -rss_limit_mb and -malloc_limit_mb, OSS-Fuzz flags inputs over about 25 seconds or 2.5 GB, and production parsers (usvg, roxmltree, serde_json, image, Skia) enforce nesting depth, node counts and allocation budgets in code.
For NUIF the codec, extension payload handling and path geometry are untrusted-input parsers under spec/11-security.md; the same Arbitrary-based generator can feed both fuzz targets and the trial-and-error loop.
Evidence
- libFuzzer target contract:
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size); the target “must tolerate any kind of input”, “must notexit()”, “must be as deterministic as possible”, “must be fast”, and ideally not modify global state; narrower targets are better. https://llvm.org/docs/LibFuzzer.html, §Fuzz Target, retrieved 2026-08-29. - Coverage comes from SanitizerCoverage inline 8-bit counters via
-fsanitize=fuzzer; mutations that reach a previously uncovered path are added to the corpus. Same page, §Corpus. - Options:
-max_len(0 = guess from corpus),-len_control,-timeout(default 1200 s),-rss_limit_mb(default 2048),-malloc_limit_mb(single allocation cap),-dict,-use_value_profile,-jobs/-workers,-minimize_crash,-merge. Same page, §Options. - Structure-aware fuzzing: custom mutator
size_t LLVMFuzzerCustomMutator(uint8_t *Data, size_t Size, size_t MaxSize, unsigned int Seed)parses per grammar, mutates, re-serialises; libprotobuf-mutator withDEFINE_PROTO_FUZZERuses protobuf as the intermediate format (SQLite example). https://github.com/google/fuzzing/blob/master/docs/structure-aware-fuzzing.md, retrieved 2026-08-29. - AFL++: LTO instrumentation preferred;
-mmemory limit “highly recommend”;-ttimeout; dictionaries via-xandAFL_LLVM_DICT2FILE; CMPLOG/Redqueen viaAFL_LLVM_CMPLOG=1and-c;afl-cminandafl-tmin. https://github.com/AFLplusplus/AFLplusplus/blob/stable/docs/fuzzing_in_depth.md, retrieved 2026-08-29. - AFL++ custom mutator API:
afl_custom_init,afl_custom_fuzz,afl_custom_post_process,afl_custom_trim,afl_custom_havoc_mutation,afl_custom_queue_get;AFL_CUSTOM_MUTATOR_LIBRARY,AFL_CUSTOM_MUTATOR_ONLY. https://github.com/AFLplusplus/AFLplusplus/blob/stable/docs/custom_mutators.md, retrieved 2026-08-29. Paper: Fioraldi, Maier, Eißfeldt, Heuse, WOOT 2020, §3.2.1 (havoc probability 6%), §3.2.2 Input-To-State (https://aflplus.plus/papers/aflpp-woot2020.pdf, retrieved 2026-08-29). - Grammar-Mutator: AFL++ custom mutator for “highly-structured inputs” with JSON grammars, tree-based mutations (rules, random, random recursive, splicing) and tree-based trimming;
grammar_generator-<lang> 100 1000 ./seeds ./treescreates 100 seeds of max tree size 1000. https://github.com/AFLplusplus/Grammar-Mutator README, retrieved 2026-08-29. - cargo-fuzz: “a tool to invoke a fuzzer”, libFuzzer via
libfuzzer-sys, nightly required; commandscargo fuzz init|add|list|run|tmin|cmin|coverage|fmt;cargo fuzz coveragebuilds with-Cinstrument-coverageand writesfuzz/coverage/<target>/coverage.profdata; crash artefacts underfuzz/artifacts/<target>/. https://rust-fuzz.github.io/book/cargo-fuzz.html, /cargo-fuzz/tutorial.html, /cargo-fuzz/coverage.html and https://github.com/rust-fuzz/cargo-fuzz README, retrieved 2026-08-29. afl.rs alternative:cargo afl build,cargo afl fuzz -i in -o out <bin>,fuzz!macro (https://rust-fuzz.github.io/book/afl.html). fuzz_target!accepts|data: &[u8]|or|input: T|forT: Arbitrary, aninit:block, and an optional-> Corpusreturn (Corpus::Keep/Corpus::Reject); inputs whoseArbitrarydecoding fails are rejected. https://docs.rs/libfuzzer-sys/latest/libfuzzer_sys/macro.fuzz_target.html and https://rust-fuzz.github.io/book/cargo-fuzz/structure-aware-fuzzing.html, retrieved 2026-08-29.- arbitrary 1.4.2:
trait Arbitrary<'a>: Sized { fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self>; fn arbitrary_take_rest(u: Unstructured<'a>) -> Result<Self>; fn size_hint(depth: usize) -> (usize, Option<usize>); fn try_size_hint(depth: usize) -> Result<(usize, Option<usize>), MaxRecursionReached> };#[derive(Arbitrary)]with thederivefeature. https://docs.rs/arbitrary/latest/arbitrary/trait.Arbitrary.html and https://github.com/rust-fuzz/arbitrary README, retrieved 2026-08-29. Unstructuredmethods:int_in_range(not necessarily uniform; returns range start on empty data),choose,choose_index,ratio,arbitrary_len(uses elementsize_hint, takes lengths “from the end of the data”),bytes,take_rest,arbitrary_iter. https://docs.rs/arbitrary/latest/arbitrary/struct.Unstructured.html andsrc/unstructured.rs, retrieved 2026-08-29.- Recursion behaviour:
size_hint::MAX_DEPTH = 20guards onlysize_hintcomputation (src/size_hint.rsline 4, lines 38–47);ArbitraryIter::nextdraws abool“keep going” flag that is false on exhausted data, soVec<T>and recursive children terminate when bytes run out (src/unstructured.rs,src/foreign/alloc/vec.rs,src/foreign/core/bool.rs). Retrieved 2026-08-29. NUIF interpretation: depth must be bounded explicitly inarbitraryimplementations. - Grammar fuzzing: derivation trees with expansion phases bounded by
min_nonterminals/max_nonterminals(https://www.fuzzingbook.org/html/GrammarFuzzer.html); greybox grammar fuzzing combines fragment or region mutation with coverage-guided seed selection (https://www.fuzzingbook.org/html/GreyboxGrammarFuzzer.html). Retrieved 2026-08-29. - Nautilus: combining context-free grammars with feedback-driven fuzzing outperforms AFL “by an order of magnitude”; mutations on derivation trees (random with a configurable maximum subtree size, rules, random recursive 2^n repetitions, splicing) plus subtree and recursive minimisation; AFL-style 64 KB bitmap. Aschermann et al., NDSS 2019, DOI 10.14722/ndss.2019.23412, §IV.A–C, §V (PDF https://www.ndss-symposium.org/wp-content/uploads/2019/02/ndss2019_04A-3_Aschermann_paper.pdf, retrieved 2026-08-29).
- Zest: “converts random-input generators into deterministic parametric generators”; mutations in the untyped parameter domain map to structural mutations; every parameter sequence yields a syntactically valid input if the generator does; the algorithm tracks total and valid coverage and saves inputs that add valid coverage; the XML generator bounds
MAX_DEPTHandMAX_CHILDREN. Padhye et al., ISSTA 2019, DOI 10.1145/3293882.3330576, Abstract, §3.1–3.2, Fig. 2 (PDF https://rohan.padhye.org/files/zest-issta19.pdf, retrieved 2026-08-29). - OSS-Fuzz: default engines libfuzzer, afl, honggfuzz, centipede; seed corpus
<target>_seed_corpus.zip,<target>.dict,<target>.optionswith[libfuzzer]keys such asmax_len,rss_limit_mb = 6000,timeout = 30; inputs over “~25 seconds or more than 2.5GB RAM” are reported as timeout or OOM bugs; Rust projects build withcargo fuzz build -Oonbase-builder-rust. https://google.github.io/oss-fuzz/getting-started/new-project-guide/, /new-project-guide/rust-lang/, /faq/, retrieved 2026-08-29. Ideal integration: targets live in the project repository, run in regression CI, ship dictionaries and seed corpora, must not hang or exhaust memory instantly. https://google.github.io/oss-fuzz/advanced-topics/ideal-integration/. - usvg parser limits (
crates/usvg/src/parser/svgtree/parse.rs,main, retrieved 2026-08-29):if depth > 1024 { return Err(Error::NodesLimitReached); }inparse_xml_node;if doc.nodes.len() > 1_000_000 { return Err(Error::NodesLimitReached); };useself-reference checkif link == node || link == origin;fix_recursive_patterns,fix_recursive_links,fix_recursive_fe_imagereplace self-referential paint/clip/mask/filter links. roxmltreeParsingOptions { allow_dtd, nodes_limit }andError::EntityReferenceLoop(depth limit 10, 255 references per reference). https://docs.rs/roxmltree/latest/roxmltree/, retrieved 2026-08-29. No fuzz directory exists in the resvg repository and no OSS-Fuzzprojects/resvgentry was found. - Skia:
fuzz/Fuzz.hwraps the byte buffer withnext<T>(),nextRange(min, max),exhausted();FuzzCanvas(Fuzz*, SkCanvas*, int depth = 9)returns whendepth <= 0or the buffer is exhausted, draws up to 2000 ops and recurses withdepth - 1into paints, image filters and pictures;fuzz/oss_fuzz/FuzzSVG.cpprejects inputs over 30,000 bytes and renders the SVG DOM to a 128×128 surface. https://github.com/google/skia,fuzz/Fuzz.h,fuzz/FuzzCanvasHelpers.h,fuzz/FuzzCanvasHelpers.cpp,fuzz/oss_fuzz/FuzzSVG.cpp,main, retrieved 2026-08-29. - image 0.25.10:
Limits { max_image_width, max_image_height, max_alloc }withmax_allocdefault 512 MiB andreserve/freeaccounting; fuzz targets such asfuzz/fuzzers/fuzzer_script_png.rscallimage::load_from_memory_with_format(data, ImageFormat::Png). https://docs.rs/image/latest/image/struct.Limits.html and https://github.com/image-rs/image/tree/main/fuzz, retrieved 2026-08-29. - serde_json:
remaining_depth: 128withcheck_recursion!returningRecursionLimitExceeded;disable_recursion_limitrequires theunbounded_depthfeature and the docs recommend another stack-overflow guard. https://docs.rs/serde_json/latest/serde_json/struct.Deserializer.html#method.disable_recursion_limit, retrieved 2026-08-29. - fontations:
cargo +nightly fuzz build -O --debug-assertions;fuzz_skrifa_outline.rsiterates glyphs over size, location, hinting and memory variants;helpers.rscaps variation axes at 5. https://github.com/googlefonts/fontations/tree/main/fuzz, retrieved 2026-08-29.
Mechanism
// Structure-aware target over the NUIF document model (synthesis; attributions inline)
#[derive(Arbitrary, Debug)]
struct FuzzDoc { root: FuzzNode, context: FuzzContext }
impl<'a> Arbitrary<'a> for FuzzNode { // manual impl: explicit depth bound (Zest Fig. 2; Skia depth = 9)
fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> { gen_node(u, 0) }
}
fn gen_node(u, depth) -> Result<FuzzNode> {
let n_children = if depth >= MAX_DEPTH { 0 } else { u.int_in_range(0..=MAX_CHILDREN)? };
... // arbitrary_len / arbitrary_iter stop on exhausted bytes
}
fuzz_target!(|doc: FuzzDoc| -> Corpus { // libfuzzer-sys; decoding failure auto-rejected
let bytes = encode_canonical(&doc.into_document()); // bounded by construction
if bytes.len() > MAX_INPUT { return Corpus::Reject; }
let decoded = decode_with_limits(&bytes, Limits { depth: 256, entities: 100_000, alloc: 256 MiB });
match decoded {
Err(e) if e.is_limit() => Corpus::Keep, // limits must fire, not overflow (usvg, serde_json)
Err(_) => Corpus::Reject,
Ok(d) => { assert_eq!(encode_canonical(&d), bytes); layout_with_budget(&d, &doc.context); Corpus::Keep }
}
});
Run configuration: cargo fuzz run codec_roundtrip -- -max_len=65536 -timeout=10 -rss_limit_mb=2048 -malloc_limit_mb=512 -dict=fuzz/nuif.dict -use_value_profile=1 -jobs=8; cargo fuzz cmin after each campaign; cargo fuzz tmin on crashes; cargo fuzz coverage to compare corpus coverage with the property-based generator.
Budget classes to enforce inside NUIF parsers, with source examples: nesting depth (usvg 1024; serde_json 128; roxmltree entity depth 10), node or entity count (usvg 1,000,000; roxmltree nodes_limit), input size (Skia 30,000 bytes for SVG; libFuzzer -max_len), allocation (image max_alloc 512 MiB; libFuzzer -malloc_limit_mb), reference cycles (usvg fix_recursive_*), and time (libFuzzer -timeout; OSS-Fuzz 25 s).
Invariants: the target is deterministic for a given input; every limit produces a typed error rather than a panic or stack overflow; decoded documents re-encode to identical bytes; extension payloads decoded from arbitrary bytes are preserved verbatim.
NUIF relevance
Borrow
cargo fuzz/libFuzzer raw byte targets for every untrusted parser and a parametric byte choice stream for valid semantic operations, matching thefuzz parsers/codecs and path geometrytechnique inconformance/PLAN.md.- Concrete limit values from usvg, serde_json and image as starting points for the bounds that
spec/11-security.mdrequires (depth, entity count, allocation, cycle detection). - libFuzzer’s target contract (deterministic, no exit, no global state) as the acceptance rule for every headless engine entry point exposed through
spec/12-cli-api-and-automation.md. - Zest’s valid-coverage feedback: keep corpus inputs that are valid documents and add coverage, reject structurally invalid ones so the corpus stays useful for the round-trip loop.
Adapt
- Depth bounding must be explicit in
Arbitraryimplementations becausearbitrary’sMAX_DEPTHguards only size hints; NUIF should generate nodes with a depth parameter as in Zest and Skia. - The same typed operation choice-stream mapper is reusable by deterministic trials and coverage-guided fuzzing; it delegates values and invariants to production operation types instead of serializing a second document model (Zest parametric generators; Hypothesis choice sequences).
- Extension payloads are opaque bytes and should be fuzzed for preservation, not for interpretation: the oracle is byte equality after round trip (
rfcs/0002-extension-preservation.md).
Reject
- Grammar-mutator custom mutators (AFL++ Grammar-Mutator, libprotobuf-mutator) are unnecessary while the Rust model already provides a typed generator; they add a second grammar to maintain.
- A raw-byte-only campaign with no valid corpus. NUIF deliberately retains raw malformed parser inputs but regenerates valid canonical/package/resource/source seeds from production fixtures so mutations reach post-parse relations.
Implemented decision
fuzz/ is a standalone workspace pinned to nightly 2026-08-28,
cargo-fuzz 0.13.2 and libfuzzer-sys 0.4.13. Five targets separate codec,
package/archive, PNG/font, static-source-adapter and valid operation concerns.
Each owns a generated target-specific corpus under ignored target/; corpora
are not shared across incompatible input selectors. CPU rendering is sampled
only after valid typed operations, while GPU execution is excluded from the
security fuzzer because it has a separate nondeterministic process/device risk
boundary. cargo xtask fuzz-smoke applies 10-second, 512-MiB allocation and
2-GiB RSS limits, records every target, and CI runs 512 inputs per target under
AddressSanitizer. Format resource limits remain normative where declared by
spec/11-security.md; campaign limits are implementation test budgets rather
than wire-profile requirements.