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.
- 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_hashmatched 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
protoArrayand confirmed: this is spec-compliant behaviour, not a Lodestar bug. The attack succeeds at the Gloas fork-choice spec level.
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 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.
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".
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)viaget_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.
- 28 from slot-37916 same-slot voters for 0x724a (supported_node
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.
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.
The attack works because:
- 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.
- 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)=82vs(0x5704,EMPTY)≈28, soFULLshould 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). - 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. - 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.
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:
- 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. - (Spec) Consider increasing the weight given to the
idx=1 / FULLattestation 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. - (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.
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.logfor 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