Описание
zaino-state has a Non-Finalized State Reorg — No Cycle Detection or Depth Limit
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
Infinite loop via self-referencing block:
- A compromised validator serves a block
BwhereB.prev_hash == B.hash. handle_reorgis called withB.get_block_by_hash_bytes_in_serialized_order(B.prev_hash)findsBitself inworking_snapshot.blocks.- Check: is
B.hashinworking_snapshot.heights_to_hashes? IfBis a new chaintip not yet on the main chain, no. - Recurse with
prev_block=B(the exact same block). - This repeats forever. The async recursion builds a new
Box::pinfuture 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
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: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.
Ссылки
- https://github.com/zingolabs/zaino/security/advisories/GHSA-3whf-vgf2-9w6g
- https://github.com/zingolabs/zaino/pull/1172
- https://github.com/zingolabs/zaino/commit/428822509bc722eb9727681752686ede9bc87e77
- https://github.com/zingolabs/zaino/commit/d874295f1377bec7fd712ef75b364181b8c77d46
- https://github.com/zingolabs/zaino/commit/e05112aac54ec3cdb6da29fbc143ea710b32f009
Пакеты
zaino-state
< 0.4.1
0.4.1
6.9 Medium
CVSS4
Дефекты
6.9 Medium
CVSS4