Core Contracts

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

freeze rewards due to gauge manipulation

Summary

in baseguage, a user's rewards are updated through the updateReward() function every time the stake, withdraw, getreward, votedirection, or checkpoint functions are called. within the updateReward() function, the earned() function is called to calculate rewards, and the value of earned() is used later when withdrawing rewards. however, due to Gauge manipulation, an attacker can freeze the rewards so that they are no longer updated.

Vulnerability Details

function _updateReward(address account) internal {
rewardPerTokenStored = getRewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
UserState storage state = userStates[account];
state.rewards = earned(account);
state.rewardPerTokenPaid = rewardPerTokenStored;
state.lastUpdateTime = block.timestamp;
emit RewardUpdated(account, state.rewards);
}
}

the _updateReward() function calls the earned() function to update the user's rewards.

function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
// @audit-info if the getUserWeight(account) returns 0????
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}

the reward calculation subtracts the rewardpertokenpaid from getRewardPerToken() value then multiplies the difference by the getUserWeight() function, and adds the result to the existing rewards.

function getUserWeight(address account) public view virtual returns (uint256) {
uint256 baseWeight = _getBaseWeight(account);
return _applyBoost(account, baseWeight);
}
function _getBaseWeight(address account) internal view virtual returns (uint256) {
return IGaugeController(controller).getGaugeWeight(address(this)); // address(this)??
}
function _applyBoost(address account, uint256 baseWeight) internal view virtual returns (uint256) {
if (baseWeight == 0) return 0;
// (skip)

the getUserWeight() function returns the value returned by the _applyBoost() function. in other words, if the _applyBoost() function always returns 0, then the getUserWeight() function always returns 0

in the first line of the _applyboost() function, if baseweight is 0, it returns 0. (_getBaseWeightshould returns 0)

the _getBaseWeight() function retrieves the weight value of the current contract not the user using IGaugeController's getGaugeWeight() function

function getGaugeWeight(address gauge) external view override returns (uint256) {
return gauges[gauge].weight;
}

GaugeController's getGaugeWeight() function returns gauges[gauge].weight. then, if an attacker can set this value to 0, they could force all users' reward updates to halt.

function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (weight > WEIGHT_PRECISION) revert InvalidWeight();
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
if (votingPower == 0) revert NoVotingPower();
uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
userGaugeVotes[msg.sender][gauge] = weight;
_updateGaugeWeight(gauge, oldWeight, weight, votingPower);
emit WeightUpdated(gauge, oldWeight, weight);
}
function _updateGaugeWeight(
address gauge,
uint256 oldWeight,
uint256 newWeight,
uint256 votingPower
) internal {
Gauge storage g = gauges[gauge];
uint256 oldGaugeWeight = g.weight;
uint256 newGaugeWeight = oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION)
+ (newWeight * votingPower / WEIGHT_PRECISION);
g.weight = newGaugeWeight;
g.lastUpdateTime = block.timestamp;
}

in the _updateGaugeWeight() function, the gauges[gauge].weight value is updated. the important point is that while the maximum value of newweight is verified, the minimum value is not. therefore, by setting the denominator to 0, the additional value can be forced to 0 (it can be manipulated to 0-0).

we don't need to care newWeight part because the denominator is 0. we need to force the value of oldgaugeweight - (oldweight * votingpower / weight_precision) to 0. usergaugevotes[msg.sender][gauge] * veraactoken.balanceof(msg.sender) / 10000 must equal oldGaugeWeight. this ensures that when subtracted, the result is 0.

however, userGaugeVotes[msg.sender][gauge] value is initially 0. in other words, we need to first call the vote() function to assign an arbitrary value to userGaugeVotes[msg.sender][gauge].

userGaugeVotes[msg.sender][gauge] * X / WEIGHT_PRECISION = 10000
// (assuming oldgaugeweight is 10000)

and before calling the second vote() function, assign the value of X that satisfies the above expression to votingpower. and when the vote() function is called, the value of (oldweight * votingpower) / 10000 becomes equal to oldGaugeWeight, so subtracting the two results in 0. since the value of voingPower is the current user's balance of veraactoken, an attacker can manipulate it. if that's the case, then in baseguage, when the user's reward is updated, it will always be set to 0.

Step by Step

[Step - 0] Initial state (assuming)

  • WEIGHT_PRECISION = 10000

  • veRAACToken.balanceOf(attacker) = 1000

  • userGaugeVotes[attacker][BaseGauge] = 0

  • gauges[BaseGauge].weight = 10000

[Step - 1] state after calling the vote() to set the userGaugeVotes[user1][gauge]

call ⇒ vote(BaseGauge, 4000)

  • newWeight = 4000

  • votingPower = veRAACToken.balanceOf(attacker) = 1000

  • oldWeight = userGaugeVotes[attacker][BaseGauge] = 0

  • userGaugeVotes[attacker][BaseGauge] = 1000 (the updated value is used in the next vote() function call)

  • oldGaugeWeight = gauges[BaseGauge].weight = 10000

oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION)
+ (newWeight * votingPower / WEIGHT_PRECISION);
10000 - (0 * 1000/ 10000)
+ (4000 * 1000/ 10000)
  • gauges[BaseGauge].weight = 10400

[Step - 2] state after calling the vote() to set the guages[guages].wieght to 0

4000 * X / 10000 = 10400, Mint the attacker's veRAACToken balance by X amount so X should be 26000

call ⇒ vote(BaseGauge, 0)

  • newWeight = 0

  • votingPower = veRAACToken.balanceOf(attacker) = 26000

  • oldWeight = userGaugeVotes[attacker][gauge] = 4000

  • userGaugeVotes[attacker][BaseGauge] = 0 (the updated value is used in the next vote() function call)

  • oldGaugeWeight = gauges[BaseGauge].weight = 10400

oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION)
+ (newWeight * votingPower / WEIGHT_PRECISION);
10400 - (4000 * 26000/ 10000)
+ (0 * 26000 / 10000)
  • gauges[BaseGauge].weight = 0

[Step-3] when someone calls a specific function, the reward update logic is executed. but not updated actually

(getUserWeight(account) *
// getUserWeight(account) always returns 0 right now,
// so update reward is the same as the prev reward, can't update just
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;

since getUserWeight(account) now always returns 0, the reward is not updated.

Impact

an attacker can set the gauge's value to 0, freezing the reward at 0.=

Tools Used

code review

Recommendations

when setting a new gauge, the minimum value is checked. it must be greater than 0. also, must we really allow attackers to manipulate this gauge at will? any attackers can manipulate this value to change the rewards

Updates

Lead Judging Commences

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

BaseGauge._getBaseWeight ignores account parameter and returns gauge's total weight, allowing users to claim rewards from gauges they never voted for or staked in

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

BaseGauge._getBaseWeight ignores account parameter and returns gauge's total weight, allowing users to claim rewards from gauges they never voted for or staked in

Support

FAQs

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