← THE INDEX  ·  WRITEUP

Electroneum QBFT HasBadProposal Quorum Inconsistency Enables Permanent Consensus Stall

roundChangeSet.Add() trusts a single message's HasBadProposal flag to skip the digest check. isJustified() requires a quorum. One Byzantine validator can exploit this inconsistency to poison the prepared-block cache and stall consensus indefinitely.

Summary

The QBFT consensus implementation in Electroneum Smart Chain has an inconsistency in how HasBadProposal is evaluated across two functions. isJustified() (called before a proposer broadcasts a PRE-PREPARE) computes hasBadProposal from a quorum count of round-change messages carrying the flag. roundChangeSet.Add() (called when each individual round-change message arrives) uses the flag from that single message directly.

When hasBadProposal=true is passed to hasMatchingRoundChangeAndPrepares(), the digest consistency check is bypassed: PREPARE messages for block A are accepted as justification for a ROUND-CHANGE claiming block B was prepared. This allows a single Byzantine validator to inject a ROUND-CHANGE whose PreparedBlock does not match the PREPARE messages it carries, poisoning the highestPreparedBlock cache for the current round with an arbitrary block hash.

When the proposer for that round reads the poisoned cache and builds a proposal, isJustified() finds the PREPARE messages do not match the proposed block (requiring a quorum of HasBadProposal flags that does not exist with only one malicious validator). The proposal fails validation and no block is committed. The round times out, the attacker re-poisons the next round with the same captured PREPARE messages, and the stall continues indefinitely at that sequence number.

This reduces IBFT's Byzantine fault tolerance from f = floor((n-1)/3) to zero for this attack class. One compromised validator key is sufficient.

Impact

A single Byzantine validator can halt block production indefinitely at any chosen sequence number. The attack requires only one validator key (well within IBFT's designed tolerance), and uses PREPARE messages freely available on the gossip network.

No recovery occurs without operator intervention: the stall persists as long as the attacker continues sending poisoned ROUND-CHANGE messages, which they can do at negligible cost. Restarting nodes does not help because the attacker can re-trigger the stall at the same sequence immediately.

The liveness violation also enables MEV extraction. Proposer selection is deterministic round-robin, so an attacker who controls one validator index can calculate the round at which they become proposer. By stalling through earlier rounds and resuming once they hold the proposer role, they gain the ability to order transactions at will: frontrunning, sandwich attacks, and targeted censorship. The combination of a zero-cost liveness kill with deterministic proposer selection converts a protocol-level DoS into a repeatable revenue mechanism.

Root cause

The inconsistency spans three functions in consensus/istanbul/core/:

roundchange.go -- roundChangeSet.Add() (the poisoning entry point):

// Uses the SINGLE message's HasBadProposal to bypass digest check:
if hasMatchingRoundChangeAndPrepares(roundChange, prepareMessages, quorumSize,
    roundChange.HasBadProposal,   // single-message flag
    rcs.validatorSet) == nil {
    rcs.highestPreparedBlock[round] = preparedBlock  // poisoned with arbitrary block
}

justification.go -- isJustified() (requires quorum):

hasBadProposalCount := 0
for _, rcm := range roundChangeMessages {
    if rcm.HasBadProposal {
        hasBadProposalCount++
    }
}
hasBadProposal := hasBadProposalCount >= uint(quorumSize)  // requires quorum

justification.go -- hasMatchingRoundChangeAndPrepares() (the bypass):

for _, p := range prepareMessages {
    if p.Digest != roundChange.PreparedDigest && !hasBadProposal {
        return errors.New("prepared message digest does not match...")
    }
}
// When hasBadProposal=true, digest mismatch is silently ignored

When Add() accepts a poisoned entry, subsequent legitimate RCs with the same PreparedRound cannot overwrite it because the highestPreparedRound comparison (round must be strictly greater) evaluates false for equal values. The proposer reads the poisoned block, isJustified() rejects it, and the round stalls.

Proof of concept

The attack chain below requires one validator key and PREPARE messages captured from the gossip network (freely available to any peer).

Disclosure and fix

Reported to the Electroneum security team as a P1 consensus liveness violation.

Two equivalent fixes:

Option A (simpler): Always pass hasBadProposal=false when calling hasMatchingRoundChangeAndPrepares() from roundChangeSet.Add(). This forces digest consistency to be checked at add-time for every incoming ROUND-CHANGE, regardless of the flag value.

Option B (consistent with isJustified): Defer the highestPreparedBlock assignment until a quorum of ROUND-CHANGE messages has been collected, and compute hasBadProposal from the full quorum using the same counting logic as isJustified(). This preserves the semantics of the flag while making both functions agree on what constitutes a valid quorum.