Platform: Code4rena
Start Date: 23/05/2022
Pot Size: $75,000 USDC
Total HM: 23
Participants: 75
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 13
Id: 130
League: ETH
Rank: 7/75
Findings: 6
Award: $4,249.00
🌟 Selected for report: 3
🚀 Solo Findings: 1
🌟 Selected for report: hyh
Also found by: smiling_heretic, unforgiven, xiaoming90
All the Voting
values calculated by _writeCheckpoint()
(when it's not first checkpoint) is going to set to False
instead of account's last vote and because vote
has been used in earned()
and reward calculation so reward distribution is going to be wrong too.
This is _writeCheckpoint()
code in Gauge
:
function _writeCheckpoint(address account, uint balance) internal { uint _timestamp = block.timestamp; uint _nCheckPoints = numCheckpoints[account]; if (_nCheckPoints > 0 && checkpoints[account][_nCheckPoints - 1].timestamp == _timestamp) { checkpoints[account][_nCheckPoints - 1].balanceOf = balance; } else { bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false; checkpoints[account][_nCheckPoints] = Checkpoint(_timestamp, balance, prevVoteStatus); numCheckpoints[account] = _nCheckPoints + 1; } }
As you can see in the line bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false;
it uses index _nCheckPoints
to get the previous vote status but it should have used _nCheckPoints - 1
as index to get previous vote status becasue the last vote is in index _nCheckPoints - 1
VIM
change the line to this:
bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints-1].voted : false;
#0 - pooltypes
2022-06-13T16:09:38Z
Duplicate of #59
#1 - GalloDaSballo
2022-06-30T23:58:05Z
Dup of #59
🌟 Selected for report: MiloTruck
Also found by: 0x52, Dravee, IllIllI, MaratCerby, WatchPug, unforgiven
147.433 USDC - $147.43
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Bribe.sol#L92-L105 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L664-L676 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Bribe.sol#L41-L60 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L511-L520 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L603-L612
The actual amount that has been transferred can be different than requested amount in deflationary tokens and this is not been addressed in transferring logic in the code. This can cause wrong calculation and rewards distribution for users.
This is deposit()
code in Gauge
:
function deposit(uint amount, uint tokenId) public lock { require(amount > 0); _updateRewardForAllTokens(); _safeTransferFrom(stake, msg.sender, address(this), amount); totalSupply += amount; balanceOf[msg.sender] += amount; if (tokenId > 0) { require(IVotingEscrow(_ve).ownerOf(tokenId) == msg.sender); if (tokenIds[msg.sender] == 0) { tokenIds[msg.sender] = tokenId; IVoter(voter).attachTokenToGauge(tokenId, msg.sender); } require(tokenIds[msg.sender] == tokenId); } else { tokenId = tokenIds[msg.sender]; } uint _derivedBalance = derivedBalances[msg.sender]; derivedSupply -= _derivedBalance; _derivedBalance = derivedBalance(msg.sender); derivedBalances[msg.sender] = _derivedBalance; derivedSupply += _derivedBalance; _writeCheckpoint(msg.sender, _derivedBalance); _writeSupplyCheckpoint(); IVoter(voter).emitDeposit(tokenId, msg.sender, amount); emit Deposit(msg.sender, tokenId, amount); }
As you can see it calls _safeTransferFrom(stake, msg.sender, address(this), amount);
to get user tokens and then it adds amount
to user balance and total supply: totalSupply += amount; balanceOf[msg.sender] += amount;
but the actual transferred amount could be different in deflationary tokens and this can cause wrong reward distributions.
The other instances of the bug are similar.
VIM
check for actual transferred amount when transferring.
#0 - GalloDaSballo
2022-06-28T23:39:21Z
Dup of #222
🌟 Selected for report: unforgiven
524.326 USDC - $524.33
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Bribe.sol#L41-L60 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L590-L624
It's possible to call notifyRewardAmount()
in Bribe
or Gauge
contract with malicious tokens and contract will add them to reward tokens list and then attacker can interrupt Gauge.deliverBribes()
logic (by failing all contract transaction in that malicious token). because Gauge.deliverBribes()
is looping through all tokens and transferring rewards and if one of them fails whole transaction will fail.
As Gauge.deliverBribes()
is called by Voter.distribute()
and Voter.distribute()
is called by Gauge.getReward()
so functions: Voter.distribute()
and Gauge.getReward()
will be broke too and no one can call them for that Gauge
.
This is notifyRewardAmount()
code in Bribe
:
function notifyRewardAmount(address token, uint amount) external lock { require(amount > 0); if (!isReward[token]) { require(rewards.length < MAX_REWARD_TOKENS, "too many rewards tokens"); } // bribes kick in at the start of next bribe period uint adjustedTstamp = getEpochStart(block.timestamp); uint epochRewards = tokenRewardsPerEpoch[token][adjustedTstamp]; _safeTransferFrom(token, msg.sender, address(this), amount); tokenRewardsPerEpoch[token][adjustedTstamp] = epochRewards + amount; if (!isReward[token]) { isReward[token] = true; rewards.push(token); IGauge(gauge).addBribeRewardToken(token); } emit NotifyReward(msg.sender, token, adjustedTstamp, amount); }
As you can see it's callable by anyone and it will add the new tokens to rewards
list in Bribe
and Gauge
contract. notifyRewardAmount()
in Gauge
is similar to Bribe
's notifyRewardAmount()
.
This is deliverBribes()
code in Gauge
:
function deliverBribes() external lock { require(msg.sender == voter); IBribe sb = IBribe(bribe); uint bribeStart = block.timestamp - (block.timestamp % (7 days)) + BRIBE_LAG; uint numRewards = sb.rewardsListLength(); for (uint i = 0; i < numRewards; i++) { address token = sb.rewards(i); uint epochRewards = sb.deliverReward(token, bribeStart); if (epochRewards > 0) { _notifyBribeAmount(token, epochRewards, bribeStart); } } }
As you can see it loops through all rewards
token list and calls Bribe.deliverReward()
which then transfers the reward token. but if one of the tokens were malicious and revert the transaction then the whole transaction will fail and deliverBribes()
logic will be blocked.
This is distribute()
code in Voter
:
function distribute(address _gauge) public lock { require(isAlive[_gauge]); // killed gauges cannot distribute uint dayCalc = block.timestamp % (7 days); require((dayCalc < BRIBE_LAG) || (dayCalc > (DURATION + BRIBE_LAG)), "cannot claim during votes period"); IMinter(minter).update_period(); _updateFor(_gauge); uint _claimable = claimable[_gauge]; if (_claimable > IGauge(_gauge).left(base) && _claimable / DURATION > 0) { claimable[_gauge] = 0; IGauge(_gauge).notifyRewardAmount(base, _claimable); emit DistributeReward(msg.sender, _gauge, _claimable); // distribute bribes & fees too IGauge(_gauge).deliverBribes(); } }
As you can see it calls Gauge.deliverBribes()
. and this is getReward()
code in Gauge
:
function getReward(address account, address[] memory tokens) external lock { require(msg.sender == account || msg.sender == voter); _unlocked = 1; IVoter(voter).distribute(address(this)); _unlocked = 2; for (uint i = 0; i < tokens.length; i++) { (rewardPerTokenStored[tokens[i]], lastUpdateTime[tokens[i]]) = _updateRewardPerToken(tokens[i]); uint _reward = earned(tokens[i], account); lastEarn[tokens[i]][account] = block.timestamp; userRewardPerTokenStored[tokens[i]][account] = rewardPerTokenStored[tokens[i]]; if (_reward > 0) _safeTransfer(tokens[i], account, _reward); emit ClaimRewards(msg.sender, tokens[i], _reward); } uint _derivedBalance = derivedBalances[account]; derivedSupply -= _derivedBalance; _derivedBalance = derivedBalance(account); derivedBalances[account] = _derivedBalance; derivedSupply += _derivedBalance; _writeCheckpoint(account, derivedBalances[account]); _writeSupplyCheckpoint(); }
which calls Voter.distribute()
. so if Gauge.deliverBribes()
fails then the logic of Voter.distribute()
and Gauge.getReward()
will fail too and users couldn't call them and attacker can cause DOS.
Of course the team
can swap those malicious reward tokens added by attacker with swapOutRewardToken()
and swapOutBribeRewardToken()
but for some time those logics will not work and attacker can cause DOS.
VIM
set access levels for notifyRewardAmount()
functions.
#0 - pooltypes
2022-06-13T15:51:33Z
Duplicate of #182
#1 - GalloDaSballo
2022-06-28T23:38:38Z
The warden has shown how, through adding a malicious bribe token via notifyRewardAmount
it is possible to deny claiming of all bribes.
An alternative would be to allow claiming directly in the Bribe.sol contract (which I believe is the original Solidly design)
However at this time the Sponsor has remediated through a list of approved tokens.
Because the DOS is contingent on external circumstances and ultimately can be ended by swapping out the bribe token (and filling in spam innocuous bribes), I believe Med Severity to be Appropriate
🌟 Selected for report: unforgiven
1941.948 USDC - $1,941.95
Voter.distribute()
calls Gauge.deliverBribes()
if claimable[_gauge] / DURATION > 0
was True
and claimable[_gauge]
shows base
token rewards for gauge
. Gauge.deliverBribes()
calls Bribe.deliverReward()
which transfers the rewards to Gauge
. so for Bribe
rewards to been transferred and distributed claimable[_gauge] / DURATION > 0
most be True
and if there was some new rewards in Bribe
and that condition is not True
then those rewards will stuck in Bribe
contract.
This is Voter.distribute()
code:
function distribute(address _gauge) public lock { require(isAlive[_gauge]); // killed gauges cannot distribute uint dayCalc = block.timestamp % (7 days); require((dayCalc < BRIBE_LAG) || (dayCalc > (DURATION + BRIBE_LAG)), "cannot claim during votes period"); IMinter(minter).update_period(); _updateFor(_gauge); uint _claimable = claimable[_gauge]; if (_claimable > IGauge(_gauge).left(base) && _claimable / DURATION > 0) { claimable[_gauge] = 0; IGauge(_gauge).notifyRewardAmount(base, _claimable); emit DistributeReward(msg.sender, _gauge, _claimable); // distribute bribes & fees too IGauge(_gauge).deliverBribes(); } }
As you can see IGauge(_gauge).deliverBribes();
is inside and if
which checks _claimable / DURATION > 0
for that Gauge
. Bribe
is transferring rewards in Bribe.deliverReward()
which is called by Gauge.deliverBribes()
and it is called by Voter.distribute()
if some base
token claimable amount of that Gauge
is bigger than DURATION
, so if this condition isn't true, then rewards in Bribe
for that epoch will stuck in Bribe
.
Voter.distribute()
can be called multiple times in each time claimable[_gauge]
will set to 0
so if Bribe
rewards was after that call then those rewards will stuck in Bribe
for sure.
VIM
Move the line IGauge(_gauge).deliverBribes();
out of If
in Voter.distribute()
#0 - GalloDaSballo
2022-06-30T23:26:52Z
It seems to me like the finding is invalid.
The check will check if the gauge has any rewards left (via IGauge(_gauge).left(base)
)
(_claimable > IGauge(_gauge).left(base) && _claimable / DURATION > 0) {
And the _claimable / DURATION
is checking that the amount is more than a dust amount, as in there's more than 1 wei of token per second.
Given the information that I have, in lack of a coded POC that shows and quantifies the amount of tokens stuck, I believe the finding to be invalid
#1 - GalloDaSballo
2022-07-01T00:18:33Z
After further review of various findings from the contest, another warden has submitted a convincing POC that shows that this indeed is an enactable vulnerability.
However, in this finding the warden hasn't shown how the Bribe rewards could be lost.
Because of that, I think Medium Severity to be more appropriate
🌟 Selected for report: unforgiven
Also found by: csanuragjain, p_crypt0, smiling_heretic
353.92 USDC - $353.92
Function deliverReward()
in Bribe
contract won't set tokenRewardsPerEpoch[token][epochStart]
to 0
after transferring rewards. Gauge.getReward()
calls Voter.distribute()
which calls Gauge.deliverBribes()
which calls Bribe.deliverReward()
. so if Gauge.getReward()
or Voter.distribute()
get called multiple times in same epoch then deliverReward()
will transfer Bribe
tokens multiple times because it doesn't set tokenRewardsPerEpoch[token][epochStart]
to 0
after transferring.
This is deliverReward()
code in Bribe
:
function deliverReward(address token, uint epochStart) external lock returns (uint) { require(msg.sender == gauge); uint rewardPerEpoch = tokenRewardsPerEpoch[token][epochStart]; if (rewardPerEpoch > 0) { _safeTransfer(token, address(gauge), rewardPerEpoch); } return rewardPerEpoch; }
As you can see it doesn't set tokenRewardsPerEpoch[token][epochStart]
value to 0
, so if this function get called multiple times it will transfer epoch rewards multiple times (it will use other epoch's rewards tokens).
function Gauge.deliverBribes()
calls Bribe.deliverReward()
and Gauge.deliverBribes()
is called by Voter.distribute()
if the condition claimable[_gauge] > DURATION
is True
. This is those functions codes:
function deliverBribes() external lock { require(msg.sender == voter); IBribe sb = IBribe(bribe); uint bribeStart = block.timestamp - (block.timestamp % (7 days)) + BRIBE_LAG; uint numRewards = sb.rewardsListLength(); for (uint i = 0; i < numRewards; i++) { address token = sb.rewards(i); uint epochRewards = sb.deliverReward(token, bribeStart); if (epochRewards > 0) { _notifyBribeAmount(token, epochRewards, bribeStart); } } }
function distribute(address _gauge) public lock { require(isAlive[_gauge]); // killed gauges cannot distribute uint dayCalc = block.timestamp % (7 days); require((dayCalc < BRIBE_LAG) || (dayCalc > (DURATION + BRIBE_LAG)), "cannot claim during votes period"); IMinter(minter).update_period(); _updateFor(_gauge); uint _claimable = claimable[_gauge]; if (_claimable > IGauge(_gauge).left(base) && _claimable / DURATION > 0) { claimable[_gauge] = 0; IGauge(_gauge).notifyRewardAmount(base, _claimable); emit DistributeReward(msg.sender, _gauge, _claimable); // distribute bribes & fees too IGauge(_gauge).deliverBribes(); } }
also Gauge.getReward()
calls Voter.getReward()
.
condition claimable[_gauge] > DURATION
in Voter.distribute()
can be true multiple time in one epoch (deliverBribes()
would be called multiple times) because claimable[_gauge]
is based on index
and index
increase by notifyRewardAmount()
in Voter
anytime.
VIM
set tokenRewardsPerEpoch[token][epochStart]
to 0
in deliverReward
#0 - pooltypes
2022-06-13T15:59:52Z
Thanks, this is an issue we discovered in prod. Issuing a fix soon
#1 - pooltypes
2022-06-13T16:00:10Z
We're also taking the necessary steps to alert any users who may have funds at risk from this issue
#2 - GalloDaSballo
2022-07-01T00:41:17Z
tokenRewardsPerEpoch[token][epochStart]
is indeed not resetted on each claim
Proving the finding is reliant on claimable[_gauge] += _share;
which is set in _updateFor
Which is contingent on index += _ratio;
in notifyRewardAmount
Because notifyRewardAmount
can be called by anyone, at the cost of an amount that just needs to be greater than or equal to totalWeight / 1e18
which may be a negligible amount, then indeed index
can increase, allowing the bribe to be called multiple times in one epoch.
In terms of impact, because the Bribe contract only allows for an extremely basic, now or in 7 days, type queueing of bribes, the accounting of bribes is mostly unnecessary (as the contract will most of the times be sending all tokens anyway).
Additionally the loss would amount to an incorrect amount of bribes emitted over 7 days instead of 14.
For those reasons, I think Medium Severity to be more appropriate
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x52, 0xNazgul, 0xNineDec, AlleyCat, BouSalman, CertoraInc, Chom, Dravee, Funen, GimelSec, Hawkeye, MaratCerby, Nethermind, Picodes, RoiEvenHaim, SooYa, TerrierLover, WatchPug, _Adam, asutorufos, berndartmueller, c3phas, catchup, cccz, cryptphi, csanuragjain, delfin454000, djxploit, fatherOfBlocks, gzeon, hake, hansfriese, horsefacts, hyh, jayjonah8, minhquanym, oyc_109, p_crypt0, pauliax, robee, rotcivegaf, sach1r0, sashik_eth, simon135, sorrynotsorry, teddav, unforgiven, xiaoming90
101.6421 USDC - $101.64
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L648-L662 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L590-L624
In _notifyBribeAmount()
and notifyRewardAmount()
code calculates rewardRate[token]
and to do that, first it transfers amount
and then set rewardRate[token] = amount / DURATION
so because of rounding error in division some of transferred rewards wouldn't be distributed among users and they will be locked in contract.
This is _notifyBribeAmount()
and notifyRewardAmount()
code in Gauge
:
function notifyRewardAmount(address token, uint amount) external lock { require(token != stake); require(amount > 0); if (!isReward[token]) { require(rewards.length < MAX_REWARD_TOKENS, "too many rewards tokens"); } // rewards accrue only during the bribe period uint bribeStart = block.timestamp - (block.timestamp % (7 days)) + BRIBE_LAG; uint adjustedTstamp = block.timestamp < bribeStart ? bribeStart : bribeStart + 7 days; if (rewardRate[token] == 0) _writeRewardPerTokenCheckpoint(token, 0, adjustedTstamp); (rewardPerTokenStored[token], lastUpdateTime[token]) = _updateRewardPerToken(token); _claimFees(); if (block.timestamp >= periodFinish[token]) { _safeTransferFrom(token, msg.sender, address(this), amount); rewardRate[token] = amount / DURATION; } else { uint _remaining = periodFinish[token] - block.timestamp; uint _left = _remaining * rewardRate[token]; require(amount > _left); _safeTransferFrom(token, msg.sender, address(this), amount); rewardRate[token] = (amount + _left) / DURATION; } require(rewardRate[token] > 0); uint balance = IERC20(token).balanceOf(address(this)); require(rewardRate[token] <= balance / DURATION, "Provided reward too high"); periodFinish[token] = adjustedTstamp + DURATION; if (!isReward[token]) { isReward[token] = true; rewards.push(token); IBribe(bribe).addRewardToken(token); } emit NotifyReward(msg.sender, token, amount); }
function _notifyBribeAmount(address token, uint amount, uint epochStart) internal { if (block.timestamp >= periodFinish[token]) { rewardRate[token] = amount / DURATION; } else { uint _remaining = periodFinish[token] - block.timestamp; uint _left = _remaining * rewardRate[token]; require(amount > _left); rewardRate[token] = (amount + _left) / DURATION; } lastUpdateTime[token] = epochStart; periodFinish[token] = epochStart + DURATION; emit NotifyReward(msg.sender, token, amount); }
As you can see to calculate rewardRate[token]
the transferred amount is divided by DURATION
so some of the rewards is not going to be distributed because of the rounding error here and code don't cumulative these extra rewards to use them again and rounding error happens in every call.
VIM
send back extra rewards or keep track of extra rewards and cumulative them and then distribute them.
#0 - pooltypes
2022-06-13T17:50:42Z
Duplicate of #182
#1 - GalloDaSballo
2022-06-30T23:09:19Z
Intuitively speaking the rounding error will be < 604800
tokens, where 1 token is 1e18, hence 12 orders of magnitude below 1 coin.
I think the rounding is present and honestly thought it was handled more gracefully in Solidly's original design.
That said, the impact is effectively less than the cost of 1 transaction, hinting that even on an L2, the gas to store the rounding error would be higher than the economic loss of the error itself.
I believe the warden has found a valid finding, but the impact is extremely small, for those reasons, I think QA is a more appropriate severity
#2 - GalloDaSballo
2022-07-02T00:45:12Z
Low Severity