From 90b196b1ca65c96534c9aaab8ae51eccd2d1cc45 Mon Sep 17 00:00:00 2001 From: qu0b Date: Fri, 24 Jul 2026 10:49:38 +0000 Subject: [PATCH] fix(beacon): verify fetched beacon state hashes to the block state root 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 Claude-Session: https://claude.ai/code/session_017K6pyDaSgX2FcLtQ1qQ8Vu --- pkg/beacon/download.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/beacon/download.go b/pkg/beacon/download.go index cb37646..cc10424 100644 --- a/pkg/beacon/download.go +++ b/pkg/beacon/download.go @@ -389,6 +389,20 @@ func (d *Default) downloadAndStoreBeaconState(ctx context.Context, stateRoot pha return errors.New("beacon state is nil") } + // Verify that the state the node returned for this slot actually hashes to the + // state root committed to in the block. Nodes that are behind can otherwise + // respond to a by-slot state request with a state from a different chain (e.g. + // their stale head dialed forward through empty slots), which would then be + // stored and served under a state root it does not match. + computedRoot, err := d.sszEncoder.GetStateRoot(beaconState) + if err != nil { + return fmt.Errorf("failed to compute state root: %w", err) + } + + if computedRoot != stateRoot { + return fmt.Errorf("beacon state root mismatch: node %s returned a state for slot %d with root %#x, expected %#x", node.Config.Name, slot, computedRoot, stateRoot) + } + expiresAt := time.Now().Add(FinalityHaltedServingPeriod) if slot == phase0.Slot(0) { expiresAt = time.Now().Add(999999 * time.Hour)