Skip to content

Instantly share code, notes, and snippets.

@lodekeeper
Created June 9, 2026 22:12
Show Gist options
  • Select an option

  • Save lodekeeper/d1f5b66fa476d99e81e7717150ca7625 to your computer and use it in GitHub Desktop.

Select an option

Save lodekeeper/d1f5b66fa476d99e81e7717150ca7625 to your computer and use it in GitHub Desktop.
glamsterdam-devnet-5 slots 37915-37918: deathstar payload reorg + Lodestar fork-choice audit

Deathstar payload-reorg analysis — glamsterdam-devnet-5 slots 37915–37918

Investigation of why a malicious proposer (deathstar running lodestar-ethrex-1) successfully orphaned the timely payload of slot 37915, and why every honest client downstream (nimbus, prysm, lodestar) followed onto the malicious chain.

TL;DR

  • Slot 37915 (lighthouse) had a timely payload (~22 ms publish).
  • Slot 37916 was proposed by a malicious "deathstar" node and reorged 37915's payload by submitting a bid whose parent_block_hash matched 37914's EL hash (i.e. treating 37915 as EMPTY).
  • The PTC voted 443/512 PRESENT for slot 37915 — overwhelming honest signal.
  • At slot 37916, 82/144 attesters voted "37915 still head, FULL" vs 28/132 voted "37916 head, parent EMPTY" — clear honest majority for the FULL branch.
  • Despite this, every honest client at slot 37917 (nimbus) set head to the deathstar chain, every honest client at slot 37918 (prysm) extended it, and Lodestar at slot 37917 also flipped head onto the deathstar chain.
  • I audited Lodestar's protoArray and confirmed: this is spec-compliant behaviour, not a Lodestar bug. The attack succeeds at the Gloas fork-choice spec level.

The chain

Bids tell the story (bid.parent_block_root and bid.parent_block_hash for each proposer):

