Severity: P1 (Criteria B: “shutting down/disrupting block production”)
Summary
A single Byzantine validator can permanently stall block production on the Electroneum
Smart Chain by exploiting an inconsistency between how roundChangeSet.Add() and
isJustified() handle the HasBadProposal flag. The former accepts a SINGLE message’s
flag, while the latter requires a QUORUM. This allows a single malicious validator to
poison the proposer’s prepared-block cache, causing every subsequent proposal to fail
justification validation, resulting in an indefinite consensus stall.
This violates IBFT/QBFT’s fundamental liveness guarantee: the protocol should maintain liveness with up to f = floor((n-1)/3) Byzantine validators.
Root Cause
The Inconsistency
File: consensus/istanbul/core/justification.go
The isJustified() function (called before broadcasting a PRE-PREPARE) computes
hasBadProposal from a QUORUM count:
// justification.go - lines 37-51
hasBadProposalCount := 0
for _, rcm := range roundChangeMessages {
if rcm.HasBadProposal {
// ... dedup by source
hasBadProposalCount++
}
}
hasBadProposal := hasBadProposalCount >= uint(quorumSize) // REQUIRES QUORUM
But roundChangeSet.Add() (called when each ROUND-CHANGE message arrives) uses the
SINGLE message’s HasBadProposal flag:
// roundchange.go - roundChangeSet.Add()
roundChange := msg.(*qbfttypes.RoundChange)
if hasMatchingRoundChangeAndPrepares(roundChange, prepareMessages, quorumSize,
roundChange.HasBadProposal, // <-- SINGLE MESSAGE'S FLAG, NOT QUORUM
rcs.validatorSet) == nil {
rcs.highestPreparedRound[round] = preparedRound
rcs.highestPreparedBlock[round] = preparedBlock // POISONED
rcs.prepareMessages[round] = prepareMessages
}
What HasBadProposal=true Bypasses
In hasMatchingRoundChangeAndPrepares() (justification.go, lines 175-182):
for _, p := range prepareMessages {
if p.Digest != roundChange.PreparedDigest && !hasBadProposal {
return errors.New("prepared message digest does not match...")
}
}
When hasBadProposal=true, PREPARE messages for block A are accepted as justification
for a ROUND-CHANGE claiming block B was prepared. The round check still applies (PREPARE
round must match RC’s PreparedRound), but the digest/block mismatch is ignored.
Exploit Chain
Prerequisites
- Attacker controls ONE validator key (within IBFT’s Byzantine tolerance)
- Normal block production is occurring
Step-by-Step
Phase 1, Capture PREPARE Messages
- Sequence S, Round 0: The proposer proposes block A.
- Validators reach the PREPARED state, they broadcast PREPARE messages for (sequence=S, round=0, digest=hash(A)).
- The attacker (a validator) captures these PREPARE messages from the P2P gossip. These messages are signed by legitimate validators and are freely available to any peer.
- If COMMIT quorum is NOT reached (timeout, network delay, or attacker withholds their COMMIT), a round change begins.
Phase 2, Poison the Round Change Set
- For round 1, the attacker crafts a ROUND-CHANGE message:
Round: 1 PreparedRound: 0 (matches the captured PREPAREs) PreparedBlock: block B (ATTACKER'S ARBITRARY BLOCK) PreparedDigest: hash(B) (does NOT match the PREPAREs' digest) HasBadProposal: true (bypasses digest check in Add()) Justification: [captured PREPARE messages for block A, round 0] -
The attacker signs this RC with their validator key and broadcasts it.
-
When any validator processes this RC in
handleRoundChange(), it callsroundChangeSet.Add():,preparedRound(0) != nil→ enters the block,highestPreparedRound[1] == nil→ condition met,hasMatchingRoundChangeAndPrepares(rc, prepares, quorum, true, valSet):- PREPARE.Round(0) == RC.PreparedRound(0) → PASS
- PREPARE.Digest(hash(A)) != RC.PreparedDigest(hash(B)) && !true → PASS (bypassed)
- Quorum of distinct validator signatures → PASS
- Result:
highestPreparedBlock[1] = block B(POISONED)
Phase 3, Consensus Stall
-
Legitimate validators also send their RCs for round 1 (with preparedRound=nil, since they didn’t complete a prepare phase after round 0’s stall, or with preparedRound=0 and the correct block A).
-
If a legitimate RC has preparedRound=0 and correct block A, it would try to set highestPreparedBlock. But:,
preparedRound(0).Cmp(highestPreparedRound[1](0)) > 0→ FALSE (0 is NOT > 0), The legitimate entry does NOT overwrite the poisoned one. -
When quorum of RCs is reached for round 1, and the proposer for round 1 executes: ```go // roundchange.go, handleRoundChange() _, proposal := c.highestPrepared(currentRound) // Returns block B (poisoned) // HasBadProposal(hash(B)) returns false (block B is unknown) // proposal = block B
// isJustified(blockB, rcPayloads, preparesForBlockA, quorum, valSet) // In isJustified: hasBadProposalCount < quorum (only 1 RC has flag) // Digest check: hash(B) != PREPARE.digest(hash(A)) && !false → FAILS // Result: “prepared messages do not match proposal” error ```
-
The proposal is BLOCKED. No block is proposed for round 1. Round times out.
Phase 4, Persistent Stall
-
startNewRound(2)is called.ClearLowerThan(2)clears rounds 0 and 1. Round 2 starts fresh. -
The attacker re-sends a poisoned RC for round 2:
- Same captured PREPAREs from round 0 (they’re still valid)
- PreparedRound=0, PreparedBlock=C (another arbitrary block), HasBadProposal=true
-
In
roundChangeSet.Add()for round 2:highestPreparedRound[2] == nil→ condition met- poisoning succeeds again
-
This repeats indefinitely. The attacker can stall EVERY round at this sequence using the SAME captured PREPARE messages from the original round 0.
-
Since no block is ever committed, the sequence never advances, and the roundChangeSet is never fully reset (only
round == 0triggersnewRoundChangeSet).
Impact
- Permanent consensus stall: Block production halts indefinitely at a specific block height. No new blocks, no transaction processing.
- Single Byzantine validator: Only one compromised validator key is required, well within IBFT’s designed tolerance of f = floor((n-1)/3).
- No recovery without intervention: The stall persists as long as the attacker continues sending poisoned RCs. Network restart or attacker removal is required.
- MEV extraction: Attacker controls when chain resumes and who proposes next block.
- Market manipulation: Timed chain halts enable external short positions.
- Maps to P1 Criteria B: “gaming consensus for monetary advantage”
MEV Escalation, From DoS to Monetary Gain
The consensus stall is not merely a denial-of-service. The attacker controls WHEN the chain stalls and WHEN it resumes. Combined with deterministic proposer selection, this converts the stall into a consensus-level MEV extraction tool.
Proposer Selection During Round Changes
Proposer selection is deterministic round-robin (validator/default.go:138-148):
func roundRobinProposer(valSet, proposer, round) {
seed = indexOf(lastProposer) + round + 1
pick = seed % N
return valSet.GetByIndex(pick)
}
During a stall, lastProposer is fixed (last committed block’s proposer). The attacker
at validator index A becomes proposer at round:
K = (A - lastProposerIdx - 1) mod N
This is fully predictable before the attack begins.
Full MEV Attack Chain
- Attacker (1 of N validators) captures PREPARE messages from gossip
- Sends ROUND-CHANGE with HasBadProposal=true + arbitrary preparedBlock + mismatched PREPAREs
roundChangeSet.Add()accepts due to single-message HasBadProposal bypass (no quorum check)highestPreparedBlockis poisoned →isJustified()fails → no block proposed → round timeout- Attacker re-poisons each round, stalling consensus indefinitely
- Attacker monitors mempool during stall, sees all pending transactions accumulating
- Attacker calculates round K where they become proposer:
K = (attackerIdx - lastProposerIdx - 1) % N - At round K, attacker stops poisoning, clean round, attacker is proposer
- Attacker’s miner constructs block with chosen transaction ordering, frontrunning, sandwich, censorship
- Honest validators accept normally, clean round, valid block
- Repeat from step 2 for next profitable opportunity
Concrete Scenario
Setup: 4 validators (A, B, C, D). Attacker controls A (index 0). Last proposer was D (index 3).
- Round 0: proposer = (3 + 0 + 1) % 4 = 0 → Attacker A
- Round 1: proposer = (3 + 1 + 1) % 4 = 1 → Validator B
If attacker is round 0 proposer: 1. See 100K ETN DEX swap in mempool 2. Build block: [attacker buy] → [victim swap] → [attacker sell] 3. Propose normally (isJustified not checked at round 0) 4. Collect sandwich profit
If attacker is NOT round 0 proposer: 1. Let round 0 proceed, withhold COMMIT → round change 2. Poison rounds until attacker’s turn 3. Resume, propose with MEV-optimized block
Economic Impact
- ETN market cap: $17.4M, daily volume: 415K transactions
- Timed chain halt + external short position = market manipulation profit
- BFT tolerance reduced from (n-1)/3 to 0, single validator breaks all guarantees
- Attack costs nothing (no gas, no stake slashing) and is repeatable indefinitely
Affected Code Paths
| File | Function | Line | Issue |
|---|---|---|---|
consensus/istanbul/core/roundchange.go |
roundChangeSet.Add() |
~249 | Uses single message’s HasBadProposal |
consensus/istanbul/core/justification.go |
isJustified() |
~37-51 | Requires quorum of HasBadProposal |
consensus/istanbul/core/justification.go |
hasMatchingRoundChangeAndPrepares() |
~175 | Digest bypass with hasBadProposal=true |
consensus/istanbul/core/roundchange.go |
handleRoundChange() |
~118 | Uses poisoned highestPreparedBlock |
consensus/istanbul/core/core.go |
startNewRound() |
~183 | ClearLowerThan doesn’t prevent re-poisoning |
Suggested Fix
In roundChangeSet.Add(), do NOT use the single message’s HasBadProposal flag.
Either:
1. Always require digest match in hasMatchingRoundChangeAndPrepares() when called
from Add() (set hasBadProposal=false), or
2. Defer the highestPreparedBlock decision until quorum is reached and compute
hasBadProposal from the full set of RC messages, consistent with isJustified().
Source · github.com/zionsworking/security-research-notebook · writeups/electroneum/qbft-hasbadproposal-consensus-stall.md