56657 bc insight inactive validator scheduling bypass in vechain thor pos consensus mechanism
Submitted on Oct 19th 2025 at 01:53:25 UTC by @Angry_Mustache_Man for Attackathon | VeChain Hayabusa Upgrade
Report ID: #56657
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/vechain/thor/compare/master...release/hayabusa
Impacts:
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
Brief/Intro
A vulnerability exists in the VeChain Thor Proof-of-Stake (PoS) consensus mechanism that allows inactive/offline validators to be included in the block production scheduling sequence, bypassing the staking system's online participation requirements. This vulnerability affects the pos.NewScheduler() function in pos/sched.go and can lead to consensus calculation errors and scheduling inconsistencies.
Vulnerability Details
The condition p.Active || p.Address == addr creates a privilege escalation where:
https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/pos/sched.go#L75-L82
// but only active/online validators will be picked for block production
if p.Active || p.Address == addr {
shuffled = append(shuffled, entry{
address: p.Address,
weight: p.Weight,
active: p.Active,
score: -math.Log(random) / float64(p.Weight),
})Normal validators: Must be Active = true (online) to be included in scheduling.
Node Master: Gets included regardless of their online status.
There are no checks done before this function too.
Even the codebase comments indicate an error should be returned if the node master was not Active, but that check is not implemented:
https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/pos/sched.go#L40-L47
// If `addr` is not listed in `proposers` or not active, an error returned.
func NewScheduler(
addr thor.Address,
proposers []Proposer,
parentBlockNumber uint32,
parentBlockTime uint64,
seed []byte,
) (*Scheduler, error) {Bug Flow
Impact Details
Including an offline Node creates delays and inconsistencies. It violates the "online participation" consensus requirement for block production and creates consensus calculation errors by including offline validator's weight in total weight calculations.
Proof of Concept
Include this test in the file pos/sched_test.go:
func TestScheduler_InactiveProposerVulnerability_POC(t *testing.T) {
t.Log("=== POC: Inactive Proposer Vulnerability Test ===")
t.Log("This test demonstrates that an inactive proposer can still be scheduled")
t.Log("for block production, while other inactive proposers are excluded.")
// Create test proposers with one inactive target proposer
proposers := []Proposer{
{Address: thor.BytesToAddress([]byte("active1")), Weight: 100_000_000, Active: true},
{Address: thor.BytesToAddress([]byte("active2")), Weight: 200_000_000, Active: true},
{Address: thor.BytesToAddress([]byte("inactive_target")), Weight: 300_000_000, Active: false}, // Target: inactive but should be scheduled
{Address: thor.BytesToAddress([]byte("inactive_other")), Weight: 400_000_000, Active: false}, // Other: inactive and should be excluded
{Address: thor.BytesToAddress([]byte("active3")), Weight: 500_000_000, Active: true},
}
targetProposer := thor.BytesToAddress([]byte("inactive_target"))
otherInactiveProposer := thor.BytesToAddress([]byte("inactive_other"))
// Step 1: Create scheduler with inactive target proposer
t.Log("\n Step 1: Creating scheduler with inactive target proposer...")
sched, err := NewScheduler(targetProposer, proposers, 1, 10, []byte("vulnerability_test"))
assert.NoError(t, err, "Scheduler should be created successfully even with inactive target")
// Step 2: Verify the target proposer is included in the sequence despite being inactive
t.Log("\n Step 2: Verifying target proposer inclusion in sequence...")
targetInSequence := false
otherInactiveInSequence := false
activeCount := 0
for _, entry := range sched.sequence {
if entry.address == targetProposer {
targetInSequence = true
t.Logf(" VULNERABILITY CONFIRMED: Inactive target proposer %s is in sequence with weight %d",
entry.address.String(), entry.weight)
}
if entry.address == otherInactiveProposer {
otherInactiveInSequence = true
t.Logf(" UNEXPECTED: Other inactive proposer %s is in sequence (should be excluded)",
entry.address.String())
}
if entry.active {
activeCount++
}
}
// Assertions for the vulnerability
assert.True(t, targetInSequence, "VULNERABILITY: Inactive target proposer should be in sequence")
assert.False(t, otherInactiveInSequence, "Other inactive proposer should be excluded from sequence")
t.Logf(" Sequence contains %d active proposers out of %d total entries", activeCount, len(sched.sequence))
// Step 3: Test that the inactive proposer can be scheduled for block production
t.Log("\n Step 3: Testing block production scheduling for inactive proposer...")
parentTime := uint64(10)
nowTime := uint64(20)
// Schedule block production
scheduledTime := sched.Schedule(nowTime)
t.Logf(" Scheduled block time: %d", scheduledTime)
// Verify the scheduled time is valid for the inactive proposer
isValidTime := sched.IsTheTime(scheduledTime)
assert.True(t, isValidTime, "VULNERABILITY: Inactive proposer should be able to produce blocks at scheduled time")
// Step 4: Test that the inactive proposer appears in the scheduling sequence
t.Log("\n Step 4: Verifying inactive proposer appears in scheduling sequence...")
// Check multiple time slots to see if the inactive proposer gets scheduled
foundInScheduling := false
for i := uint64(0); i < 10; i++ {
testTime := parentTime + thor.BlockInterval()*(i+1)
if sched.IsScheduled(testTime, targetProposer) {
foundInScheduling = true
t.Logf(" VULNERABILITY CONFIRMED: Inactive proposer scheduled at time %d", testTime)
break
}
}
assert.True(t, foundInScheduling, "VULNERABILITY: Inactive proposer should appear in scheduling sequence")
// Step 5: Test weight distribution impact
t.Log("\n Step 5: Testing weight distribution impact...")
// Calculate total weight in sequence (should include inactive target)
totalWeightInSequence := uint64(0)
for _, entry := range sched.sequence {
totalWeightInSequence += entry.weight
}
// Calculate expected weight (all active + inactive target)
expectedWeight := uint64(0)
for _, p := range proposers {
if p.Active || p.Address == targetProposer {
expectedWeight += p.Weight
}
}
t.Logf(" Total weight in sequence: %d", totalWeightInSequence)
t.Logf(" Expected weight (active + inactive target): %d", expectedWeight)
assert.Equal(t, expectedWeight, totalWeightInSequence,
"Weight calculation should include inactive target proposer")
// Step 6: Test Updates function behavior
t.Log("\n Step 6: Testing Updates function with inactive proposer...")
updates, score := sched.Updates(nowTime, totalWeightInSequence)
t.Logf(" Updates count: %d, Score: %d", len(updates), score)
// The inactive proposer should be marked as active in updates
targetMarkedActive := false
for _, update := range updates {
if update.Address == targetProposer && update.Active {
targetMarkedActive = true
t.Logf(" VULNERABILITY: Inactive proposer marked as active in updates")
break
}
}
assert.True(t, targetMarkedActive, "VULNERABILITY: Inactive proposer should be marked as active in updates")
// Final assertion to confirm the vulnerability
assert.True(t, targetInSequence && foundInScheduling && targetMarkedActive,
"VULNERABILITY CONFIRMED: Inactive proposer can participate in consensus")
}Run the test:
go test -v -run TestScheduler_InactiveProposerVulnerability_POC ./pos -cover
Was this helpful?