56345 bc insight there is an issue related to strict threshold breaks exact 2 3 and is causing finality freeze

Submitted on Oct 14th 2025 at 18:46:03 UTC by @XDZIBECX for Attackathon | VeChain Hayabusa Upgrade

  • Report ID: #56345

  • Report Type: Blockchain/DLT

  • Report severity: Insight

  • Target: https://github.com/vechain/thor/compare/master...release/hayabusa

  • Impacts:

    • Network not being able to confirm new transactions (total network shutdown)

Description

Brief / Intro

There is an issue in Hayabusa’s BFT finality logic in the Summarize() function: it checks whether enough validators participated to “finalize” an epoch. It should accept at least two-thirds participation, but the code mistakenly requires strictly more than two-thirds.

Concretely:

  • In PoA (count-based), the code uses len(votes) > thresholdVotes.

  • In DPoS (weight-based), the code uses justifiedWeight > thresholdWeight.

Both checks use > where the protocol requires >=. When participation is exactly two-thirds (66.67%), due to integer division producing exact two-thirds thresholds frequently, the epoch is treated as not justified/committed. As a result the Quality metric does not increase, the finalized checkpoint isn’t updated, and finality stalls. This can cause the chain to stop confirming new transactions whenever participation is exactly 2/3.

Vulnerability Details

The bug occurs in Summarize() where both PoA (vote-count) and DPoS (weight-based) branches use >:

justifier.go (relevant excerpt)
func (js *justifier) Summarize() *bftState {
    var justified, committed bool

    // Pre-HAYABUSA (PoA mode)
    if js.thresholdWeight == 0 {
        justified = uint64(len(js.votes)) > js.thresholdVotes      // should be >=
        committed = js.comVotes > js.thresholdVotes                 // should be >=
    } else {
        // Post-HAYABUSA (DPoS mode)
        justified = js.justifiedWeight > js.thresholdWeight         // should be >=
        committed = js.comWeight > js.thresholdWeight               // should be >=
    }

    var quality uint32
    if justified {
        quality = js.parentQuality + 1
    } else {
        quality = js.parentQuality
    }

    return &bftState{
        Quality:   quality,
        Justified: justified,
        Committed: committed,
    }
}

This violates the VIP specification which states finality requires at least two-thirds of the total weighted staked VET (see VIP-253): "At least" means using >=. The code instead requires strictly more than two-thirds (>), so exact equality fails.

The threshold is calculated using integer arithmetic as exactly two-thirds:

justifier.go (threshold calculation excerpt)
// PoS active path

if posActive {
    totalWeight, err := engine.getTotalWeight(sum) // Get total validator weight
    if err != nil {
        return nil, err
    }
    thresholdWeight := totalWeight * 2 / 3 // Calculate 2/3 threshold 
    return newJustifier(parentQuality, checkpoint, 0, thresholdWeight), nil
} else {
    mbp, err := engine.getMaxBlockProposers(sum)
    if err != nil {
        return nil, err
    }
    thresholdVotes := mbp * 2 / 3
    return newJustifier(parentQuality, checkpoint, thresholdVotes, 0), nil
}

When participation equals the computed threshold (exactly 2/3), Summarize() sets Justified=false and Committed=false, so Quality does not increase. Downstream, CommitBlock() only writes a new finalized checkpoint when state.Committed == true and state.Quality > 1. Therefore, no checkpoint is finalized and finality can stall. An attacker can withhold ~34% by stake to keep participation pinned at 2/3, indefinitely stalling finality until participation exceeds 2/3 by any epsilon.

Impact Details

Because Summarize() uses > instead of >= for the 2/3 supermajority:

  • Epochs with exactly two-thirds participation never reach Committed=true.

  • CommitBlock() never writes a new finalized checkpoint.

  • An attacker (or natural outages) withholding ~34% of stake can keep participation at exactly 2/3, stalling finality indefinitely.

  • During such a stall, new transactions cannot be finalized; exchanges/wallets treat transfers as unsafe; bridges and downstream protocols likely pause; and fork-choice progress based on Quality halts, causing a network-wide liveness failure.

Proof of Concept

Single minimal test proving the bug in both modes (PoA count and DPoS weight):

finality_bug_test.go
package bft

import (
    "testing"

    "github.com/vechain/thor/v2/test/datagen"
)

