Skip to content

feat(fuzz): add coverage-guided fuzz harnesses for zebra-network and zebra-consensus - #11221

Open
robustfengbin wants to merge 11 commits into
ZcashFoundation:mainfrom
robustfengbin:fuzz-harness
Open

feat(fuzz): add coverage-guided fuzz harnesses for zebra-network and zebra-consensus#11221
robustfengbin wants to merge 11 commits into
ZcashFoundation:mainfrom
robustfengbin:fuzz-harness

Conversation

@robustfengbin

@robustfengbin robustfengbin commented Aug 7, 2026

Copy link
Copy Markdown

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 needs
google/oss-fuzz#15900 to merge.

Solution

1. A new zebra-fuzz/ directory with 15 cargo-fuzz targets, their
dictionaries and seed corpora, and a README. zebra-fuzz/fuzz declares its own
[workspace], so it is not a member of the Zebra workspace: it is not built by
cargo build, cargo test or cargo clippy at the repository root, and it does
not appear in the per-crate CI matrix, which is derived from cargo tree.

The diff is 45 files and 12,178 added lines:

lines
fuzz target sources (15 files) 5,686
fuzz/Cargo.lock 5,544
dictionaries 452
zebra-fuzz/README.md 184
seed_gen.rs, manifests, .gitignore 288
changelog entries (2 files) 8
changes to existing crates 16

plus 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 is
the 5,686 lines of harness source and the 16-line feature gate.

2. A fuzzing feature on zebra-network and zebra-consensus — 16 added
lines across 4 files, 0 deletions. It changes the visibility of exactly one
module per crate and nothing else:

#[cfg(not(feature = "fuzzing"))]
mod protocol;
#[cfg(feature = "fuzzing")]
pub mod protocol;
  • It is off by default and activates no dependencies (fuzzing = []), so
    default and release builds are unchanged.
  • It is declared alongside proptest-impl as a test-only feature. Like
    proptest-impl, it is not part of the crates' stability surface and the items
    it exposes carry no semver guarantee.
  • cargo-semver-checks runs with feature-group: default-features, so the
    gated modules stay private from its point of view.

Six of the 15 targets need these modules (p2p_message_parse, p2p_deep_fuzz,
addr_message_fuzz for protocol; equihash_fuzz, block_deserialize,
block_deep_fuzz for block). The other nine use public APIs only. The
harnesses 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 the fuzzing
feature, i.e. exactly what --all-features does in CI:

Command Result
cargo check -p zebra-network -p zebra-consensus --features fuzzing pass
cargo clippy -p zebra-network --all-features --all-targets -- -D warnings pass
cargo clippy -p zebra-consensus --all-features --all-targets -- -D warnings pass
cargo check --locked --all-features --all-targets pass
cargo build --all-features --all-targets pass
cargo doc --no-deps --all-features --document-private-items pass
cargo fuzz build --fuzz-dir zebra-fuzz/fuzz 15/15 targets
cargo fuzz build -O --fuzz-dir zebra-fuzz/fuzz (release + ASan, the mode OSS-Fuzz uses) 15/15 targets

--locked passing means the workspace Cargo.lock needs 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 pass
that 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) and
block_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 run writes to zebra-fuzz/fuzz/corpus/ is
git-ignored, following the existing zebra-chain/fuzz/corpus entry in
.gitignore.

Specifications & References

