Skip to content

fix(beacon): verify fetched beacon state hashes to the block state root - #259

Open
qu0b wants to merge 1 commit into
ethpandaops:release/gloasfrom
qu0b:qu0b/fix/gloas-verify-state-root
Open

fix(beacon): verify fetched beacon state hashes to the block state root#259
qu0b wants to merge 1 commit into
ethpandaops:release/gloasfrom
qu0b:qu0b/fix/gloas-verify-state-root

Conversation

@qu0b

@qu0b qu0b commented Jul 24, 2026

Copy link
Copy Markdown
Member

Problem

downloadAndStoreBeaconState fetches the finalized state by slot from a single upstream and stores whatever bytes come back under the block's state_root, without ever verifying them. The state is then served verbatim at /eth/v2/debug/beacon/states/finalized.

A beacon node that has fallen behind can answer a by-slot state request with a state from a different chain view instead of erroring — lodestar (and lighthouse, in a different way) serve their stale head state dialed forward through empty slots for slots beyond their head. Checkpointz caches that fabricated state under the real block.state_root and serves it; checkpoint-syncing clients then fail their hash_tree_root(state) == block.state_root anchor check.

Observed on glamsterdam-devnet-7

  • lodestar-geth-1's head froze at slot 56351 while checkpointz still listed it healthy: true.
  • Asked for the state at the network's finalized checkpoint slot 71391, it returned a state with latest_block_header.slot = 56351 dialed forward ~15k empty slots, HTR 0xcb39fc23… — matching nothing on the canonical chain (expected 0x2d414744…).
  • The serving path's PastFinalizedCheckpoint node filter usually keeps such nodes out, but it filters on claimed finality, not head — finality stalls, optimistic finality claims, or a node freezing near a serving flip let a fabricating upstream through, and nothing downstream catches it.

Fix

Compute the hash tree root of the fetched state (existing sszEncoder.GetStateRoot) and reject the bundle if it doesn't match the state root committed to in the block. The bad upstream's bundle fails, and the serving loop retries with another node.

Cost: one state HTR per bundle download (once per serving-checkpoint flip / genesis fetch) — ~1s for a 73 MB gloas state.

Testing

  • go build ./... / go test ./... green.
  • Patched binary run against three glamsterdam-devnet-7 upstreams: downloaded, verified and served the epoch-2236 bundle (no false positive on a real 73 MB / 503k-validator gloas state), served bytes byte-identical to the upstream's by-root copy.

The same code exists on master; happy to open a port there too.

🤖 Generated with Claude Code

https://claude.ai/code/session_017K6pyDaSgX2FcLtQ1qQ8Vu

downloadAndStoreBeaconState fetches the state by slot from a single
upstream and stored whatever came back under the block's state root
without verification. A node that is behind can answer a by-slot state
request with a state from a different chain (e.g. its stale head dialed
forward through empty slots, as lodestar and lighthouse do), which
checkpointz would then serve at /eth/v2/debug/beacon/states/finalized.
Checkpoint-syncing clients fail with a state root mismatch against
block.state_root.

Compute the hash tree root of the fetched state and reject the bundle
if it does not match the state root committed to in the block.

Observed on glamsterdam-devnet-7 with a lodestar upstream whose head
was ~15k slots behind: it returned a fabricated state for the finalized
checkpoint slot instead of erroring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K6pyDaSgX2FcLtQ1qQ8Vu
@qu0b
qu0b requested a review from samcm as a code owner July 24, 2026 11:22
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.

1 participant