Slot Proposer parent_root bid.parent_block_hash bid.block_hash
37915 lighthouse-ethrex-1 0xe5b7… (37914) 0x408f6b… (37914's EL) 0x7dbfbd…
37916 deathstar (lodestar-ethrex-1) 0x5704… (37915) 0x408f6b… (37914's EL again — ignores 37915 FULL) 0xcf81d4…
37917 nimbus-erigon-1 0x724a… (37916) 0xcf81d4… (37916's EL) 0x5f92df…
37918 prysm-nethermind-2 0x5950… (37917) 0x5f92df… (37917's EL) 0xeda194…

Deathstar's block 37916 acknowledges 37915's beacon block (parent_root) but builds the new payload on 37914's EL — explicitly treating 37915 as EMPTY. That's the reorg of 37915's payload.

PTC vote on 37915

PTC aggregation included in slot 37916's body:

data.beacon_block_root = 0x5704… (slot 37915)
data.slot              = 37915
data.payload_present   = true
data.blob_data_available = true
aggregation_bits       = 443 set / 69 unset (committee size 512)

443/512 = 86.5% PRESENT. No ABSENT aggregation included (likely deathstar suppressed it; possibly there were no ABSENT voters at all).

PTC behaved correctly. The malicious branch wasn't enabled by PTC failure.

Attestation distribution (slot 37915–37917 votes)

From regular Attestation aggregations included across blocks 37915–37927:

att slot idx head meaning ones / size
37915 0 0x5704 same-slot vote for 37915 head (idx=0 mandatory) 118 / 144
37916 1 0x5704 slot-37916 attester: "37915 still head, payload FULL" 82 / 144
37916 0 0x724a slot-37916 attester: "37916 head" (same-slot, idx=0 mandatory) 28 / 132
37917 0 0x5950 same-slot vote for 37917 head 117 / 144
37918 0 0x66cf same-slot vote for 37918 head 111 / 144

Honest weight on the FULL branch at slot 37916 (82) was almost 3× the malicious-aligned same-slot vote on 37916 (28). The honest minority tried to reorg deathstar by voting "37915 still head".

Why Lodestar still moved head to 37917 at slot 37917

At slot 37916 tick, Lodestar correctly held head at lighthouse 0x5704 (sync log: head: (slot -1) 0x5704…). Deathstar's 37916 was added to forkchoice but didn't win.

At slot 37917 tick (nimbus posts 0x5950), Lodestar flipped head to nimbus's chain. The EL did a reorg from 0x7dbf… (lighthouse's 37915 payload) to 0xcf81… (deathstar's 37916 payload).

Per specs/gloas/fork-choice.md:

def get_attestation_score(store, node, state) -> Gwei:
    # sum effective balance of validators where
    # is_ancestor(supported_node, node) — i.e. node is an ancestor of supported_node
    ...

With get_supported_node mapping idx=1/idx=0/same-slot → FULL/EMPTY/PENDING, the per-node scores at slot 37917 head computation:

  • (0x5704, FULL) score = 82 (slot-37916 idx=1 voters; no descendants on FULL branch since no block built bid.parent_block_hash = 0x7dbfbd)
  • (0x5704, EMPTY) score ≈ 28 + 117 + ~57 (proposer boost) ≈ 202:
    • 28 from slot-37916 same-slot voters for 0x724a (supported_node (0x724a, PENDING), walks up to (0x5704, EMPTY) via get_parent_payload_status).
    • 117 from slot-37917 same-slot voters for 0x5950 (supported_node (0x5950, PENDING), walks (0x5950,PENDING) → (0x724a,FULL) → (0x724a,PENDING) → (0x5704,EMPTY)).
    • Proposer boost ~57 on (0x5950, PENDING) propagates the same path.

202 > 82 → (0x5704, EMPTY) wins at the (0x5704, PENDING) level. Walk continues into deathstar's subtree.

Lodestar's protoArray back-propagates weights via parent pointers along exactly the same edges, so its computed weights match the spec.

Lodestar audit — items checked

packages/fork-choice/src/forkChoice/forkChoice.ts and packages/fork-choice/src/protoArray/protoArray.ts:

Concern Lodestar location Spec match
Map idx=1/idx=0/same-slot → FULL/EMPTY/PENDING onAttestation lines 909-932 ✓ matches get_supported_node
Proposer boost applied only to PENDING variant applyScoreChanges line 408 ✓ matches proposer_boost_node=PENDING
is_previous_slot_payload_decision zeroes weight on EMPTY/FULL of prev-slot block maybeUpdateBestChildAndDescendant lines 1438-1449 (childEffectiveWeight) ✓ matches get_weight returning 0
Tiebreaker via should_extend_payload for prev-slot FULL getPayloadStatusTiebreaker lines 1137-1157 ✓ matches get_payload_status_tiebreaker
Parent edges for PENDING/EMPTY/FULL variants onBlock lines 485-547, onExecutionPayload lines 594-672 ✓ FULL/EMPTY parent = own PENDING; PENDING parent = parent block's EMPTY or FULL per getParentPayloadStatus
Weight back-prop via parent pointers applyScoreChanges lines 419-440 equivalent to spec's is_ancestor-based sum

No deviation found.

Where the attack succeeds — spec-level discussion

The attack works because:

  1. Deathstar wins the block race at slot 37916 — published at 1996 ms, well inside the attestation cutoff, so it eats some attester weight at slot 37916.
  2. Nimbus at slot 37917 follows its local fork-choice. At nimbus's slot-37917 head computation (before its own proposer boost is applied to a block it hasn't yet posted), the score is (0x5704,FULL)=82 vs (0x5704,EMPTY)≈28, so FULL should win and nimbus should build on (0x5704, FULL). If nimbus instead built on the deathstar chain, that's nimbus's bug (Nico flagged this — nimbus has confirmed they don't honour PTC/payload-status correctly).
  3. Once nimbus posts 0x5950 on the deathstar chain, slot-37917 same-slot attesters vote for it (117 votes), proposer boost adds ~57, and (0x5704, EMPTY) subtree weight jumps from 28 to 202 — past (0x5704, FULL)'s 82.
  4. From this point, every honest client correctly follows the heavier chain into the deathstar branch. Lodestar at slot 37917 sees the new state and correctly flips head, per spec.

The "lone honest minority on FULL branch" can't recover because:

  • No block was built on (0x5704, FULL), so it has no descendants accumulating weight.
  • The honest minority can't unilaterally insert a block onto the FULL branch — only a proposer can, and the slot-37917 proposer (nimbus) didn't.

Root cause framing

This is a wrong-side-of-the-attestation-window failure of a single client (nimbus at slot 37917), amplified by the spec's "weight decides EMPTY vs FULL" rule into a chain-wide loss of 37915's timely payload.

Mitigations to discuss:

  1. Fix nimbus's slot-37917-style fork-choice to honour the (0x5704, FULL) weight when it locally exceeds (0x5704, EMPTY). This is the highest-impact change — it would have prevented the attack from propagating.
  2. (Spec) Consider increasing the weight given to the idx=1 / FULL attestation on a previous-previous-slot block, since the only way to give weight to a FULL branch is via these idx=1 votes — there's no way for proposer boost or same-slot votes to land on FULL.
  3. (Spec) Consider letting PTC quorum on a slot directly raise that slot's FULL-node weight, so 443 PRESENT votes on 37915 don't just sit in PTC-state and instead show up in fork-choice weight.

Data

All analysis done from lodestar-nethermind-1.srv.glamsterdam-devnet-5.ethpandaops.io:

  • Beacon API /eth/v2/beacon/blocks/{slot} for blocks 37915–37927.
  • /data/lodestar/beacon-2026-06-09.log for chain head events.
  • Local time of slot 37915: 2026-06-09 19:22:48 UTC. Slot duration 12 s.
  • Lodestar's own sync trace at slot 37917:
    19:23:00.108 New chain head slot=37915 root=0x5704…  ← lighthouse, FULL
    19:23:00.175 Persisted payload envelope to db slot=37915
    19:23:12     Added 0x724a (deathstar) to forkchoice — did NOT take head
    19:23:18     Synced slot 37916 head=(slot -1) 0x5704  ← still on lighthouse
    19:23:24.217 New chain head slot=37917 root=0x5950…  ← flipped to nimbus
    19:23:30     Synced slot 37917 EL block 22317 hash 0xcf81  ← EL reorged from 0x7dbf to 0xcf81
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment