What if a node rejects a malicious block but still remembers too much about it?
We sent an invalid block with a malformed body, and Zebra correctly rejected it. The problem was that surrounding sync state still treated the block hash as if it had already been handled, resulting in the node suppressing a future valid block with the same hash. If an attacker won the delivery race for a victim node, Zebra could remember the poisoned hash in its sync bookkeeping and later refuse the canonical block with that same hash. That victim would fall behind the live chain while honest nodes kept moving.
The bugs we found were not smart contract bugs or cryptographic breaks, but failures in the glue code around consensus, including caching logic and download queues.
All findings were responsibly disclosed to the Zcash Foundation, resulting in public advisories and $93,750 in bounty awards.
In this post we will go over multiple bugs like this which could cause a node to desync from the network, potentially allowing an attacker to kick honest nodes out of the consensus. But first, let’s discuss Zebra’s place in the Zcash infrastructure path.
Why We Looked Below Consensus
Zebra is the Zcash Foundation’s Rust full-node implementation. Full nodes are good research targets because they sit between an adversarial network and critical consensus state. They parse unauthenticated peer messages, schedule downloads, keep caches, retry work, and score peers. While a bug in this layer might not lead to an invalid block being accepted as valid, it can keep a valid block from reaching the consensus logic at all.
We started with invariants like invalid consensus acceptance, accounting divergence, valid block suppression, and peer-local failures becoming node-global recovery. LLM agents were used to map large code paths and keep parallel hypotheses alive. While these were useful for breadth, the hard part was still deciding which invariants mattered, killing weak candidates, building localnet proofs, and evaluating impact against real running services.
That mattered because the most dangerous bugs we found were not contained to one obvious line of code. A block hash cache interacted with ZIP-244 transaction commitments. A peer-local sync error became a global restart. In both cases, the consensus decision itself was not the failure.
The Zcash Infrastructure Path
This image shows the path a peer message takes when it enters Zebra, block downloads trigger verification work, and accepted blocks become local chain state.

