56454 bc insight gas undercharging threatens hayabusa network upgrade
Submitted on Oct 16th 2025 at 08:25:34 UTC by @Angry_Mustache_Man for Attackathon | VeChain Hayabusa Upgrade
Report ID: #56454
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
Vulnerability Details
During the HAYABUSA upgrade, the critical transition of PoA→PoS invokes the native_isEndorsed function. The native_isEndorsed function (in-scope for the attackathon) undercharges gas. See implementation:
https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/builtin/authority_native.go#L105-L137
{"native_isEndorsed", func(env *xenv.Environment) []any {
var nodeMaster common.Address
env.ParseArgs(&nodeMaster)
env.UseGas(thor.SloadGas * 2)
listed, endorsor, _, _, err := Authority.Native(env.State()).Get(thor.Address(nodeMaster))
if err != nil {
panic(err)
}
if !listed {
return []any{false}
}
env.UseGas(thor.SloadGas)
endorsement, err := Params.Native(env.State()).Get(thor.KeyProposerEndorsement)
if err != nil {
panic(err)
}
// Use staker Transition Period logic
// to ensure that transitioning validators are marked as endorsed
env.UseGas(thor.GetBalanceGas)
isEndorsed, err := Staker.Native(env.State()).TransitionPeriodBalanceCheck(
env.ForkConfig(),
env.BlockContext().Number,
endorsement,
)(thor.Address(nodeMaster), endorsor)
if err != nil {
panic(err)
}
return []any{isEndorsed}
}},
}Current gas charges in native_isEndorsed:
env.UseGas(thor.SloadGas * 2) // Authority.Get() - 2 SLOAD operations = 400 gas
env.UseGas(thor.SloadGas) // Params.Get() - 1 SLOAD operation = 200 gas
env.UseGas(thor.GetBalanceGas) // TransitionPeriodBalanceCheck balance check = 400 gasTotal charged GAS: 1000 gas.
However, TransitionPeriodBalanceCheck (called next) performs additional operations that are not charged here:
https://github.com/vechain/thor/blob/b4c914fe573ed6141daa159fa293e9193a96d74f/builtin/staker/transition.go#L72-L98
func (s *Staker) TransitionPeriodBalanceCheck(fc *thor.ForkConfig, currentBlock uint32, endorsement *big.Int) authority.BalanceChecker {
return func(validator, endorser thor.Address) (bool, error) {
balance, err := s.state.GetBalance(endorser)
if err != nil {
return false, err
}
if balance.Cmp(endorsement) >= 0 {
return true, nil
}
if currentBlock < fc.HAYABUSA { // before HAYABUSA fork, we only check the account balance
return false, nil
}
validation, err := s.validationService.GetValidation(validator)
if err != nil {
return false, err
}
if validation == nil {
return false, nil
}
if validation.Endorser != endorser {
return false, nil // endorser mismatch
}
queuedVET := big.NewInt(0).SetUint64(validation.QueuedVET)
queuedVET.Mul(queuedVET, bigE18) // convert to wei
return queuedVET.Cmp(endorsement) >= 0, nil
}
}The code comment notes: "// before HAYABUSA fork, we only check the account balance". After HAYABUSA, GetValidation is invoked and performs extra storage reads (SLOADs) that are not accounted for in native_isEndorsed.
So the actual gas to be charged after HAYABUSA should be >= 1200 GAS: thor.SloadGas * 2 + thor.SloadGas + thor.GetBalanceGas + (Gas for the GetValidation call)
The Missing Gas Charge is that s.validationService.GetValidation(validator) performs:
Every block validation during the HAYABUSA transition calls native_isEndorsed. The missing charge per call creates economic incentives for exploitation: each call saves >=200 gas, causing economic depletion of the protocol and potentially leading to network shutdown.
Impact Details
Every validator check triggers the missing gas charge. Undercharging (~>=16.7%) per call can be exploited repeatedly, causing economic depletion and risking a full network shutdown (new transactions cannot be confirmed).
Proof of Concept
Create a new test file named thor/builtin/authority_native_test.go with the following tests to (a) statically verify that native_isEndorsed does not charge an SLOAD before calling TransitionPeriodBalanceCheck, and (b) measure gas consumed by GetValidation:
package builtin_test
import (
"math/big"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/vechain/thor/v2/builtin"
"github.com/vechain/thor/v2/builtin/gascharger"
"github.com/vechain/thor/v2/builtin/staker"
"github.com/vechain/thor/v2/thor"
"github.com/vechain/thor/v2/vm"
"github.com/vechain/thor/v2/xenv"
)
// This test performs a simple static check on authority_native.go to prove that
// native_isEndorsed calls TransitionPeriodBalanceCheck but does not charge an
// SLOAD (env.UseGas(thor.SloadGas)) immediately before that call.
func Test_POC(t *testing.T) {
const path = "authority_native.go"
b, err := os.ReadFile(path)
require.NoError(t, err, "failed to read authority_native.go")
src := string(b)
start := strings.Index(src, "native_isEndorsed")
require.NotEqual(t, -1, start, "native_isEndorsed not found in file")
getBalanceIdx := strings.Index(src[start:], "env.UseGas(thor.GetBalanceGas)")
require.NotEqual(t, -1, getBalanceIdx, "env.UseGas(thor.GetBalanceGas) not found in native_isEndorsed")
transIdx := strings.Index(src[start:], "TransitionPeriodBalanceCheck")
require.NotEqual(t, -1, transIdx, "TransitionPeriodBalanceCheck not found in native_isEndorsed")
segment := src[start+getBalanceIdx : start+transIdx]
// Check whether an SLOAD gas charge exists between GetBalanceGas and TransitionPeriodBalanceCheck
if strings.Contains(segment, "UseGas(thor.SloadGas") {
t.Fatalf("SLOAD gas charge found before TransitionPeriodBalanceCheck; expected no SLOAD charge (issue NOT present)")
}
t.Log("SLOAD gas charge is missing before TransitionPeriodBalanceCheck")
}
// TestGetValidationGasConsumptionSimple demonstrates that GetValidation consumes gas
// by creating a staker and measuring gas via the gascharger.
func Test_GasConsumedAtGetValidation(t *testing.T) {
// Create a simple test setup
setup := createTestSetup(t)
// Create test addresses
master1 := thor.BytesToAddress([]byte("master1"))
endorsor1 := thor.BytesToAddress([]byte("endorsor1"))
// Build a minimal xenv + contract so the gascharger can attach to env.UseGas()
contract := &vm.Contract{Gas: 1_000_000}
env := xenv.New(nil, nil, setup.state, nil, nil, nil, nil, contract, 0)
charger := gascharger.New(env)
// Create staker with a proper validation service wired to the charger
stakerAddr := builtin.Staker.Address
params := builtin.Params.Native(setup.state)
stakerImpl := staker.New(stakerAddr, setup.state, params, charger)
// Add validation to staker
stakeAmountVET := uint64(25_000_000) // 25M VET
stakeAmountWei := new(big.Int).SetUint64(stakeAmountVET)
stakeAmountWei.Mul(stakeAmountWei, big.NewInt(1e18))
// Fund endorsor
currentBalance, err := setup.state.GetBalance(endorsor1)
require.NoError(t, err)
newBalance := new(big.Int).Add(currentBalance, stakeAmountWei)
err = setup.state.SetBalance(endorsor1, newBalance)
require.NoError(t, err)
// Update staker contract state (effectiveVET + contract balance) to allow AddValidation
effectiveVETBytes, err := setup.state.GetStorage(stakerAddr, thor.Bytes32{})
require.NoError(t, err)
effectiveVET := new(big.Int).SetBytes(effectiveVETBytes.Bytes())
effectiveVET.Add(effectiveVET, stakeAmountWei)
setup.state.SetStorage(stakerAddr, thor.Bytes32{}, thor.BytesToBytes32(effectiveVET.Bytes()))
stakerBalance, err := setup.state.GetBalance(stakerAddr)
require.NoError(t, err)
newStakerBalance := new(big.Int).Add(stakerBalance, stakeAmountWei)
err = setup.state.SetBalance(stakerAddr, newStakerBalance)
require.NoError(t, err)
// Add validation
addValidationErr := stakerImpl.AddValidation(master1, endorsor1, thor.LowStakingPeriod(), stakeAmountVET)
require.NoError(t, addValidationErr, "Failed to add validation to staker")
// Measure gas around GetValidation
before := charger.TotalGas()
validation, getErr := stakerImpl.GetValidation(master1)
after := charger.TotalGas()
require.NoError(t, getErr, "GetValidation failed")
require.NotNil(t, validation, "GetValidation returned nil")
consumed := after - before
t.Logf("GetValidation gas measurement:")
t.Logf(" - total gas before: %d", before)
t.Logf(" - total gas after : %d", after)
t.Logf(" - gas consumed : %d", consumed)
// Expect 2 SLOAD operations (400 gas) - Validation struct spans 2 storage words
require.Equal(t, uint64(2*thor.SloadGas), consumed, "GetValidation should consume 2 SLOAD operations (400 gas) for Validation struct")
}Run the tests:
go test -v ./builtin -run Test_POC
go test -v ./builtin -run Test_GasConsumedAtGetValidation
Was this helpful?