Follow-up Work

  • CI path filters. .github/path-filters.yml matches **/*.rs,
    **/Cargo.toml and **/Cargo.lock, which also match files under
    zebra-fuzz/fuzz/. Changes confined to the harnesses will therefore trigger
    lint, unit_tests and semver, none of which build this crate. An exclusion
    for zebra-fuzz/** would avoid those runs — happy to add it here or in a
    follow-up.
  • Labels. I cannot apply labels; C-feature looks correct.
  • OSS-Fuzz side. Once this merges, Add zebra (Zcash consensus node) google/oss-fuzz#15900 needs three changes:
    project.yaml (main_repo, plus primary_contact / auto_ccs once the
    addresses are confirmed), the Dockerfile clone URL, and build.sh, which
    currently packages zebra-fuzz/fuzz/corpus/<target>/ and must instead read the
    committed seeds/<target>_seed_corpus.zip archives and copy each one to
    $OUT/<target>_seed_corpus.zip. All three are deliberately not made yet: a
    trial build against them would fail until the harnesses exist here.

AI Disclosure

  • No AI tools were used in this PR
  • AI tools were used: Claude (Anthropic) — fuzz harness implementation, the
    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

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 fuzzing features 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,
        };

Comment thread zebra-fuzz/README.md
Comment on lines +84 to +86
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.

@robustfengbin robustfengbin Aug 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +221 to +224
// 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);
Comment on lines +33 to +34
# TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value)
vgid_v6="\xFF\xFF\xFF\xFF"
Comment on lines +33 to +34
# TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value)
vgid_v6="\xFF\xFF\xFF\xFF"
Comment on lines +33 to +34
# TX_V6_VERSION_GROUP_ID = 0xFFFFFFFF (placeholder until librustzcash sets final value)
vgid_v6="\xFF\xFF\xFF\xFF"
Comment on lines +623 to +626
let s = String::from_utf8_lossy(&payload[..payload.len().min(96)])
.into_owned()
.replace('"', "");
format!(r#"["{}"]"#, s)
Comment thread zebra-fuzz/README.md
Comment on lines +84 to +85
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +266 to +269
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);
Comment on lines +273 to +276
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);
Comment on lines +349 to +353
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);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@robustfengbin

Copy link
Copy Markdown
Author

Thanks for putting Copilot on this — the pass was worth it. Counting the five
suppressed comments, that is seventeen items. Fourteen were right as written and
are fixed. Two I have implemented differently from what was suggested, and one is
a question I answer at the end. Where I did something different I have said so
here rather than changing the code quietly.

Copilot notes that it reviewed 29 of the 45 changed files, so this is a response
to what it raised, not a claim that the harnesses are now clean.

Fixed as suggested

1. dicts/*.dict — the V6 version group ID, and the missing NU6.2 / NU6.3
branch IDs (8 comments across 4 files).
Both correct, and the first one was
self-inflicted. The dictionary was written in April, when TX_V6_VERSION_GROUP_ID
had no final value, so it carried 0xFFFFFFFF behind a comment that said
"placeholder". Because the file name matched no target, libFuzzer never loaded
it and the stale token was inert. Renaming the file in this PR is what made it
live: it went from unused to actively steering mutations at a value the decoder
rejects. It now carries 0xD884B698 (98 B6 84 D8 on the wire), and NU6.2
(0x5437F330) and NU6.3 / Ironwood (0x37A5165B) have been added — the latter
conspicuous by its absence in a PR motivated by Ironwood.

Since the file had gone from unused to used in one step, I re-derived the rest of
its constants against the current source rather than only the ones flagged. Two
more were wrong:

  • locktime_threshold_lt / locktime_threshold_gte encoded 496,683,519 and
    496,683,520, while the comment directly above them correctly said
    500_000_000. They now match LockTime::MIN_TIMESTAMP.
  • expiry_nu5_activation encoded height 1,686,144. NU5 activates on Mainnet at
    1,687,104 (zebra-chain/src/parameters/constants.rs), and the hex in the
    comment was wrong in the same way. Fixed, with the NU6.3 activation height
    added alongside it.

branch_nu7_placeholder I left as a placeholder but corrected: the value in
CONSENSUS_BRANCH_IDS is 0xFFFFFFFE, not 0xFFFFFFFF, and it is gated behind
cfg(any(test, feature = "zebra-test")). The comment now says so.

2. v6_transaction_fuzz.rs — the round-trip steps that returned early
(lines 57, 63 and the suppressed comment on 67).
All three are now unwrapped,
but they are not the same kind of problem and I would rather not claim credit for
catching something that could not happen:

  • The two zcash_serialize_to_vec calls cannot fail in this context.
    ZcashSerialize documents that serialization "MUST be infallible up to errors
    in the underlying writer", and the writer is a Vec. Asserting is still right —
    it removes an early return that would skip everything below — but the branch was
    unreachable rather than a missed finding.
  • The intermediate zcash_deserialize is the real one. It can genuinely fail,
    and a failure means the serializer emitted bytes its own deserializer rejects.
    That is a round-trip failure, and the if let Ok(..) around it discarded both
    assertions in exactly the case they exist to catch. That one was a missed
    finding.

3. jsonrpsee_envelope_fuzz.rs:626 — stripping " is not JSON escaping.
Correct, and the consequence was worse than a malformed envelope: any payload
containing a backslash, newline or control byte died in the envelope decoder, so
the per-method parameter parsing this target exists to reach was never entered for
those inputs. All five raw-string interpolation sites now go through
serde_json::Value::String, which is what two other arms of the same function
already did.

4. script_flag_matrix_fuzz.rs:269 — the monotonicity oracle only created a
coverage edge.
Correct, and the comment defending it does not survive contact
with the source. It said a panic "would mask the real crash classes (FFI SIGSEGV
etc.)". That is not how the harness behaves: libfuzzer-sys installs a panic hook
that runs the default hook and then calls std::process::abort()
(libfuzzer-sys-0.4.13/src/lib.rs, initialize()), so a panic and a SIGSEGV each
terminate the process on the input that caused it and are told apart by their
stack frames. There was nothing to mask. A strict flag turning a failing script
into a passing one is a real flag-handling bug, and it now panics with the
offending flag in the message.

5. README.md:86 — the integration packages corpus/, this PR ships
seeds/.
Correct, and repointing main_repo alone would indeed ship no seeds.
This is a sequencing constraint rather than an oversight: google/oss-fuzz#15900
needs three changes, and all of them have to land after this PR merges, because a
trial build against them beforehand fails on a fuzz directory that does not exist
yet:

  • project.yamlmain_repo, plus primary_contact / auto_ccs once you
    confirm the addresses
  • Dockerfile — the clone URL
  • build.sh — read zebra-fuzz/fuzz/seeds/<target>_seed_corpus.zip and copy each
    archive to $OUT/<target>_seed_corpus.zip

The Follow-up Work section listed only the first two. That omission is mine and
the third is now there.

6. p2p_message_parse.rs:276 — cross-network decode. Right on both counts.
The result was discarded, so Ok(Some(_)) — cross-network acceptance — passed
silently. And a complete Mainnet frame does return Err from a Testnet codec:
the frame carries a full 24-byte header, so decode reaches the magic check,
which rejects before anything else. "Need more bytes" is not reachable here, and
the unknown-command path that returns Ok(None) sits downstream of the magic
check, so it cannot be reached either.

Both non-Err outcomes now fail, with separate messages: one for a message
decoded across networks, one for Ok(None) on a complete frame.

Fixed, but not the way it was suggested

7. p2p_deep_fuzz.rs:353 — the three corrupted-frame variants. Agreed on two
of them: the oversize length and the flipped checksum now assert Err.

The zero-command variant is different. An all-zero command passes the magic and
length checks with an intact checksum and reaches the catch-all arm of the command
match in zebra-network/src/protocol/external/codec.rs, which returns Ok(None)
on purpose:

Zcash connections are not authenticated, so malicious nodes can send fake
messages […] Zebra needs to ignore unexpected messages, because closing the
connection could cause a denial of service or eclipse attack.

Asserting Err there would fail on deliberate, correct behaviour on every input
that reaches it. The invariant that does hold is non-acceptance, and that is what
it now asserts: no Ok(Some(_)) for a command no message defines.

While fixing the oversize probe I found it was not testing what its comment
claimed. A codec from Codec::builder() carries MAX_HANDSHAKE_BODY_LEN (1 KiB),
so MAX_PROTOCOL_MESSAGE_LEN + 1 was being rejected by the handshake cap and
never reached the guard it names. The codecs in that layer now call
reconfigure_full_body_len() first.

The fourth variant, truncation to a bare header, has no verdict and now says why:
a message with an empty body is already complete at 24 bytes, so Ok(Some(_)) is
the right answer for verack, getaddr and the other bodyless messages.

8. script_flag_matrix_fuzz.rs:224 — "differential". The observation is
right: the two results are discarded without comparison, and the two sides do not
share checker semantics.

One correction on where the claim lives. It is not in this PR's description; the
word appears once in zebra-fuzz/, in a module comment describing zcash_script
as "the differential Rust port", which is an attribute of that crate rather than a
claim about this harness. I have reworded it to "the Rust port" regardless, since
it is ambiguous and costs nothing. The claim you are describing is in the OSS-Fuzz
integration PR (google/oss-fuzz#15900), which says the interpreter is "run
differentially against the zcash_script C++ library". That is not what this
target does, and I will correct that wording on that PR.

I have not made the target differential. The C++ side runs with the sighash
callback and the Rust side with NullSignatureChecker, so every script containing
a CHECK*SIG diverges by construction; comparing them today would produce noise,
not findings. Giving both sides the same checker is a change of scope rather than
a missing assertion, so I have documented what these two observers are and are not
instead of smuggling it in here.

The question

9. README.md:85 — 17 MB of corpora versus what I told you in #11166. A fair
thing to ask, and the discrepancy is mine. In #11166 I wrote that "the corpora
stay on the OSS-Fuzz side, not in your repo", then changed the plan and never went
back to correct that sentence.

Why it changed: OSS-Fuzz has no persistent home for a starting corpus other than
the project repository, so shipping none means every target begins empty and
spends its first weeks rediscovering the wire formats — which undercuts the
"validate that fuzzing has benefit" point raised on the OSS-Fuzz side.

The numbers, so the decision is yours: about 17 MB across 15 archives, against a
repository GitHub reports at roughly 271 MB — a one-off increase of about 6%. It
is concentrated, with block_deep_fuzz and block_deserialize accounting for 83%
of it, because real blocks average around 10 KB and do not compress well. The
archives are cargo fuzz cmin output, which is libFuzzer's -merge=1: a greedy
pass that keeps an input only when it adds a coverage feature, so the result
retains every coverage feature of what went in (not necessarily in the fewest
possible files).

If you would rather not carry them, say so and I will drop them in one commit, or
trim the two block targets to a fixed number of samples each. Deleting files is
the one thing here I can promise without qualification.

One gap I noticed while fixing the above

This was not in the review and it is not a deliberate trade-off — nobody had
looked. All three P2P targets build their codecs with Codec::builder(), which
starts at MAX_HANDSHAKE_BODY_LEN (1 KiB), the pre-handshake limit; Zebra raises
a codec to MAX_PROTOCOL_MESSAGE_LEN only after a handshake completes. So those
targets have only ever exercised pre-handshake framing: a frame declaring a
longer body is rejected at the header, and a real-sized block or transaction
never reaches the body parser through this path.

It also affected the fix for the corrupted-frame comment above. The oversize
probe asserts that MAX_PROTOCOL_MESSAGE_LEN + 1 is rejected, but at the
handshake cap it was being rejected by the 1 KiB check instead — passing for the
wrong reason. Those codecs now call reconfigure_full_body_len() first, so the
assertion exercises the guard it names.

For the targets as a whole I have documented the limitation rather than changing
it here. Covering the post-handshake state means fuzzing both codec states, not
swapping one for the other — the 1 KiB check is a rule worth testing too — and
that changes how three targets consume their input, which seemed like the wrong
thing to land in the middle of a review. Happy to do it in this PR instead if you
would prefer.

Verification

cargo check --all-targets on the fuzz workspace is clean. All fifteen targets
build in both modes (cargo fuzz build and cargo fuzz build -O, the release +
ASan mode OSS-Fuzz uses). The five changed targets were replayed against their
committed seed corpora — 9,187 inputs, as libFuzzer itself reports them — with
no assertion firing, then
fuzzed for a further 90 seconds each (2.4M executions for
script_flag_matrix_fuzz, 0.6M–0.8M for the others) with no crashes.

Because a quiet oracle and an absent one look identical, each new assertion was
also inverted, rebuilt, and confirmed to produce a libFuzzer crash artifact on
the shipped seeds: the oversize, bad-checksum and zero-command frame probes, the
cross-network decode, the monotonicity check, and the transaction round-trip.
All six fired. The inversions were reverted and are not part of the diff.

@mergify

mergify Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟠 1 of 1 protections blocking · waiting on 🕒 schedule

Protection Waiting on
🟠 ❄️ 6.3.0 release [Scheduled Freeze] 🕒 schedule

🟠 ❄️ 6.3.0 release [Scheduled Freeze]

Waiting for

  • current-datetime < 2026-08-10T14:25:11[America/Sao_Paulo]
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

  • current-datetime < 2026-08-10T14:25:11[America/Sao_Paulo]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants