Skip to content

core: exempt empty no-op blocks from sidechain ghost-state rejection#2293

Open
bit2swaz wants to merge 1 commit into
0xPolygon:developfrom
bit2swaz:fix/2224-sidechain-ghost-state-empty-block
Open

core: exempt empty no-op blocks from sidechain ghost-state rejection#2293
bit2swaz wants to merge 1 commit into
0xPolygon:developfrom
bit2swaz:fix/2224-sidechain-ghost-state-empty-block

Conversation

@bit2swaz

Copy link
Copy Markdown

Summary

Fixes #2224.

After a validator restart, a node could refuse to sync past an empty (zero-transaction) block, drop every peer with sidechain ghost-state attack detected, and need manual intervention to recover. In the logs sideroot and canonroot are the same value.

The cause is in insertSideChain() in core/blockchain.go. When a sidechain block hits a pruned ancestor, the code rejects any block whose state root equals the canonical block's state root at the same height, treating the match as a shadow-state attack (a block trying to reuse existing canonical state so it can skip re-execution of its ancestors). That assumption only holds for a block that carried a state transition. An empty block makes no state transition, so its state root equals its parent's. Two legitimately distinct empty blocks at the same height (different seal, timestamp, or coinbase, so different hashes) therefore share a state root, and the check fires a false positive.

The fix narrows the rejection with one guard: the block is exempt only when its root equals its parent's root, which means it asserted no new state and there is nothing forged to skip-verifying. A block that claims the canonical root but differs from its parent's root (the real shadow-state attack, including a forged empty-looking block) is still rejected. If the parent header is unavailable, the original conservative behavior (reject) is kept.

This is go-ethereum derived code, so the same bug likely exists upstream.

Executed tests

  • TestSideImportEmptyBlockGhostState (new): builds a freezer-pruned canonical chain, generates a distinct empty sibling at a pruned height whose root equals both its parent's and the canonical block's, re-imports it, and asserts no ghost-state error. Confirmed it fails against the pre-fix code with the exact sidechain ghost-state attack error, then passes after the fix.
  • TestSideImportGhostStateStillRejected (new): asserts the attack path is still rejected. A side block that performs a real state transition (root differs from its parent) but claims a root equal to the canonical block must still return the ghost-state error.
  • TestSideImportPrunedBlocks (existing): still passes; the non-empty sidechain re-import path is unchanged.
  • go test ./core/ passes with no regressions.
  • gofmt -l core/blockchain.go core/blockchain_test.go is clean.
  • go build ./... compiles.

Both new tests run against the hash and path trie schemes.

Rollout notes

This change is consensus-relevant but backwards-compatible. It only narrows an existing rejection: it never accepts a block the old code would have accepted, and it still rejects every block whose root differs from its parent's. Empty blocks that previously wedged a node now import as normal sidechain blocks and fall through to the existing TD and reorg logic. No new dependencies, no config or storage format changes.

The guarded path is live in bor: insertSideChain runs on ErrPrunedAncestor with setHead, and bor uses validator-based difficulty and TD, which is the path the issue's production trace shows.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@sonarqubecloud

Copy link
Copy Markdown

@pratikspatil024 pratikspatil024 requested a review from Copilot July 1, 2026 08:34
@pratikspatil024

Copy link
Copy Markdown
Member

@claude review once

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

Fixes a false-positive “sidechain ghost-state attack” rejection during sidechain import when encountering empty (no-op) blocks whose state root legitimately matches canonical state at the same height, which could otherwise wedge sync after restarts.

Changes:

  • Narrow insertSideChain’s ghost-state rejection by exempting blocks that appear to have made no state transition (root matches parent).
  • Add regression/security tests covering the empty-block false positive and ensuring the original rejection still triggers for a true shadow-state scenario.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
core/blockchain.go Adjusts the ghost-state attack heuristic in insertSideChain to exempt apparent no-op blocks.
core/blockchain_test.go Adds tests reproducing the empty-block sync wedge and asserting the attack path remains rejected.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/blockchain.go
Comment on lines +3697 to +3705
parent := bc.GetHeader(block.ParentHash(), block.NumberU64()-1)
if parent == nil || parent.Root != block.Root() {
log.Warn("Sidechain ghost-state attack detected", "number", block.NumberU64(), "sideroot", block.Root(), "canonroot", canonical.Root())

// If someone legitimately side-mines blocks, they would still be imported as usual. However,
// we cannot risk writing unverified blocks to disk when they obviously target the pruning
// mechanism.
return nil, it.index, errors.New("sidechain ghost-state attack")
}

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This narrows a consensus-critical guard in insertSideChain, so I'd like a human to sign off on the ghost-state security reasoning even though the change itself looks well-scoped and correct.

Extended reasoning...

Overview

The PR modifies core/blockchain.go:insertSideChain to exempt empty (no-op) blocks from the ghost-state attack rejection. The fix adds a guard: only reject when the block's root differs from its parent's root — i.e., when the block actually asserts a state transition. When the block's root equals its parent's, it is a genuine empty block that legitimately shares a state root with the canonical block at the same height. If the parent header is missing, the code falls back to the original conservative reject.

Security risks

This is consensus-relevant code — insertSideChain is on the sidechain re-import path triggered by ErrPrunedAncestor. The fix only narrows the rejection, never broadens acceptance for blocks with a real state transition, so it cannot cause the original attack (a forged block claiming canonical state to skip re-execution) to succeed. For the exemption to fire, block.Root() == parent.Root() == canonical.Root(), meaning the block introduces no new state and there is nothing forged to skip-verify. The check remains applied recursively per block in the loop, so a chain of forged non-empty blocks still fails at the first block whose root differs from its parent's. The nil-parent fallback preserves the conservative behavior.

Level of scrutiny

High — this touches security-sensitive chain insertion logic (falls under blockchain-security.md scope) that guards against shadow-state attacks on the pruning mechanism. Even a narrow relaxation of a consensus guard warrants human sign-off. The reasoning is sound and the tests directly assert both the false-positive fix and that the real attack path is still rejected across both hash and path schemes, but a human familiar with the ghost-state threat model and the bor sidechain re-import path should confirm the argument holds.

Other factors

Two new tests exercise the fix and the security counterpart, both across HashScheme and PathScheme. TestSideImportPrunedBlocks (existing) is preserved. The description explicitly references bor's live path (validator-based difficulty, TD, setHead reorgs), suggesting the author validated the code path. SonarQube quality gate passed. The go-ethereum upstream likely has the same bug — worth noting for maintenance.

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.

Bor sync wedges on empty block after producer restart — insertSideChain same-stateRoot false-positive

3 participants