Summary
The BaseGauge contract allows users to stake tokens and immediately vote with them before withdrawing, enabling flash loan attacks to manipulate governance votes without requiring any long-term token commitment.
Vulnerability Details
The vulnerability exists in the interaction between the stake
, withdraw
, and voteDirection
functions in BaseGauge. While each function works correctly in isolation, their combination allows for vote manipulation:
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
if (amount == 0) revert InvalidAmount();
_totalSupply += amount;
_balances[msg.sender] += amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
function voteDirection(uint256 direction) public whenNotPaused updateReward(msg.sender) {
if (direction > 10000) revert InvalidWeight();
uint256 votingPower = IERC20(IGaugeController(controller).veRAACToken()).balanceOf(msg.sender);
if (votingPower == 0) revert NoVotingPower();
totalVotes = processVote(userVotes[msg.sender], direction, votingPower, totalVotes);
emit DirectionVoted(msg.sender, direction, votingPower);
}
The issue can be exploited as follows:
Attacker takes a flash loan for a large amount of tokens
Stakes the tokens in BaseGauge
Calls voteDirection() to vote with their newly acquired voting power
Immediately withdraws their tokens
Repays the flash loan
This has been demonstrated through testing:
it("allows staking, voting, and withdrawal in the same block", async () => {
const { baseGauge, rewardToken, veRAACToken, user1 } = await loadFixture(deployFixture);
const stakeAmount = ethers.parseEther("100");
await rewardToken.mint(user1.address, stakeAmount);
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), stakeAmount);
const stakeTx = await baseGauge.connect(user1).stake(stakeAmount);
const voteTx = await baseGauge.connect(user1).voteDirection(5000);
const withdrawTx = await baseGauge.connect(user1).withdraw(stakeAmount);
});
All of these actions can occur within consecutive blocks, costing only gas fees.
Impact
High. This vulnerability allows:
Vote manipulation through flash loans
No real token commitment required for voting
Potential for governance attacks
Undermining of the voting power system
Tools Used
Recommendations
Implement the following protections:
Add minimum stake duration before voting rights are granted:
mapping(address => uint256) public stakeTimestamp;
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
stakeTimestamp[msg.sender] = block.timestamp;
}
function voteDirection(uint256 direction) public whenNotPaused updateReward(msg.sender) {
if (block.timestamp < stakeTimestamp[msg.sender] + MIN_STAKE_DURATION)
revert StakeDurationNotMet();
}
Lock tokens for a period after voting:
mapping(address => uint256) public tokensLockedUntil;
function voteDirection(uint256 direction) public whenNotPaused updateReward(msg.sender) {
tokensLockedUntil[msg.sender] = block.timestamp + VOTE_LOCK_DURATION;
}
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
if (block.timestamp < tokensLockedUntil[msg.sender])
revert TokensLocked();
}
Consider implementing vote power snapshots at fixed intervals rather than using current balance.