Core Contracts

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

Unauthorized Reward Claim Vulnerability in BaseGauge Due to Incorrect Reward Calculation

Summary:

A critical vulnerability exists in the BaseGauge contract that allows users who have never staked any tokens to claim rewards. This issue arises because the reward calculation in the earned function is based on the user's weight, which can be non-zero even if the user has not staked any tokens. The reward per token is calculated based on the total staked amount in the contract, but the earned function does not verify whether the user has actually staked tokens. This allows any users (even with zero balance veToken) to claim rewards without staking, leading to an unfair distribution of rewards and potential exploitation of the protocol.


Vulnerability Details:

The earned function calculates rewards based on the user's weight, which is derived from the getUserWeight function.

The getUserWeight function uses the _getBaseWeight and _applyBoost functions to determine the user's weight.

The _getBaseWeight function retrieves the gauge weight for the contract,

And the _applyBoost function applies a boost based on the user's balance of veToken.

The boost is calculated in an out-of-scope file, but we can observe the following behavior in the calculateBoost function:

function calculateBoost(
uint256 veBalance,
uint256 totalVeSupply,
BoostParameters memory params
) internal pure returns (uint256) {
// Return base boost (1x = 10000 basis points) if no voting power
if (totalVeSupply == 0) {
return params.minBoost;
}
// Calculate voting power ratio with higher precision
uint256 votingPowerRatio = (veBalance * 1e18) / totalVeSupply;
// Calculate boost within min-max range
uint256 boostRange = params.maxBoost - params.minBoost;
uint256 boost = params.minBoost + ((votingPowerRatio * boostRange) / 1e18);
// Ensure boost is within bounds
if (boost < params.minBoost) {
return params.minBoost;
}
if (boost > params.maxBoost) {
return params.maxBoost;
}
return boost;
}
  • Even when the veBalance of the user is zero, the function returns params.minBoost.

  • This means that when a user does not hold any veToken, the _applyBoost function will return (baseWeight * boostState.minBoost) / 1e18.

  • baseWeight and minBoost do not depend on the user, this implies that even a user who does not hold or stake any veToken can still claim free rewards from the protocol.

The reward per token (rewardPerTokenStored) is calculated based on the total staked amount in the contract, as shown in the getRewardPerToken function:

function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}

However, the earned function does not check whether the user has staked any tokens. Instead, it relies solely on the user's weight, which can be non-zero even if the user has not staked any tokens.

Impact:

  • Unauthorized Reward Claims: Users who have never staked or hold any tokens can claim rewards, leading to an unfair distribution of rewards and loss of funds for the protocol.

  • Economic Exploitation: Malicious users can exploit this vulnerability to drain rewards from the contract without contributing to the staking pool.


Proof of Concept :

run in BaseGauge.test.js

  • before running the test add the user3 in the beginning

let user3; // line 14
[owner, user1, user2, user3] = await ethers.getSigners(); // line 20
describe("Free reward", () => {
it("user3 should get rewards without holding veToken or staking any reward token", async () => {
// Setup initial state with emission cap
await baseGauge.setEmission(ethers.parseEther("10000"));
// Setup rewards and voting
await gaugeController.connect(user1).vote(await baseGauge.getAddress(), 5000);
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
// Stake some tokens to gauge to be eligible for rewards
await rewardToken.mint(user1.address, ethers.parseEther("1000"));
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), ethers.parseEther("1000"));
await baseGauge.connect(user1).stake(ethers.parseEther("1000"));
// Wait for rewards to accrue
await time.increase(DAY);
const before = await rewardToken.balanceOf(user3.address);
// normally claim should fail
await baseGauge.connect(user3).getReward();
// user3 will get reward
const after = await rewardToken.balanceOf(user3.address);
});
});

Mitigation:

To fix this vulnerability, the contract should ensure that only users who have staked tokens are eligible to claim rewards.

Suggested Fix:

To ensure that only users who have staked tokens are eligible to claim rewards, the earned function should be modified to calculate rewards based on the user's staked balance (_balances[account]) rather than their weight derived from veToken holdings. This aligns with the fact that rewardPerToken is calculated based on the total rewards distributed across the total staked supply (totalSupply).

Here is the corrected earned function:

function earned(address account) public view returns (uint256) {
// Check if the user has staked tokens
if (balanceOf(account) == 0) {
return 0;
}
// Calculate rewards based on the user's staked balance
return (balanceOf(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}

Explanation:

  • balanceOf(account): This function retrieves the user's staked balance from the _balances mapping. It ensures that rewards are only calculated for users who have actually staked tokens in the contract.

  • getRewardPerToken(): This function calculates the reward per token based on the total rewards and the total staked supply (totalSupply). By multiplying the user's staked balance by the difference between the current rewardPerToken and the user's rewardPerTokenPaid, we ensure that rewards are proportional to the user's contribution to the staking pool.

Updates

Lead Judging Commences

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

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

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

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

Support

FAQs

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