GitHub Advisoryadvisory · crates.io
GHSA-3whf-vgf2-9w6g zaino-state has a Non-Finalized State Reorg — No Cycle Detection or Depth Limit
MEDIUM
Official page ↗
- Ecosystem
- crates.io
- Packages
- zaino-state
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
- Weaknesses
- CWE-770
- Reviewed
- 2026-07-31
Affected packages
• crates.io zaino-state: < 0.4.1 → fixed in 0.4.1
Description
Summary
`NonFinalizedState::handle_reorg` is a recursive, unbounded async function that traverses parent blocks until it finds a common ancestor on the main chain. It has no recursion depth limit and no cycle detection. A malicious or buggy validator can serve a block whose `previous_block_hash` points back to itself (or forms a cycle with other blocks), causing `handle_reorg` to infinite-loop, consuming 100% CPU and never making sync progress. Additionally, `update()` contains an `.expect("empty snapshot impossible")` that panics if the non-finalized snapshot becomes empty after trimming finalized blocks.
Details
Location: `packages/zaino-state/src/chain_index/non_finalised_state.rs:443-489`
```rust
async fn handle_reorg(
&self,
working_snapshot: &mut NonfinalizedBlockCacheSnapshot,
block: &impl Block,
) -> Result {
let prev_block = match working_snapshot
.get_block_by_hash_bytes_in_serialized_order(block.prev_hash_bytes_serialized_order())
.cloned()
{
Some(prev_block) => {
if !working_snapshot
.heights_to_hashes
.values()
.any(|hash| hash == prev_block.hash())
{
Box::pin(self.handle_reorg(working_snapshot, &prev_block)).await? // {
let prev_block = self
.source
.get_block(HashOrHeight::Hash(
zebra_chain::block::Hash::from_bytes_in_serialized_order(
block.prev_hash_bytes_serialized_order(),
),
))
.await
.map_err(|e| { ... })?
.ok_or(SyncError::ValidatorConnectionError(...))?;
Box::pin(self.handle_reorg(working_snapshot, &*prev_block)).await? // 1000 blocks would recurse >1000 times. Each async recursion creates a new `Box::pin` future on the heap. While this won't exhaust the native stack immediately, it will allocate unbounded heap memory and CPU time, effectively DoS-ing the sync task.
`.expect("empty snapshot impossible")` panic:
Location: `packages/zaino-state/src/chain_index/non_finalised_state.rs:543-548`
```rust
new_snapshot.remove_finalized_blocks(finalized_height);
let best_block = &new_snapshot
.blocks
.values()
.max_by_key(|block| block.chainwork())
.cloned()
.expect("empty snapshot impossible"); // ` during traversal to detect cycles and abort with an error.
3. Replace `.expect("empty snapshot impossible")` with a proper `Err(UpdateError::DatabaseHole)` or similar error return.
Additional Attack Vectors
• Deep reorg DoS: A miner with significant hash power (or a compromised validator) triggers a deep reorg. Zaino spends excessive CPU and memory in `handle_reorg`, starving the async runtime and stalling response serving.
• Fork-choice manipulation: By serving cyclic or very deep sidechains, an attacker can keep Zaino stuck in reorg handling indefinitely, preventing it from ever serving the real best chain.
Related
Weakness · CWE (1)
- CWE-770 Allocation of Resources Without Limits or Throttling weakness
References
- https://github.com/zingolabs/zaino/security/advisories/GHSA-3whf-vgf2-9w6g web
- https://github.com/zingolabs/zaino/pull/1172 web
- https://github.com/zingolabs/zaino/commit/428822509bc722eb9727681752686ede9bc87e77 web
- https://github.com/zingolabs/zaino/commit/d874295f1377bec7fd712ef75b364181b8c77d46 web
- https://github.com/zingolabs/zaino/commit/e05112aac54ec3cdb6da29fbc143ea710b32f009 web
- https://github.com/zingolabs/zaino package