Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
Consensus stall

QBFT HasBadProposal Quorum Inconsistency, Consensus Liveness Violation

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

  1. Sequence S, Round 0: The proposer proposes block A.
  2. Validators reach the PREPARED state, they broadcast PREPARE messages for (sequence=S, round=0, digest=hash(A)).
  3. 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.
  4. 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

  1. 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]
  2. The attacker signs this RC with their validator key and broadcasts it.

  3. When any validator processes this RC in handleRoundChange(), it calls roundChangeSet.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

  1. 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).

  2. 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.

  3. 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 ```

  4. The proposal is BLOCKED. No block is proposed for round 1. Round times out.

Phase 4, Persistent Stall

  1. startNewRound(2) is called. ClearLowerThan(2) clears rounds 0 and 1. Round 2 starts fresh.

  2. 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
  3. In roundChangeSet.Add() for round 2:

    • highestPreparedRound[2] == nil → condition met
    • poisoning succeeds again
  4. This repeats indefinitely. The attacker can stall EVERY round at this sequence using the SAME captured PREPARE messages from the original round 0.

  5. Since no block is ever committed, the sequence never advances, and the roundChangeSet is never fully reset (only round == 0 triggers newRoundChangeSet).


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

  1. Attacker (1 of N validators) captures PREPARE messages from gossip
  2. Sends ROUND-CHANGE with HasBadProposal=true + arbitrary preparedBlock + mismatched PREPAREs
  3. roundChangeSet.Add() accepts due to single-message HasBadProposal bypass (no quorum check)
  4. highestPreparedBlock is poisoned → isJustified() fails → no block proposed → round timeout
  5. Attacker re-poisons each round, stalling consensus indefinitely
  6. Attacker monitors mempool during stall, sees all pending transactions accumulating
  7. Attacker calculates round K where they become proposer: K = (attackerIdx - lastProposerIdx - 1) % N
  8. At round K, attacker stops poisoning, clean round, attacker is proposer
  9. Attacker’s miner constructs block with chosen transaction ordering, frontrunning, sandwich, censorship
  10. Honest validators accept normally, clean round, valid block
  11. 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