56626 bc insight trivial renewallist bloat attack exploits unmetered database writes to increase block processing time risking bft disruption
Submitted on Oct 18th 2025 at 15:47:32 UTC by @OadeHack for Attackathon | VeChain Hayabusa Upgrade
Report ID: #56626
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/vechain/thor/compare/master...release/hayabusa
Impacts:
Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments
Description
Summary
A critical flaw in the delegation withdrawal logic allows an attacker to artificially bloat the renewalList, which tracks validators requiring processing during epoch transitions. This flaw enables an attacker to force excessive, unmetered computation during the consensus-critical SyncPOS phase, leading to a significant and controllable increase in block processing time. When a pending delegation is added, the validator is added to the list; however, if that delegation is immediately withdrawn, the validator is not removed, even if their net pending changes return to zero.
This allows an attacker to force the system to perform excessive, unmetered database operations during the consensus-critical epoch transition phase. Since there is no minimum delegation amount, the attack is trivial and cheap to execute (estimated at approximately 12.1 million Gas to bloat the entire list). The resulting computational overhead significantly increases the time required to process the epoch transition block, which can disrupt BFT timing assumptions, potentially stalling network finality or halting the chain.
Details
The vulnerability stems from an oversight in the Staker.WithdrawDelegation function, leading to an asymmetry in how the renewalList is managed.
Adding to the List:
When a user adds a delegation via Staker.AddDelegation (staker.go#L499), if the target validator is active, they are added to the renewalList to ensure the pending stake is processed at the next epoch.
if val.Status == validation.StatusActive {
if err = s.validationService.AddToRenewalList(validator); err != nil {
return nil, err
}
}The Flaw during Withdrawal:
New delegations are scheduled to start in the next iteration (current+1). The implementation correctly allows users to withdraw these delegations immediately, before they become active. This is verified by the Started() check in WithdrawDelegation (staker.go#L635).
// ensure the delegation is either queued or finished
started, err := del.Started(val, currentBlock) // False if pending
// ...
if started && !finished {
return 0, NewReverts("delegation is not eligible for withdraw")
}Started is calculated with logic in delegation.go#L29 which confirms that this will be False as long as current Iteration is lesser than the delegation start iteration.
// Started returns whether the delegation became locked
func (d *Delegation) Started(val *validation.Validation, currentBlock uint32) (bool, error) {
if val.Status == validation.StatusQueued || val.Status == validation.StatusUnknown {
return false, nil // Delegation cannot start if the validation is not active
}
currentStakingPeriod, err := val.CurrentIteration(currentBlock)
if err != nil {
return false, err
}
return currentStakingPeriod >= d.FirstIteration, nil
}If started is false, the withdrawal proceeds. The system correctly subtracts the stake from the aggregation service.
// delegation is still queued
if !started && val.Status != validation.StatusExit {
// ...
if err = s.aggregationService.SubPendingVet(del.Validation, weightedStake); err != nil {
// ...
}
// ...
}The Flaw: The WithdrawDelegation function never calls s.validationService.RemoveFromRenewalList(). Even if the withdrawal causes the validator's pending changes to drop to zero, the validator remains on the list.
The Exploit and Triviality of Attack Cost:
An attacker can exploit this by rapidly cycling AddDelegation and WithdrawDelegation. The goal is to ensure all 101 active validators are added to the renewalList.
Cost Analysis: Since there is no minimum delegation amount, the attack cost is determined solely by the transaction fees (Gas). An analysis of the required storage operations (SLOAD=200, SSTORE_SET=20000, SstoreReset=5000), including the overhead of managing the linked list pointers, estimates the net cost for one full cycle (Add + Withdraw) to be approximately 120,400 Gas.
The total cost to bloat the entire set of 101 active validators is approximately 12.1 million Gas. This cost is negligible (payable in VTHO), confirming the attack is extremely cheap and rapid to execute.
The Impact on Consensus:
During block processing, the consensus engine calls SyncPOS (protocol.go#L24), which in turn calls Staker.Housekeep (housekeep.go#L37) at epoch boundaries.
Housekeep computes (housekeep.go#L43) the transition group via validationService.UpdateGroup (service.go#L227). This function iterates the renewalList and filters based on IsPeriodEnd.
err := s.repo.iterateRenewalList(func(validator thor.Address, val *Validation) error {
// here we only handle renewals, not evictions or exits
if val.IsPeriodEnd(currentBlock) && val.ExitBlock == nil {
group = append(group, validator)
}
return nil
})Then based on the returned renewals list, the following actions are performed for every validator in the list when applying the transition (housekeep.go#L159):
for _, validator := range transition.Renewals {
// handle the renewals for aggregations
aggRenewal, delegationWeight, err := s.aggregationService.Renew(validator)
// ...
// Update validator state
valRenewal, err := s.validationService.Renew(validator, delegationWeight)
// ...
}Critically, these Renew operations are unmetered (they do not consume Gas) and perform expensive, unconditional database writes, even if the validator has no pending changes (a "no-op" renewal).
aggregationService.Renew(service.go#L76) callss.aggregationStorage.Update().validationService.Renew(service.go#L431) callss.repo.updateValidation().
These unconditional writes are extremely costly because they force the recalculation of the Merkle Patricia Trie (MPT) hashes up to the StateRoot, amplifying the CPU and I/O load during consensus.
The block processing operation (Node.processBlock) is executed synchronously within the node's housekeeping loop. Because the consensus operations are unmetered, there are no explicit timeouts enforced by the execution environment. The time taken to process the epoch transition block is directly proportional to the number of renewals executed. By forcing 101 "no-op" renewals, the attacker maximizes the CPU and I/O load, directly translating to a substantial increase in the total time required to validate and commit the block.
Impact Scenarios:
Performance Degradation (Non-Worst Case): While the
IsPeriodEndfilter might reduce the number of renewals processed in a typical epoch and validators' periods are staggered, the attacker can ensure that every validator eligible for renewal in a given epoch is processed, maximizing the computational overhead for that specific block. This increases block processing latency ("jitter") and reduces the network's fault tolerance during those heavier epochs.Chain Halt (Worst Case): Synchronization is highly probable, especially during the initial DPoS launch where validators migrate together, sharing the same StartBlock. In this scenario,
applyEpochTransition(housekeep.go#L154) iterates over all 101 validators, callingRenewfor each one—even those with no pending changes. This maximizes the duration of theapplyEpochTransitionexecution.
Simple Scenario: DoS via renewalList Bloat — Bloating the List
For each of the 101 validators, the attacker executes the following cycle:
AddDelegation(Validator_N, 1 VET): Adds
Validator_Nto therenewalList.WithdrawDelegation(DelegationID): Removes the pending stake but leaves
Validator_Non therenewalList.
Impact
Increased Block Processing Time (Denial-of-Service): The primary impact is a severe Denial-of-Service vector. Forcing maximum unmetered computation (CPU and I/O amplification via unnecessary Merkle updates) during the consensus phase significantly increases the Block Processing Time for the epoch transition block, consuming the time budget allocated for consensus operations.
BFT Disruption and Chain Halt: DLT systems using BFT consensus rely on strict timing assumptions. If the increased Block Processing Time causes a computational delay such that more than 1/3 of the voting power miss their BFT deadlines, the network cannot achieve consensus, leading to stalled finality or a complete chain halt.
Triviality and Low Cost (Griefing Vector): The absence of a minimum delegation amount coupled with the low gas cost (~12.1M Gas total) makes this attack extremely cheap and trivial to execute. This is a potent griefing vector where a low-cost attack can cause disproportionate harm to the protocol's liveness.
Recommendation
Recommended code patch (conceptual) to call RemoveFromRenewalList when no pending changes remain:
// In Staker.WithdrawDelegation (Recommended Fix)
// delegation is still queued
if !started && val.Status != validation.StatusExit {
// ... (Existing SubPendingVet and RemoveQueued logic) ...
// Fx: Check if the validator still has pending changes
agg, err := s.aggregationService.GetAggregation(del.Validation)
if err != nil { return 0, err }
// Check Validator's own pending stake changes (QueuedVET, PendingUnlockVET) AND check Aggregated delegation changes (Pending, Exiting)
if val.QueuedVET == 0 && val.PendingUnlockVET == 0 &&
agg.Pending.VET == 0 && agg.Exiting.VET == 0 {
// If no changes remain, remove from the renewal list.
// Note: Must also ensure the validator is not naturally ending their period.
if !val.IsPeriodEnd(currentBlock) {
if err = s.validationService.RemoveFromRenewalList(del.Validation); err != nil {
return 0, err
}
}
}
}Also consider adding dirty checks in:
aggregationService.Renewbefores.aggregationStorage.Update().validationService.Renewbefores.repo.updateValidation().
Proof of Concept
This POC validates that after a delegation is withdrawn, even if the validator pending aggregation is zero, the validator remains in the renewals list.
Simple Helper function
To make POC easier and less complex, add this external function to the validation service (so tests can confirm presence in the renewals list): File: https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/validation/service.go#L225
func (s *Service) IsInRenewalList(addr thor.Address) (bool, error) {
return s.repo.renewalList.contains(addr)
}Test Code POC
Add the code below to the existing test file: https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/staker_test.go
// Updated Helper function using the new external wrapper to check renewal list presence.
func validatorIsInRenewalList(t *testing.T, s *testStaker, validator thor.Address) bool {
if s.Staker == nil {
t.Fatal("Test setup error: s.Staker implementation is nil.")
}
// Access the unexported validationService (allowed because we are in package staker).
// Access path based on newStaker implementation: testStaker.Staker.validationService
valService := s.Staker.validationService
if valService == nil {
t.Fatal("Test setup error: Internal validationService not initialized correctly.")
}
// Call the EXPORTED hook defined in validation/export_test.go.
has, err := valService.IsInRenewalList(validator)
require.NoError(t, err, "Error checking renewal list presence via hook")
return has
}
// Test_POC_RenewalListBloat demonstrates the vulnerability where the renewalList
// retains validators even after their pending changes are withdrawn.
func Test_POC_RenewalListBloat(t *testing.T) {
// 1. Setup: Initialize environment with 1 active validator.
// newStaker(t, activeValidators, maxValidators, initialise)
// Assuming newStaker initializes and activates the validators.
staker, _ := newStaker(t, 3, 3, true)
currentBlock := uint32(10)
delegationAmount := uint64(1000)
// Identify the active validator.
activeValidatorAddr, err := staker.FirstActive()
require.NoError(t, err)
require.False(t, activeValidatorAddr.IsZero(), "Test Setup Failed: Should have an active validator")
// Ensure the validator is Active.
val, err := staker.GetValidation(activeValidatorAddr)
require.NoError(t, err)
// Use the constant from the imported validation package
require.Equal(t, validation.StatusActive, val.Status, "Validator must be active for this test")
// 2. Verify Initial State.
t.Log("Step 1: Initial State Verification")
initiallyPresent := validatorIsInRenewalList(t, staker, activeValidatorAddr)
assert.False(t, initiallyPresent, "Validator should not be in renewalList initially")
// 3. Action 1: Add a pending delegation.
t.Logf("Step 2: Add Delegation (Adds to renewalList)")
delegationID, err := staker.AddDelegation(activeValidatorAddr, delegationAmount, 100, currentBlock)
require.NoError(t, err)
// Verify the validator is NOW in the renewalList.
presentAfterAdd := validatorIsInRenewalList(t, staker, activeValidatorAddr)
assert.True(t, presentAfterAdd, "Validator should be in renewalList after AddDelegation")
// Verify the pending aggregation is updated.
aggService := staker.Staker.aggregationService
agg, err := aggService.GetAggregation(activeValidatorAddr)
require.NoError(t, err)
// Assuming WeightedStake fields (VET) are exported.
assert.Equal(t, delegationAmount, agg.Pending.VET, "Pending VET should match delegation amount")
// 4. Action 2: Withdraw the pending delegation.
t.Logf("Step 3: Withdraw Pending Delegation")
// VULNERABILITY: It does NOT call validationService.RemoveFromRenewalList.
// The delegation is pending (!started) so withdrawal is allowed.
withdrawnAmount, err := staker.WithdrawDelegation(delegationID, currentBlock)
require.NoError(t, err)
assert.Equal(t, delegationAmount, withdrawnAmount)
// Verify the pending aggregation is now zero.
agg, err = aggService.GetAggregation(activeValidatorAddr)
require.NoError(t, err)
assert.Equal(t, uint64(0), agg.Pending.VET, "Pending VET should be zero after withdrawal")
// 5. Verification of the Flaw: Check the renewalList again.
t.Log("Step 4: Verify Vulnerability")
presentAfterWithdraw := validatorIsInRenewalList(t, staker, activeValidatorAddr)
// THE FLAW: The validator remains in the list despite having 0 pending changes.
if presentAfterWithdraw {
t.Log("VULNERABILITY CONFIRMED: Validator remains in renewalList after pending withdrawal.")
}
// We assert True to confirm the presence of the bug.
assert.True(t, presentAfterWithdraw, "Validator remains in renewalList after pending withdrawal (DoS Vector)")
}Notes
All links to source files (staker.go, delegation.go, protocol.go, housekeep.go, etc.) are preserved as in the original report.
No additional content or changes beyond the original report are introduced.
Was this helpful?