GHSA-3WHF-VGF2-9W6G
Vulnerability from github – Published: 2026-07-31 19:49 – Updated: 2026-07-31 19:49Summary
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
async fn handle_reorg(
&self,
working_snapshot: &mut NonfinalizedBlockCacheSnapshot,
block: &impl Block,
) -> Result<IndexedBlock, SyncError> {
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? // <-- LINE 459
} else {
prev_block
}
}
None => {
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? // <-- LINE 483
}
};
let indexed_block = block.to_indexed_block(&prev_block, self).await?;
working_snapshot.add_block_new_chaintip(indexed_block.clone());
Ok(indexed_block)
}
Infinite loop via self-referencing block:
1. A compromised validator serves a block B where B.prev_hash == B.hash.
2. handle_reorg is called with B.
3. get_block_by_hash_bytes_in_serialized_order(B.prev_hash) finds B itself in working_snapshot.blocks.
4. Check: is B.hash in working_snapshot.heights_to_hashes? If B is a new chaintip not yet on the main chain, no.
5. Recurse with prev_block = B (the exact same block).
6. This repeats forever. The async recursion builds a new Box::pin future each iteration, consuming heap memory and CPU.
Stack exhaustion via deep reorg:
A deep reorg of >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
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"); // <-- LINE 548
If finalized_height is greater than or equal to all blocks in new_snapshot.blocks, remove_finalized_blocks retains only blocks at or above that height. If none exist, new_snapshot.blocks becomes empty. The .expect() then panics. While the comment claims this is "impossible," defensive programming dictates it is reachable under corruption or edge-case sync conditions.
PoC
- Run a regtest.
- Serve a block where
header.previous_block_hash == block.hash(). - Zaino's
NonFinalizedState::syncentershandle_reorgand infinite-loops. - Sync never completes. CPU usage pegs to 100%. No new blocks are served to clients.
Fix
- Add an explicit recursion depth limit (e.g., max 1000 iterations) and return
SyncError::ReorgFailureif exceeded:rust const MAX_REORG_DEPTH: usize = 1000; - Track visited hashes in a
HashSet<BlockHash>during traversal to detect cycles and abort with an error. - Replace
.expect("empty snapshot impossible")with a properErr(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.
{
"affected": [
{
"package": {
"ecosystem": "crates.io",
"name": "zaino-state"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.4.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T19:49:35Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n`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.\n\n### Details\n\n**Location:** `packages/zaino-state/src/chain_index/non_finalised_state.rs:443-489`\n\n```rust\nasync fn handle_reorg(\n \u0026self,\n working_snapshot: \u0026mut NonfinalizedBlockCacheSnapshot,\n block: \u0026impl Block,\n) -\u003e Result\u003cIndexedBlock, SyncError\u003e {\n let prev_block = match working_snapshot\n .get_block_by_hash_bytes_in_serialized_order(block.prev_hash_bytes_serialized_order())\n .cloned()\n {\n Some(prev_block) =\u003e {\n if !working_snapshot\n .heights_to_hashes\n .values()\n .any(|hash| hash == prev_block.hash())\n {\n Box::pin(self.handle_reorg(working_snapshot, \u0026prev_block)).await? // \u003c-- LINE 459\n } else {\n prev_block\n }\n }\n None =\u003e {\n let prev_block = self\n .source\n .get_block(HashOrHeight::Hash(\n zebra_chain::block::Hash::from_bytes_in_serialized_order(\n block.prev_hash_bytes_serialized_order(),\n ),\n ))\n .await\n .map_err(|e| { ... })?\n .ok_or(SyncError::ValidatorConnectionError(...))?;\n Box::pin(self.handle_reorg(working_snapshot, \u0026*prev_block)).await? // \u003c-- LINE 483\n }\n };\n let indexed_block = block.to_indexed_block(\u0026prev_block, self).await?;\n working_snapshot.add_block_new_chaintip(indexed_block.clone());\n Ok(indexed_block)\n}\n```\n\n**Infinite loop via self-referencing block:**\n1. A compromised validator serves a block `B` where `B.prev_hash == B.hash`.\n2. `handle_reorg` is called with `B`.\n3. `get_block_by_hash_bytes_in_serialized_order(B.prev_hash)` finds `B` itself in `working_snapshot.blocks`.\n4. Check: is `B.hash` in `working_snapshot.heights_to_hashes`? If `B` is a new chaintip not yet on the main chain, **no**.\n5. Recurse with `prev_block` = `B` (the exact same block).\n6. This repeats forever. The async recursion builds a new `Box::pin` future each iteration, consuming heap memory and CPU.\n\n**Stack exhaustion via deep reorg:**\nA deep reorg of \u003e1000 blocks would recurse \u003e1000 times. Each async recursion creates a new `Box::pin` future on the heap. While this won\u0027t exhaust the native stack immediately, it will allocate unbounded heap memory and CPU time, effectively DoS-ing the sync task.\n\n**`.expect(\"empty snapshot impossible\")` panic:**\n\n**Location:** `packages/zaino-state/src/chain_index/non_finalised_state.rs:543-548`\n\n```rust\nnew_snapshot.remove_finalized_blocks(finalized_height);\nlet best_block = \u0026new_snapshot\n .blocks\n .values()\n .max_by_key(|block| block.chainwork())\n .cloned()\n .expect(\"empty snapshot impossible\"); // \u003c-- LINE 548\n```\n\nIf `finalized_height` is greater than or equal to all blocks in `new_snapshot.blocks`, `remove_finalized_blocks` retains only blocks at or above that height. If none exist, `new_snapshot.blocks` becomes empty. The `.expect()` then panics. While the comment claims this is \"impossible,\" defensive programming dictates it is reachable under corruption or edge-case sync conditions.\n\n### PoC\n\n1. Run a regtest.\n2. Serve a block where `header.previous_block_hash == block.hash()`.\n3. Zaino\u0027s `NonFinalizedState::sync` enters `handle_reorg` and infinite-loops.\n4. Sync never completes. CPU usage pegs to 100%. No new blocks are served to clients.\n\n### Fix\n\n1. **Add an explicit recursion depth limit** (e.g., max 1000 iterations) and return `SyncError::ReorgFailure` if exceeded:\n ```rust\n const MAX_REORG_DEPTH: usize = 1000;\n ```\n2. **Track visited hashes** in a `HashSet\u003cBlockHash\u003e` during traversal to detect cycles and abort with an error.\n3. **Replace `.expect(\"empty snapshot impossible\")`** with a proper `Err(UpdateError::DatabaseHole)` or similar error return.\n\n### Additional Attack Vectors\n\n- **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.\n- **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.",
"id": "GHSA-3whf-vgf2-9w6g",
"modified": "2026-07-31T19:49:35Z",
"published": "2026-07-31T19:49:35Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/zingolabs/zaino/security/advisories/GHSA-3whf-vgf2-9w6g"
},
{
"type": "WEB",
"url": "https://github.com/zingolabs/zaino/pull/1172"
},
{
"type": "WEB",
"url": "https://github.com/zingolabs/zaino/commit/428822509bc722eb9727681752686ede9bc87e77"
},
{
"type": "WEB",
"url": "https://github.com/zingolabs/zaino/commit/d874295f1377bec7fd712ef75b364181b8c77d46"
},
{
"type": "WEB",
"url": "https://github.com/zingolabs/zaino/commit/e05112aac54ec3cdb6da29fbc143ea710b32f009"
},
{
"type": "PACKAGE",
"url": "https://github.com/zingolabs/zaino"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "zaino-state has a Non-Finalized State Reorg \u2014 No Cycle Detection or Depth Limit"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.