// Single minimal test proving the bug in both modes (PoA count and DPoS weight).
func TestFinalityBug_MinimalProof(t *testing.T) {
    // PoA mode (count-based): exactly 2/3 should pass per spec, but fails in code
    t.Run("PoA_exactly_two_thirds_fails", func(t *testing.T) {
        mbp := uint64(9)
        threshold := mbp * 2 / 3 // 6
        js := newJustifier(0, 0, threshold, 0)
        for i := uint64(0); i < threshold; i++ {
            js.AddBlock(datagen.RandAddress(), true, 0)
        }
        st := js.Summarize()
        if st.Justified || st.Committed {
            t.Fatalf("expected FAIL at exactly 2/3; got Justified=%v Committed=%v", st.Justified, st.Committed)
        }
    })

    // PoA mode: 2/3 + 1 passes (proves strict > instead of >=)
    t.Run("PoA_two_thirds_plus_one_passes", func(t *testing.T) {
        mbp := uint64(9)
        threshold := mbp * 2 / 3 // 6
        js := newJustifier(0, 0, threshold, 0)
        for i := uint64(0); i < threshold+1; i++ {
            js.AddBlock(datagen.RandAddress(), true, 0)
        }
        st := js.Summarize()
        if !st.Justified || !st.Committed {
            t.Fatalf("expected PASS at 2/3 + 1; got Justified=%v Committed=%v", st.Justified, st.Committed)
        }
    })

    // DPoS mode (weight-based): pick totals divisible by 3 to hit exact 2/3
    t.Run("DPoS_exactly_two_thirds_weight_fails", func(t *testing.T) {
        total := uint64(900_000_000)           // total network weight
        threshold := total * 2 / 3             // 600,000,000 (exact 2/3)
        js := newJustifier(0, 0, 0, threshold) // weight mode
        js.AddBlock(datagen.RandAddress(), true, threshold)
        st := js.Summarize()
        if st.Justified || st.Committed {
            t.Fatalf("expected FAIL at exactly 2/3 weight; got Justified=%v Committed=%v", st.Justified, st.Committed)
        }
    })

    // DPoS mode: threshold + 1 passes (proves strict > instead of >=)
    t.Run("DPoS_two_thirds_plus_one_weight_passes", func(t *testing.T) {
        total := uint64(900_000_000)
        threshold := total * 2 / 3 // 600,000,000
        js := newJustifier(0, 0, 0, threshold)
        js.AddBlock(datagen.RandAddress(), true, threshold+1)
        st := js.Summarize()
        if !st.Justified || !st.Committed {
            t.Fatalf("expected PASS at threshold+1; got Justified=%v Committed=%v", st.Justified, st.Committed)
        }
    })
}

Logs of the test run:

test_run.log

```text Running tool: C:\Program Files\Go\bin\go.exe test -timeout 30s -run ^TestFinalityBug_MinimalProof$ github.com/vechain/thor/v2/bft

=== RUN TestFinalityBug_MinimalProof === RUN TestFinalityBug_MinimalProof/PoA_exactly_two_thirds_fails --- PASS: TestFinalityBug_MinimalProof/PoA_exactly_two_thirds_fails (0.00s) === RUN TestFinalityBug_MinimalProof/PoA_two_thirds_plus_one_passes --- PASS: TestFinalityBug_MinimalProof/PoA_two_thirds_plus_one_passes (0.00s) === RUN TestFinalityBug_MinimalProof/DPoS_exactly_two_thirds_weight_fails --- PASS: TestFinalityBug_MinimalProof/DPoS_exactly_two_thirds_weight_fails (0.00s) === RUN TestFinalityBug_MinimalProof/DPoS_two_thirds_plus_one_weight_passes --- PASS: TestFinalityBug_MinimalProof/DPoS_two_thirds_plus_one_weight_passes (0.00s) --- PASS: TestFinalityBug_MinimalProof (0.00s) PASS ok github.com/vechain/thor/v2/bft 0.238s


## References

- VIP: https://github.com/vechain/VIPs/blob/master/vips/VIP-253.md#finality
- Affected repository comparison: https://github.com/vechain/thor/compare/master...release/hayabusa
- Code references in the analysis above are from:
  - https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/bft/justifier.go#L84C2-L98C3
  - https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/bft/justifier.go#L130C2-L137C3

(References used in the vulnerability details are included above.)

-- End of report.

Was this helpful?