Solid arrows show the normal path from peer data to verification and state. Dashed arrows mark untrusted input or rejected-data paths that cross into sync bookkeeping and recovery logic.
Inside Zebra, these steps are split across crates. zebra-network handles peers and wire messages. zebrad orchestrates sync and mempool tasks. zebra-consensus decides whether blocks and transactions are valid. zebra-state records finalized and nonfinalized chain state. None of those boundaries are security-neutral, because each one decides what to remember after external data succeeds, fails, or times out.
The components have different jobs, but the question we kept coming back to was the same: When external data enters the system, when does it become trusted local state?
For Zebra, the risky points were when peer-controlled block data became sync metadata, duplicate-filter state, retry state, or a node-global recovery decision.
Bugs in Zebra
Poisoning Zebra’s Sync State
The first advisory, GHSA-4m69-67m6-prqp↗, came from a ZIP-244 edge case.
ZIP-244↗ changed how Zcash transaction identifiers and authorizing-data commitments work. For version 5 transactions, txid_v5 commits to the transaction effects, while auth_digest commits to authorizing data. That distinction matters for blocks because the block hash is computed over the header, and the transaction Merkle root is built from transaction IDs. If a peer keeps the header fixed but mutates body data that affects the authorization digest, the advertised block hash can remain the same even though validation later rejects the body.
The exploit was a same-header, different-body block variant. By changing coinbase authorizing data that affected auth_digest but not txid_v5, a peer could send Zebra a block body with the same header hash as a real block but different serialized bytes.
Zebra rejected the poisoned block. However, for our attack, we only needed to enter the block into the sent-hash cache under the same block hash that Zebra would later need for the canonical body.
That cache was keyed by block.hash.
self.curr_buf.push_back((block.hash, block.height));
self.sent.insert(block.hash, outpoints);
For this ZIP-244 edge case, this became exploitable. The malicious body and the canonical body had different serialized bytes, but they could land under the same cache key.
The later duplicate guard also checked only the hash:
if self
.non_finalized_block_write_sent_hashes
.contains(&semantically_verified.hash)
{
let _ = rsp_tx.send(Err(CommitBlockError::new_duplicate(
Some(semantically_verified.hash.into()),
KnownBlock::WriteChannel,
)
.into()));
return rsp_rx;
}
The exploit follows directly from those two snippets. First, the peer sends a malformed body that causes the node to record block.hash as already sent. Zebra later rejects the body, but the vulnerable failure path did not fully undo the earlier we-already-sent-this-hash state. When an honest peer delivers the canonical body, the duplicate guard sees the same hash and returns before the valid body can be queued.
That is the core of the bug.
- A malicious peer sends same-hash poisoned body.
- Zebra rejects the body.
- The stale sent-hash state remains.
- A canonical body with the same block hash arrives.
- Zebra suppresses it as a duplicate write.
The PoC exercised the same P2P request and response that Zebra expects from normal block relay. It first built two byte strings with the same block hash.
let canonical_block =
proposal_block_from_template(&tpl, BlockTemplateTimeSource::default(), &net)?;
let canonical_bytes = canonical_block.zcash_serialize_to_vec()?;
let canonical_hash = canonical_block.hash();
let poisoned_bytes = mutate_coinbase_scriptsig(&canonical_block, &canonical_bytes)?;
let poisoned_block: Block = poisoned_bytes.clone().zcash_deserialize_into()?;
if canonical_hash != poisoned_block.hash() {
return Err("PoC precondition broken: poisoned and canonical hashes differ".into());
}
Then it used the normal inbound flow. The peer did not send an unsolicited block but rather advertised the hash, waited for Zebra to ask for it, and only then served the poisoned bytes.
handshake(&mut stream)?;
send_inv_block(&mut stream, &first_block_hash.0)?;
while Instant::now() < getdata_deadline {
let (cmd, _) = read_msg(&mut stream)?;
if cmd == "getdata" {
send_block_message(&mut stream, first_block)?;
break;
}
}
ATTACK: poisoned first, canonical second
poisoned -> "rejected"
canonical -> "duplicate"
final height -> 3 (target was 4)
CONTROL: canonical first
canonical -> null
final height -> 4
The takeaway was that the malicious delivery did not need RPC credentials. A normal handshake, an inv, and a response to Zebra’s getdata were enough to suppress the later canonical delivery.
The fix was to make failed block writes clean up after themselves. If a body is rejected, bookkeeping that says the hash is already in flight must be cleared or made precise enough to distinguish a failed write from a committed one.
Restarting Sync from a Peer-Local Error
The second advisory, GHSA-gvjc-3w7c-92jx↗, was a different kind of sync failure.
Zebra asks peers for block hashes using FindBlocks. A malicious peer could return a tiny inventory response then serve a syntactically valid block whose coinbase height was far above the victim’s local tip. The response did not need to be large. The trick was to make Zebra’s downloader classify the resulting block as too far ahead of the current sync window.
The PoC demonstrates that path with an intentionally small malicious peer. When the victim sent getblocks, the peer answered with only two hashes. One was the attacker’s high-height block.
elif command == "getblocks":
inv_payload = make_inv_payload([self.high_hash, self.tail_hash])
write_message(sock, b"inv", inv_payload)
When Zebra later requested that hash with getdata, the peer served the above-lookahead block.
elif command == "getdata":
for item_hash in parse_inv_payload(payload):
if item_hash == self.high_hash:
write_message(sock, b"block", self.high_block)
self.high_blocks_served += 1
The downloader rejected that block before it reached consensus verification:
if block_height > lookahead_drop_height {
Err(BlockDownloadVerifyError::AboveLookaheadHeightLimit {
height: block_height,
hash,
})?;
}
That should have stayed local to this peer. The advertised hash came from one peer, and the too-far-ahead block came from that same fetch flow. But in the vulnerable path, the error class flowed into sync recovery instead of peer scoring.
The vulnerable logic treated most expected downloader errors as recoverable but restarted the synchronizer on errors that were not explicitly classified.
fn handle_response<T>(
response: Result<T, BlockDownloadVerifyError>,
) -> Result<(), BlockDownloadVerifyError> {
match response {
Ok(_t) => Ok(()),
Err(error) => {
if Self::should_restart_sync(&error) {
Err(error)
} else {
Ok(())
}
}
}
}
The fallback in should_restart_sync was the dangerous part.
_ => {
warn!(?e, "error downloading and verifying block");
true
}
Combined, the peer-controlled download error and the restart fallback gave the attacker a cheap restart primitive. Repeating the same sequence could repeatedly push Zebra out of its normal sync flow.
The localnet PoC compared the same victim with and without the malicious peer.
honest source height: 120
control victim max observed height: 120
malicious high blocks served: 3
victim final height: 6
saw AboveLookaheadHeightLimit log: True
saw sync restart wait log: True
This was not a bandwidth-exhaustion DoS, but an attribution failure that allowed a malicious peer to repeatedly restart the victim node.
Zebra fixed this by classifying AboveLookaheadHeightLimit as a non-restart condition and carrying enough advertiser information to score the peer instead of restarting sync globally.
Smaller Bugs, Same Pattern
Some lower-severity Zebra findings rhymed with the peer-attribution issue above.
In #10595↗, the peer connection path accepted oversized FindBlocks inventory responses. FindBlocks responses were capped at 500 hashes internally, but the network path could forward up to the generic 50,000-entry inv decoder limit as Response::BlockHashes.
(Handler::FindBlocks, Message::Inv(items))
if items
.iter()
.all(|item| matches!(item, InventoryHash::Block(_))) =>
{
Handler::Finished(Ok(Response::BlockHashes(
block_hashes(&items[..]).collect(),
)))
}
In #10616↗, inbound block gossip scoring could miss consensus misbehavior because the inbound path downcast the verifier error to the wrong concrete type. The sync path scored the same invalid block class correctly.
That was an attribution mismatch. The evidence of misbehavior existed, but one path looked for it through the wrong error type.
The Zebra bugs pointed to the same rule.
Peer data should either become validated state, or it should be completely discarded with correct attribution.
What the Zebra Bugs Have in Common
The two advisories and the smaller issues all came from handling of state that sits next to consensus, not from the final validity predicate itself.
In the first advisory, a rejected body still affected duplicate suppression for the same hash. In the second, a bad response from one peer became a synchronizer restart. In the smaller issues, protocol limits and verifier errors crossed boundaries between network handlers, sync code, and peer scoring.
That is the part of node research that is easy to miss if the audit stops at “does verification reject the invalid object?” Rejection is only one half of the invariant. The other half is what the node remembers after rejection.
For Zebra, the useful audit targets were the caches, queues, retry flags, and scorer paths around validation. We kept asking a few mechanical questions.
- Does failed validation clear every piece of state that was created before the failure?
- Does a peer-local error stay attached to the peer that caused it?
- Does a protocol-specific bound survive translation into a generic internal response?
- Does the same invalid object receive the same score across inbound, sync, and block paths?
Those questions are small, but they force the review into the places where mature node implementations accumulate risk. A full node is much more than just a verifier, containing a full distributed system that decides what to fetch, what to retry, what to cache, and what to blame.
This is why local PoCs mattered. A bookkeeping bug can look harmless until a live node shows that the stale bookkeeping changes future sync behavior. For the Zebra advisories, the useful evidence was a node losing progress because unauthenticated peer input changed what the sync machinery remembered.
For each high-signal candidate, we tried to make the proof exercise the same path a real node would use. That meant running zebrad, driving peer or RPC behavior through the public interfaces, and watching node-visible outcomes such as tip height, duplicate suppression, restart behavior, and peer attribution. The goal was not only to show that a branch was reachable, but to deliver a full PoC demonstrating how that branch changed the node’s ability to catch up.
The broader research method was simple: follow external data until something remembers it, then test whether that memory can be poisoned.
Conclusion
These are the kinds of bugs that mature infrastructure tends to produce. Each component can look reasonable on its own, but the security question is what happens when those pieces are composed under adversarial timing and adversarial data.
The fixes were not exotic. Cleaning up after failed validation and keeping peer-local failures peer-local. These rules are simple, but they have to be enforced at the exact place where external data becomes trusted state.
At the time of writing, our Zcash research had received $93,750 in bounty awards and public advisory credit. The Zcash Foundation handled the reports constructively, published advisories, and shipped fixes for the Zebra issues discussed above. We appreciate the Zcash team’s work with us through disclosure and remediation.
Zebra is only the first layer of the story we can tell today. We have also reported additional vulnerabilities in adjacent Zcash infrastructure that are not yet public, and we plan to publish Part 2 once disclosure allows it.
Disclosure Timeline
- May 11, 2026 | Reported Block suppression via NU5 same-header body poisoning of sent-hash cache
- May 15, 2026 | Reported Sync restart poisoning from single unauthenticated peer via above-lookahead block
- May 29, 2026 | Zcash Foundation published GHSA-4m69-67m6-prqp
- May 29, 2026 | Zcash Foundation published GHSA-gvjc-3w7c-92jx
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.