# 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**](https://immunefi.com/audit-competition/vechain-hayabusa-upgrade-attackathon)

* **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.

1. Adding to the List:

When a user adds a delegation via `Staker.AddDelegation` ([staker.go#L499](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/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.

```go
if val.Status == validation.StatusActive {
    if err = s.validationService.AddToRenewalList(validator); err != nil {
        return nil, err
    }
}
```

2. 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](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/staker.go#L635)).

```go
// 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](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/delegation/delegation.go#L29) which confirms that this will be False as long as current Iteration is lesser than the delegation start iteration.

```go
// 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.

```go
// 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.

3. 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.

4. The Impact on Consensus:

During block processing, the consensus engine calls `SyncPOS` ([protocol.go#L24](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/protocol.go#L24)), which in turn calls `Staker.Housekeep` ([housekeep.go#L37](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/housekeep.go#L37)) at epoch boundaries.

`Housekeep` computes ([housekeep.go#L43](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/housekeep.go#43)) the transition group via `validationService.UpdateGroup` ([service.go#L227](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/validation/service.go#L227)). This function iterates the `renewalList` and filters based on `IsPeriodEnd`.

```go
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](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/housekeep.go#L159)):

```go
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](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/aggregation/service.go#L76)) calls `s.aggregationStorage.Update()`.
* `validationService.Renew` ([service.go#L431](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/validation/service.go#L431)) calls `s.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 `IsPeriodEnd` filter 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](https://github.com/vechain/thor/blob/release/hayabusa/builtin/staker/housekeep.go#L154)) iterates over all 101 validators, calling `Renew` for each one—even those with no pending changes. This maximizes the duration of the `applyEpochTransition` execution.

{% stepper %}
{% step %}

### Simple Scenario: DoS via renewalList Bloat — Preparation

The attacker identifies the 101 active validators. The network state is such that the validators' periods are synchronized (e.g., shortly after DPoS activation).
{% endstep %}

{% step %}

### 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_N` to the `renewalList`.
* WithdrawDelegation(DelegationID): Removes the pending stake but leaves `Validator_N` on the `renewalList`.
  {% endstep %}

{% step %}

### Simple Scenario: DoS via renewalList Bloat — The Trigger

The network reaches the synchronized epoch boundary.
{% endstep %}

{% step %}

### Simple Scenario: DoS via renewalList Bloat — Excessive Computation

During `applyEpochTransition`, the system executes 101 "no-op" renewals. This forces 202+ unconditional database writes and subsequent Merkle Trie recalculations.
{% endstep %}

{% step %}

### Simple Scenario: DoS via renewalList Bloat — The Impact

The unmetered computation significantly delays block processing time. This delay can exceed BFT timing assumptions, causing validators to miss voting deadlines and resulting in stalled network finality or a chain halt.
{% endstep %}
{% endstepper %}

#### 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

{% hint style="info" %}
Three recommended mitigations (retain original links/references when implementing):

1. Implement correct removal logic in `Staker.WithdrawDelegation` to remove validators from the `renewalList` when net pending changes drop to zero and the validator is not naturally ending their period.
2. Optimize `Renew` functions to avoid unconditional database writes (dirty checking) so `Update()` or `updateValidation()` is only called when actual state changes occur.
3. Consider adding a minimum delegation amount to increase the attack cost.
   {% endhint %}

Recommended code patch (conceptual) to call RemoveFromRenewalList when no pending changes remain:

```go
// 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.Renew` before `s.aggregationStorage.Update()`.
* `validationService.Renew` before `s.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>

```go
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>

```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.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/vechain-hayabusa-upgrade-or-attackathon/56626-bc-insight-trivial-renewallist-bloat-attack-exploits-unmetered-database-writes-to-increase-blo.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
