feat(fuzz): add coverage-guided fuzz harnesses for zebra-network and zebra-consensus - #11221
feat(fuzz): add coverage-guided fuzz harnesses for zebra-network and zebra-consensus#11221robustfengbin wants to merge 11 commits into
Conversation
…zebra-consensus Adds a `zebra-fuzz/` directory with 15 libFuzzer targets built via cargo-fuzz, and a default-off `fuzzing` feature in zebra-network and zebra-consensus that makes `protocol` and `block` visible to the harnesses. Default and release builds are unchanged: the feature is off by default and adds no dependencies. The harnesses use exactly three types from `protocol::external` and five functions from `block::check`. The fuzz directory is its own cargo workspace, so the root build does not see it.
Each target ships a `<target>_seed_corpus.zip` under `zebra-fuzz/fuzz/seeds/`, minimized with `cargo fuzz cmin` (libFuzzer `-merge=1`), which produces the smallest subset preserving every coverage feature. 68,645 files / 110 MB of raw corpora reduce to 14,648 files / 24 MB, packed as 15 archives totalling 17.5 MB. The seeds are derived from Zcash mainnet data -- blocks, transactions, and P2P messages -- together with mutations of those inputs produced during fuzzing. Every input traces back to public chain data. The working corpus directory `zebra-fuzz/fuzz/corpus/` is gitignored; cargo-fuzz writes there at runtime.
Documents the harness layout, the `fuzzing` feature, the 15 targets, the
seed corpora, and the failure mode of a stale `fuzz/Cargo.lock`.
Also removes comments that named a specific Zebra release while describing
current state: they were accurate when written and are not now. Comments
recording history ("v6.0.0 introduced Transaction::V6") are kept.
libFuzzer dictionaries are looked up by target name, so `dicts/tx.dict` was never shipped with any target. Ship the same token set under the names of the four targets that parse transactions.
There was a problem hiding this comment.
Pull request overview
Adds an isolated OSS-Fuzz suite covering Zebra’s network, consensus, RPC, script, serialization, and Ironwood surfaces.
Changes:
- Adds 15 fuzz targets with dictionaries, corpora, and documentation.
- Adds opt-in
fuzzingfeatures exposing internal network and consensus modules. - Adds seed-generation and standalone fuzz-workspace support.
The PR satisfies the contribution metadata requirements, but harness oracle and OSS-Fuzz corpus-integration issues remain.
Reviewed changes
Copilot reviewed 29 out of 45 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
zebra-network/src/lib.rs |
Gates public protocol access for fuzzing. |
zebra-network/Cargo.toml |
Declares the fuzzing feature. |
zebra-network/CHANGELOG.md |
Documents the feature. |
zebra-consensus/src/lib.rs |
Gates public block access for fuzzing. |
zebra-consensus/Cargo.toml |
Declares the fuzzing feature. |
zebra-consensus/CHANGELOG.md |
Documents the feature. |
zebra-fuzz/README.md |
Documents targets and operation. |
zebra-fuzz/fuzz/Cargo.toml |
Defines the standalone fuzz workspace. |
zebra-fuzz/fuzz/Cargo.lock |
Locks fuzz dependencies. |
zebra-fuzz/fuzz/.gitignore |
Ignores generated fuzz data. |
zebra-fuzz/fuzz/seed_gen.rs |
Generates V6/Ironwood seeds. |
zebra-fuzz/fuzz/fuzz_targets/rpc_handler_fuzz.rs |
Fuzzes RPC handlers. |
zebra-fuzz/fuzz/fuzz_targets/jsonrpsee_envelope_fuzz.rs |
Fuzzes JSON-RPC envelopes. |
zebra-fuzz/fuzz/fuzz_targets/script_verify_fuzz.rs |
Fuzzes script verification FFI. |
zebra-fuzz/fuzz/fuzz_targets/script_flag_matrix_fuzz.rs |
Fuzzes script flag combinations. |
zebra-fuzz/fuzz/fuzz_targets/address_fuzz.rs |
Fuzzes address parsers. |
zebra-fuzz/fuzz/fuzz_targets/note_commitment_tree_fuzz.rs |
Fuzzes commitment trees. |
zebra-fuzz/fuzz/fuzz_targets/equihash_fuzz.rs |
Fuzzes Equihash verification. |
zebra-fuzz/fuzz/fuzz_targets/block_deserialize.rs |
Fuzzes block decoding. |
zebra-fuzz/fuzz/fuzz_targets/block_deep_fuzz.rs |
Fuzzes block consensus paths. |
zebra-fuzz/fuzz/fuzz_targets/p2p_message_parse.rs |
Fuzzes P2P framing. |
zebra-fuzz/fuzz/fuzz_targets/p2p_deep_fuzz.rs |
Fuzzes P2P message internals. |
zebra-fuzz/fuzz/fuzz_targets/addr_message_fuzz.rs |
Fuzzes address gossip handling. |
zebra-fuzz/fuzz/fuzz_targets/v6_transaction_fuzz.rs |
Fuzzes V6 transaction codecs. |
zebra-fuzz/fuzz/fuzz_targets/v6_transaction_semantic_fuzz.rs |
Fuzzes V6 transaction semantics. |
zebra-fuzz/fuzz/fuzz_targets/ironwood_value_balance_codec_fuzz.rs |
Fuzzes Ironwood state codecs. |
zebra-fuzz/fuzz/dicts/v6_transaction_fuzz.dict |
Supplies transaction tokens. |
zebra-fuzz/fuzz/dicts/v6_transaction_semantic_fuzz.dict |
Supplies semantic-target tokens. |
zebra-fuzz/fuzz/dicts/block_deserialize.dict |
Supplies block decoder tokens. |
zebra-fuzz/fuzz/dicts/block_deep_fuzz.dict |
Supplies deep-block tokens. |
zebra-fuzz/fuzz/seeds/address_fuzz_seed_corpus.zip |
Seeds address fuzzing. |
zebra-fuzz/fuzz/seeds/addr_message_fuzz_seed_corpus.zip |
Seeds address-message fuzzing. |
zebra-fuzz/fuzz/seeds/block_deep_fuzz_seed_corpus.zip |
Seeds deep-block fuzzing. |
zebra-fuzz/fuzz/seeds/block_deserialize_seed_corpus.zip |
Seeds block decoding. |
zebra-fuzz/fuzz/seeds/equihash_fuzz_seed_corpus.zip |
Seeds Equihash fuzzing. |
zebra-fuzz/fuzz/seeds/ironwood_value_balance_codec_fuzz_seed_corpus.zip |
Seeds Ironwood codec fuzzing. |
zebra-fuzz/fuzz/seeds/jsonrpsee_envelope_fuzz_seed_corpus.zip |
Seeds JSON-RPC envelope fuzzing. |
zebra-fuzz/fuzz/seeds/note_commitment_tree_fuzz_seed_corpus.zip |
Seeds tree fuzzing. |
zebra-fuzz/fuzz/seeds/p2p_deep_fuzz_seed_corpus.zip |
Seeds deep P2P fuzzing. |
zebra-fuzz/fuzz/seeds/p2p_message_parse_seed_corpus.zip |
Seeds P2P parsing. |
zebra-fuzz/fuzz/seeds/rpc_handler_fuzz_seed_corpus.zip |
Seeds RPC fuzzing. |
zebra-fuzz/fuzz/seeds/script_flag_matrix_fuzz_seed_corpus.zip |
Seeds script-flag fuzzing. |
zebra-fuzz/fuzz/seeds/script_verify_fuzz_seed_corpus.zip |
Seeds script verification. |
zebra-fuzz/fuzz/seeds/v6_transaction_fuzz_seed_corpus.zip |
Seeds V6 codec fuzzing. |
zebra-fuzz/fuzz/seeds/v6_transaction_semantic_fuzz_seed_corpus.zip |
Seeds V6 semantic fuzzing. |
Suppressed comments (5)
zebra-fuzz/fuzz/dicts/v6_transaction_fuzz.dict:48
- IMPORTANT: This dictionary omits the live NU6.2 and NU6.3 branch IDs, including the branch needed by seeded V6 transactions. Add their little-endian wire values so dictionary mutations can preserve a valid V6 header.
branch_nu6_1="\xF0\x4D\xEC\x4D"
branch_nu7_placeholder="\xFF\xFF\xFF\xFF"
zebra-fuzz/fuzz/dicts/v6_transaction_semantic_fuzz.dict:48
- IMPORTANT: This dictionary omits the live NU6.2 and NU6.3 branch IDs, including the branch needed by seeded V6 transactions. Add their little-endian wire values so dictionary mutations can preserve a valid V6 header.
branch_nu6_1="\xF0\x4D\xEC\x4D"
branch_nu7_placeholder="\xFF\xFF\xFF\xFF"
zebra-fuzz/fuzz/dicts/block_deserialize.dict:48
- IMPORTANT: This dictionary omits the live NU6.2 and NU6.3 branch IDs, including the branch needed by V6 transactions inside blocks. Add their little-endian wire values so mutations can retain valid current-upgrade transaction headers.
branch_nu6_1="\xF0\x4D\xEC\x4D"
branch_nu7_placeholder="\xFF\xFF\xFF\xFF"
zebra-fuzz/fuzz/dicts/block_deep_fuzz.dict:48
- IMPORTANT: This dictionary omits the live NU6.2 and NU6.3 branch IDs, including the branch needed by V6 transactions inside blocks. Add their little-endian wire values so mutations can retain valid current-upgrade transaction headers.
branch_nu6_1="\xF0\x4D\xEC\x4D"
branch_nu7_placeholder="\xFF\xFF\xFF\xFF"
zebra-fuzz/fuzz/fuzz_targets/v6_transaction_fuzz.rs:67
- IMPORTANT: If the serializer emits bytes that the same deserializer rejects—or if the re-decoded value cannot serialize—the target silently skips both invariants. Those are precisely round-trip failures. Require both operations to succeed before comparing the values and bytes.
if let Ok(tx2) = Transaction::zcash_deserialize(Cursor::new(&serialized)) {
let serialized2 = match tx2.zcash_serialize_to_vec() {
Ok(bytes) => bytes,
Err(_) => return,
};
| Each target ships a seed corpus as `seeds/<target>_seed_corpus.zip`, which | ||
| libFuzzer takes as its starting corpus. Without one, a run spends its first weeks | ||
| rediscovering the input format instead of exercising the code under test. |
There was a problem hiding this comment.
Answered in my summary comment on this PR. Short version: correct, and it is a
sequencing constraint rather than an oversight — the OSS-Fuzz side has to change
after this merges, otherwise a trial build fails on a directory that does not
exist yet. The PR description now lists build.sh alongside the other two.
| // Both panic-caught above; libfuzzer already records SIGSEGV / | ||
| // SIGABRT escapes from inside the C call. We discard the values — | ||
| // their presence is not a bug; only a panic / crash is. | ||
| let _ = (cxx_result, rust_result); |
| # TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value) | ||
| vgid_v6="\xFF\xFF\xFF\xFF" |
| # TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value) | ||
| vgid_v6="\xFF\xFF\xFF\xFF" |
| # TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value) | ||
| vgid_v6="\xFF\xFF\xFF\xFF" |
| let s = String::from_utf8_lossy(&payload[..payload.len().min(96)]) | ||
| .into_owned() | ||
| .replace('"', ""); | ||
| format!(r#"["{}"]"#, s) |
| Each target ships a seed corpus as `seeds/<target>_seed_corpus.zip`, which | ||
| libFuzzer takes as its starting corpus. Without one, a run spends its first weeks |
There was a problem hiding this comment.
Answered in my summary comment on this PR. Short version: the discrepancy is
mine. The addition is about 17 MB against a repository GitHub reports at roughly
271 MB, and the decision is yours: if you would rather not carry them, I will
drop them in one commit.
| if base_failed && strict_succeeded { | ||
| // Observability marker: the branch existing in the binary | ||
| // gives libfuzzer a coverage edge to chase. We do not abort. | ||
| std::hint::black_box(&strict); |
| let mut codec_test = Codec::builder().for_network(&Network::new_default_testnet()).finish(); | ||
| // Rejection (Err) and "need-more-bytes" (Ok(None)) are both | ||
| // valid; a panic is the only outcome we treat as a bug. | ||
| let _ = codec_test.decode(&mut out); |
| let mut corrupted_oversize = out.clone(); | ||
| let oversize = (zebra_chain::serialization::MAX_PROTOCOL_MESSAGE_LEN as u32) + 1; | ||
| corrupted_oversize[16..20].copy_from_slice(&oversize.to_le_bytes()); | ||
| let mut codec_d1 = Codec::builder().for_network(&Network::Mainnet).finish(); | ||
| let _ = codec_d1.decode(&mut corrupted_oversize); |
There was a problem hiding this comment.
Answered in my summary comment on this PR. Two of the three now assert Err. The
zero-command variant does not: the catch-all arm of the command match returns
Ok(None) deliberately, so that an unauthenticated peer cannot drop a connection
by sending an unknown command. It now asserts the invariant that does hold —
no Ok(Some(_)).
markdownlint is configured with MD049 style: underscore, which the two asterisk-emphasised words in this file violate.
…tionaries The dictionary was written before TX_V6_VERSION_GROUP_ID had a final value and carried an all-FF placeholder for it. It was never loaded, because its file name matched no target, so the stale token was inert until this branch renamed it. Re-derive every constant in it against the current source: - TX_V6_VERSION_GROUP_ID is 0xD884B698 - add the NU6.2 and NU6.3 (Ironwood) consensus branch ids - the NU7 placeholder in CONSENSUS_BRANCH_IDS is 0xFFFFFFFE, and is gated behind cfg(any(test, feature = "zebra-test")) - the locktime threshold tokens did not match the LockTime::MIN_TIMESTAMP named in the comment above them - the NU5 mainnet activation height was off by 960 blocks; add the NU6.3 activation height alongside it
Several oracles computed a verdict and then discarded it, so a violation produced no finding: - p2p_deep_fuzz: the oversize-length and bad-checksum frames now assert rejection. The zero-command frame asserts non-acceptance instead: an unknown command is dropped with Ok(None) deliberately, because peers are unauthenticated and erroring on junk would be a DoS vector. The oversize probe also needed reconfigure_full_body_len(), or it was rejected by the 1 KiB handshake cap rather than by the MAX_PROTOCOL_MESSAGE_LEN guard it names. - p2p_message_parse: a complete Mainnet frame carries a full header, so the Testnet decoder reaches the magic check and must reject it. Both Ok(Some(_)) and Ok(None) now fail, with distinct messages. - v6_transaction_fuzz: the intermediate deserialize returning early discarded both round-trip assertions in exactly the case they exist to catch. - script_flag_matrix_fuzz: a strict flag turning a failing script into a passing one now panics. The comment defending the coverage-edge-only form argued a panic would mask FFI crash classes, but libfuzzer-sys aborts on panic, so both terminate on the offending input and are told apart by their stack frames. Also state that this target is not a differential oracle, since the two interpreters do not share checker semantics. - jsonrpsee_envelope_fuzz: interpolate fuzz-controlled strings with serde_json. Stripping quotes left backslashes, newlines and control bytes to break the envelope before parameter parsing was reached. Each verdict is asserted outside the catch_unwind that guards the call under test, so the oracle does not depend on unwind behaviour.
Codec::builder() starts at MAX_HANDSHAKE_BODY_LEN, and Zebra raises a codec to MAX_PROTOCOL_MESSAGE_LEN only after a handshake completes, so the three P2P targets exercise pre-handshake framing and reject longer frames at the header. Covering both codec states is a change to how those targets consume their input, so record the limitation rather than reshaping them here.
libFuzzer's -merge=1 is a greedy, order-dependent pass: it retains every coverage feature of its input, but the result is not guaranteed to be the smallest subset with that property. State the guarantee that holds.
The byte sequence encoded 3,428,655 rather than the 3,428,143 named in the comment beside it: the decimal was converted to hex incorrectly and the bytes were then written from the wrong hex. This is the same defect this dictionary already carried for the NU5 activation height, and it was introduced while fixing that one. Every numeric token in the file has now been decoded and checked against the constant its comment names, rather than by eye.
|
Thanks for putting Copilot on this — the pass was worth it. Counting the five Copilot notes that it reviewed 29 of the 45 changed files, so this is a response Fixed as suggested1. Since the file had gone from unused to used in one step, I re-derived the rest of
2.
3. 4. 5.
The Follow-up Work section listed only the first two. That omission is mine and 6. Both non- Fixed, but not the way it was suggested7. The zero-command variant is different. An all-zero command passes the magic and
Asserting While fixing the oversize probe I found it was not testing what its comment The fourth variant, truncation to a bare header, has no verdict and now says why: 8. One correction on where the claim lives. It is not in this PR's description; the I have not made the target differential. The C++ side runs with the sighash The question9. Why it changed: OSS-Fuzz has no persistent home for a starting corpus other than The numbers, so the decision is yours: about 17 MB across 15 archives, against a If you would rather not carry them, say so and I will drop them in one commit, or One gap I noticed while fixing the aboveThis was not in the review and it is not a deliberate trade-off — nobody had It also affected the fix for the corrupted-frame comment above. The oversize For the targets as a whole I have documented the limitation rather than changing Verification
Because a quiet oracle and an absent one look identical, each new assertion was |
Merge Protections🟠 1 of 1 protections blocking · waiting on 🕒 schedule
🟠 ❄️ 6.3.0 release [Scheduled Freeze]Waiting for
This freeze has no end date and must be removed manually.A freeze on the repository is scheduled for the following reason: 6.3.0 release
|
Motivation
Zebra currently has no continuous fuzzing. This PR adds the fuzz harnesses to the
repository so that Zebra can be enrolled in OSS-Fuzz, which runs them continuously
and reports crashes to the maintainers.
This was discussed in #11166. The harnesses have already been reviewed on the
OSS-Fuzz side in google/oss-fuzz#15900; the reviewer asked that they live in the
upstream repository so that maintenance ownership is clear.
Refs #11166 — not
Closes, because the integration also needsgoogle/oss-fuzz#15900 to merge.
Solution
1. A new
zebra-fuzz/directory with 15cargo-fuzztargets, theirdictionaries and seed corpora, and a README.
zebra-fuzz/fuzzdeclares its own[workspace], so it is not a member of the Zebra workspace: it is not built bycargo build,cargo testorcargo clippyat the repository root, and it doesnot appear in the per-crate CI matrix, which is derived from
cargo tree.The diff is 45 files and 12,178 added lines:
fuzz/Cargo.lockzebra-fuzz/README.mdseed_gen.rs, manifests,.gitignoreplus 15 binary seed archives. Nothing is deleted anywhere in the diff. Almost half
of the added lines are the generated
fuzz/Cargo.lock; the substance of the PR isthe 5,686 lines of harness source and the 16-line feature gate.
2. A
fuzzingfeature onzebra-networkandzebra-consensus— 16 addedlines across 4 files, 0 deletions. It changes the visibility of exactly one
module per crate and nothing else:
fuzzing = []), sodefault and release builds are unchanged.
proptest-implas a test-only feature. Likeproptest-impl, it is not part of the crates' stability surface and the itemsit exposes carry no semver guarantee.
cargo-semver-checksruns withfeature-group: default-features, so thegated modules stay private from its point of view.
Six of the 15 targets need these modules (
p2p_message_parse,p2p_deep_fuzz,addr_message_fuzzforprotocol;equihash_fuzz,block_deserialize,block_deep_fuzzforblock). The other nine use public APIs only. Theharnesses use exactly these items:
protocol::external::{Codec, Message, InventoryHash}block::check::{equihash_solution_is_valid, coinbase_is_first, difficulty_threshold_is_valid, merkle_root_validity, time_is_valid_at}Tests
Verified locally against
9d67087f4. All commands exercise thefuzzingfeature, i.e. exactly what
--all-featuresdoes in CI:cargo check -p zebra-network -p zebra-consensus --features fuzzingcargo clippy -p zebra-network --all-features --all-targets -- -D warningscargo clippy -p zebra-consensus --all-features --all-targets -- -D warningscargo check --locked --all-features --all-targetscargo build --all-features --all-targetscargo doc --no-deps --all-features --document-private-itemscargo fuzz build --fuzz-dir zebra-fuzz/fuzzcargo fuzz build -O --fuzz-dir zebra-fuzz/fuzz(release + ASan, the mode OSS-Fuzz uses)--lockedpassing means the workspaceCargo.lockneeds no change.Seed corpora
Each target ships a seed corpus as
zebra-fuzz/fuzz/seeds/<target>_seed_corpus.zip— 15 archives, about 17 MB in total, roughly 6% of the current repository size. They
are minimised with
cargo fuzz cmin, which is libFuzzer's-merge=1: a greedy passthat keeps an input only when it adds a coverage feature the earlier ones did not, so
the result retains every coverage feature of what went in — not necessarily in the
fewest possible files, but without losing coverage by construction. The corpora went from 68,645
files (110 MB) to 14,648 files (24 MB) uncompressed.
83% of the compressed size is two targets —
block_deep_fuzz(8.6 MB) andblock_deserialize(5.9 MB) — whose inputs are derived from real mainnet blocks;the other 13 total 3.0 MB. Everything in the corpora is derived from public chain
data. Happy to trim these if you would rather keep the repository smaller.
The evolving corpus that
cargo fuzz runwrites tozebra-fuzz/fuzz/corpus/isgit-ignored, following the existing
zebra-chain/fuzz/corpusentry in.gitignore.Specifications & References
Follow-up Work
.github/path-filters.ymlmatches**/*.rs,**/Cargo.tomland**/Cargo.lock, which also match files underzebra-fuzz/fuzz/. Changes confined to the harnesses will therefore triggerlint,unit_testsandsemver, none of which build this crate. An exclusionfor
zebra-fuzz/**would avoid those runs — happy to add it here or in afollow-up.
C-featurelooks correct.project.yaml(main_repo, plusprimary_contact/auto_ccsonce theaddresses are confirmed), the
Dockerfileclone URL, andbuild.sh, whichcurrently packages
zebra-fuzz/fuzz/corpus/<target>/and must instead read thecommitted
seeds/<target>_seed_corpus.ziparchives and copy each one to$OUT/<target>_seed_corpus.zip. All three are deliberately not made yet: atrial build against them would fail until the harnesses exist here.
AI Disclosure
verification runs above,
zebra-fuzz/README.md, and this PR description.All output was reviewed by me and I am the responsible author.
PR Checklist
type(scope): description