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: 6/75
Findings: 6
Award: $4,284.67
π Selected for report: 4
π Solo Findings: 0
π Selected for report: hyh
Also found by: WatchPug, hansfriese, rotcivegaf
1179.7334 USDC - $1,179.73
Users who are approved, but do not own a particular NFT, are supposed to be eligible to call merge and withdraw from the NFT.
Currently _burn(), used by merge() and withdraw() to remove the NFT from the system, will revert unless the sender is the owner of NFT as the function tries to update the accounting for the sender, not the owner.
Setting the severity to medium as the impact is merge() and withdraw() permanent unavailability for any approved sender, who isn't the owner of the involved NFT.
_removeTokenFrom() requires _from
to be the NFT owner as it removes _tokenId
from the _from
account:
/// @dev Remove a NFT from a given address /// Throws if `_from` is not the current owner. function _removeTokenFrom(address _from, uint _tokenId) internal { // Throws if `_from` is not the current owner assert(idToOwner[_tokenId] == _from); // Change the owner idToOwner[_tokenId] = address(0); // Update owner token index tracking _removeTokenFromOwnerList(_from, _tokenId); // Change count tracking ownerToNFTokenCount[_from] -= 1; }
_burn() allows _tokenId
to approved or owner, but calls _removeTokenFrom() with msg.sender
as _from
:
function _burn(uint _tokenId) internal { require(_isApprovedOrOwner(msg.sender, _tokenId), "caller is not owner nor approved"); address owner = ownerOf(_tokenId); // Clear approval approve(address(0), _tokenId); // TODO add delegates // Remove token _removeTokenFrom(msg.sender, _tokenId); emit Transfer(owner, address(0), _tokenId); }
This way if _burn() is called by an approved account who isn't an owner, it will revert on _removeTokenFrom()'s assert(idToOwner[_tokenId] == _from)
check.
Now burn() is used by merge():
function merge(uint _from, uint _to) external { require(attachments[_from] == 0 && !voted[_from], "attached"); require(_from != _to); require(_isApprovedOrOwner(msg.sender, _from)); require(_isApprovedOrOwner(msg.sender, _to)); LockedBalance memory _locked0 = locked[_from]; LockedBalance memory _locked1 = locked[_to]; uint value0 = uint(int256(_locked0.amount)); uint end = _locked0.end >= _locked1.end ? _locked0.end : _locked1.end; locked[_from] = LockedBalance(0, 0); _checkpoint(_from, _locked0, LockedBalance(0, 0)); _burn(_from);
And withdraw():
/// @notice Withdraw all tokens for `_tokenId` /// @dev Only possible if the lock has expired function withdraw(uint _tokenId) external nonreentrant { assert(_isApprovedOrOwner(msg.sender, _tokenId)); require(attachments[_tokenId] == 0 && !voted[_tokenId], "attached"); LockedBalance memory _locked = locked[_tokenId]; require(block.timestamp >= _locked.end, "The lock didn't expire"); uint value = uint(int256(_locked.amount)); locked[_tokenId] = LockedBalance(0,0); uint supply_before = supply; supply = supply_before - value; // old_locked can have either expired <= timestamp or zero end // _locked has only 0 end // Both can have >= 0 amount _checkpoint(_tokenId, _locked, LockedBalance(0,0)); assert(IERC20(token).transfer(msg.sender, value)); // Burn the NFT _burn(_tokenId);
Consider changing _removeTokenFrom() argument to be the owner:
function _burn(uint _tokenId) internal { require(_isApprovedOrOwner(msg.sender, _tokenId), "caller is not owner nor approved"); address owner = ownerOf(_tokenId); // Clear approval approve(address(0), _tokenId); // TODO add delegates // Remove token - _removeTokenFrom(msg.sender, _tokenId); + _removeTokenFrom(owner, _tokenId); emit Transfer(owner, address(0), _tokenId); }
#0 - GalloDaSballo
2022-06-29T19:56:01Z
The warden has shown how an approved user is unable to execute ordinary operations due to a logic flaw, while the impact may make Medium Severity valid, as the owner can still operated, but delegated users cannot, I believe the finding shows a logical flaw in the system in that it doesn't work as intended.
For that reason I believe this finding is of High Severity
π Selected for report: hyh
Also found by: smiling_heretic, unforgiven, xiaoming90
1179.7334 USDC - $1,179.73
Any user balance affecting action, i.e. deposit, withdraw/withdrawToken or getReward, calls _writeCheckpoint to update the balance records used for the earned reward estimation. The issue is that _writeCheckpoint always sets false to voted
flag for the each new checkpoint due to wrong index used in the mapping access, while only voted periods are eligible for accruing the rewards.
This way any balance changing action of a voted user will lead to stopping of the rewards accrual for the user, until next vote will be cast. I.e. any action that has no relation to voting and should have only balance change as the reward accruing process impact, in fact removes any future rewards from the user until the next vote.
Setting the severity to be high as the impact here violates system logic and means next periods accrued rewards loss for a user.
_writeCheckpoint adds a new checkpoint if block.timestamp is not found in the last checkpoint:
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; } }
However, instead of moving vote status from the previous checkpoint it records false
to prevVoteStatus
all the time as last status is checkpoints[account][_nCheckPoints-1].voted
, while checkpoints[account][_nCheckPoints]
isn't created yet and is empty:
bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false;
Notice that checkpoints
is a mapping and no range check violation happens:
/// @notice A record of balance checkpoints for each account, by index mapping (address => mapping (uint => Checkpoint)) public checkpoints;
This will effectively lead to rewards removal on any user action, as earned() used in rewards estimation counts only voted periods:
if (_endIndex > 0) { for (uint i = _startIndex; i < _endIndex; i++) { Checkpoint memory cp0 = checkpoints[account][i]; Checkpoint memory cp1 = checkpoints[account][i+1]; (uint _rewardPerTokenStored0,) = getPriorRewardPerToken(token, cp0.timestamp); (uint _rewardPerTokenStored1,) = getPriorRewardPerToken(token, cp1.timestamp); if (cp0.voted) { reward += cp0.balanceOf * (_rewardPerTokenStored1 - _rewardPerTokenStored0) / PRECISION; } } } Checkpoint memory cp = checkpoints[account][_endIndex]; uint lastCpWeeksVoteEnd = cp.timestamp - (cp.timestamp % (7 days)) + BRIBE_LAG + DURATION; if (block.timestamp > lastCpWeeksVoteEnd) { (uint _rewardPerTokenStored,) = getPriorRewardPerToken(token, cp.timestamp); if (cp.voted) { reward += cp.balanceOf * (rewardPerToken(token) - Math.max(_rewardPerTokenStored, userRewardPerTokenStored[token][account])) / PRECISION; } }
I.e. if a user has voted, then performed any of the operations that call _writeCheckpoint update: deposit, withdraw/withdrawToken or getReward, then this user will not have any rewards for the period between this operation and the next vote as all checkpoints that were created by _writeCheckpoint will have voted == false
.
Update the index to be _nCheckPoints-1
:
- bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false; + bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints - 1].voted : false;
#0 - GalloDaSballo
2022-06-30T23:57:18Z
Due to 1 typo / oversight in the line bool prevVoteStatus = (_nCheckPoints > 0) ? checkpoints[account][_nCheckPoints].voted : false;
All future checkpoints are registered as having voted
set to false.
Due to a configuration choice (or mistake as detailed in other findings), having voted
set to false causes all rewards to not be receiveable.
While the impact is loss of Yield (typically a medium finding), the finding shows how this bug will systematically impact all gauges for all users.
Because of that., I believe High Severity to be appropriate
873.8766 USDC - $873.88
If Bribe and Gauge constructors are run not in the same transaction, the griefing attack is possible. A malicious user can run setGauge after Bribe, but before Gauge constructor, making Bribe contract unusable. The fix here is Bribe redeployment.
Setting severity to be medium as that is temporary system breaking impact.
setGauge can be run by anyone, but only once with a meaningful gauge:
function setGauge(address _gauge) external { require(gauge == address(0), "gauge already set"); gauge = _gauge; }
Now it is called in Gauge constructor:
constructor(address _stake, address _bribe, address __ve, address _voter, bool _isForPair) { stake = _stake; bribe = _bribe; _ve = __ve; voter = _voter; factory = msg.sender; IBribe(bribe).setGauge(address(this));
This way it will not be called before Gauge constructor, but if it is not atomic with Bribe constructor, an attacker can call in-between.
Consider either running Bribe and then Gauge constructors atomically, or introducing an owner role in Bribe constructor and onlyOwner access control in setGauge, setting it manually.
#0 - pooltypes
2022-06-10T02:58:40Z
Thanks, we've addressed this and removed the setGauge
pattern in our mainnet deployment
#1 - GalloDaSballo
2022-06-25T20:36:48Z
The warden has identified a grief that can be done by front-running a non-authorized call to setGauge
.
Impact would require re-deploying the bribes contract and in lack of an atomic deploy + set, I believe the finding to be valid and Medium Severity to be appropriate
π Selected for report: 0xf15ers
Also found by: 0x52, Ruhum, WatchPug, berndartmueller, cccz, horsefacts, hyh, minhquanym, pauliax
https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L590-L595 https://github.com/code-423n4/2022-05-velodrome/blob/7fda97c570b758bbfa7dd6724a336c43d4041740/contracts/contracts/Gauge.sol#L617-L621
Griefing attack is possible as Gauge's and Bribe's notifyRewardAmount() allows for adding the reward token by anyone up to MAX_REWARD_TOKENS. This will disable the possibility of the further addition of reward tokens and clutter the system as reward token array cycle is used in Gauge's deliverBribes(), deposit() and withdraw() (via _updateRewardForAllTokens()).
Neither Gauge, nor Bribe have the ability to remove reward tokens, so the attack will have permanent impact on the deployed contracts. Making this a combined issue as Gauge's notifyRewardAmount() calls Bribe's addRewardToken(), and Bribe's notifyRewardAmount() calls Gauge's addBribeRewardToken() with the similar functionality, i.e. attack on one of them is equivalent to attack on another.
The inability to add reward tokens is a violation of system's logic and substantial slowing down of user-facing deposit() and withdraw() affects all the users and raises cumulative gas costs of system's usage for them, so marking severity here as high.
Gauge's notifyRewardAmount() do not have access controls:
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"); }
notifyRewardAmount() also adds reward token if it is not registered yet:
if (!isReward[token]) { isReward[token] = true; rewards.push(token); IBribe(bribe).addRewardToken(token); }
There is no lower limit on a provided amount for any new token, e.g. 1 wei will suffice:
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;
This way any caller can add any token as a reward one. This makes a griefing attack possible, when a malicious user calls notifyRewardAmount(token, amount)
with a tiny amount and a different token each time, filling up the reward token array, rewards
.
anyone can fill up the reward query up the maximum, preventing from any further addition of reward tokens and slowing down the system.
Similarly, Bribe's notifyRewardAmount have no access controls and do not require a reward token to be registered, only checks the maximum:
function notifyRewardAmount(address token, uint amount) external lock { require(amount > 0); if (!isReward[token]) { require(rewards.length < MAX_REWARD_TOKENS, "too many rewards tokens"); }
And adds it to the rewards
of both contracts if the token is previously unknown:
if (!isReward[token]) { isReward[token] = true; rewards.push(token); IGauge(gauge).addBribeRewardToken(token); }
Gauge's deliverBribes(), deposit() and withdraw() functions loop over rewards
array, so the attack will substantially slow down and raise gas costs for the system, as a loop over usual 1-3 reward tokens will be extended to 16:
uint internal constant MAX_REWARD_TOKENS = 16;
There seems to be no way to remove a reward token in Gauge and Bribe, so the impact is permanent.
Consider making Gauge's and Bribe's notifyRewardAmount() require isReward[token]
, and introducing permissioned addRewardToken() to Gauge so only registered actors can run it.
Alternatively, if there is a desire to keep the system permissionless, consider introducing the oracle and requiring substantial enough minimum predefined value to be locked on new token addition, so a tiny amount in the attack will not suffice. Also, as an additional measure, consider reducing the MAX_REWARD_TOKENS, as lower values, like 5 or 7, will suffice for the most practical purposes.
#0 - pooltypes
2022-06-13T15:52:41Z
Duplicate of #182
#1 - GalloDaSballo
2022-06-30T22:52:25Z
Dup of #182
π Selected for report: hyh
Also found by: codexploder
873.8766 USDC - $873.88
_updateRewardForAllTokens is called in withdraw() only, while both withdraw() and withdrawToken() are public and can be called directly. Reward update is needed on user's balance change, its absence leads to reward data rewriting and rewards losing impact for a user.
Per discussion with project withdrawToken() will not be exposed in UI, so setting the severity to be medium as user's direct usage of the vulnerable function is required here, while the impact is losing of a part of accumulated rewards.
withdraw() is a wrapper for withdrawToken(), which changes user's balance. withdrawToken() can be called directly, and in this case there will be no rewards update:
function withdraw(uint amount) public { _updateRewardForAllTokens(); uint tokenId = 0; if (amount == balanceOf[msg.sender]) { tokenId = tokenIds[msg.sender]; } withdrawToken(amount, tokenId); } function withdrawToken(uint amount, uint tokenId) public lock { totalSupply -= amount; balanceOf[msg.sender] -= amount; _safeTransfer(stake, msg.sender, amount);
This way if a user call withdrawToken(), the associated rewards can be lost due to incomplete reward balance accounting.
A showcase of reward update issue:
https://github.com/belbix/solidly/issues/1
It's replicated live with users losing accumulated rewards:
https://github.com/solidlyexchange/solidly/issues/55
Consider moving rewards update to withdrawToken(), since it is called by withdraw() anyway:
function withdraw(uint amount) public { - _updateRewardForAllTokens(); uint tokenId = 0; if (amount == balanceOf[msg.sender]) { tokenId = tokenIds[msg.sender]; } withdrawToken(amount, tokenId); } function withdrawToken(uint amount, uint tokenId) public lock { + _updateRewardForAllTokens(); totalSupply -= amount; balanceOf[msg.sender] -= amount; _safeTransfer(stake, msg.sender, amount);
Or, if the intent is to keep withdrawToken() as is, consider making it private, so no direct usage be possible:
- function withdrawToken(uint amount, uint tokenId) public lock { + function withdrawToken(uint amount, uint tokenId) private lock { totalSupply -= amount; balanceOf[msg.sender] -= amount; _safeTransfer(stake, msg.sender, amount);
#0 - GalloDaSballo
2022-06-29T20:20:57Z
I'm extremely conflicted about this finding as it's effectively a copy paste that tries to imply that not calling _updateRewardForAllTokens
is dangerous, yet no effort is made in showing why.
That said, _updateRewardForAllTokens
seems to be reliant on the supplyCheckpoints
which would be changed via _writeSupplyCheckpoint
. Leading me to agree that the reward math will be wrong.
I want to advise the warden to write a more detailed POC,which shows impact, as leaving that to imagination is not the way to go
π 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
102.2071 USDC - $102.21
// codifies the minting rules as per ve(3,3), abstracted from the token to support any token that allows minting // TODO: decide on whether to abstract from VELO or not. currently it's only somewhat abstracted (e.g. L38)
Consider simplifying the comment to state that currently the system supports VELO token only as current Minter contract implementation arenβt universal, first of all it doesnβt allow tokens with decimals different from 18 (several functions break up on different decimals).
Both FTM testnet and Kovan ids are left in release code:
contract RedemptionReceiver is ILayerZeroReceiver { IERC20 public immutable USDC; IVelo public immutable VELO; uint16 public immutable fantomChainId; // 12 for FTM, 10012 for FTM testnet ... constructor( ... ) { require(_fantomChainId == 12 || _fantomChainId == 10012, "CHAIN_ID_NOT_FTM");
contract RedemptionSender { address public immutable weve; uint16 public immutable optimismChainId; // 11 for OP, 10011 for OP Kovan ... constructor( ... ) { require(_optimismChainId == 11 || _optimismChainId == 10011, "CHAIN_ID_NOT_OP");
Consider removing test chain ids from the production code to make it cleaner and reduce further development mistakes possibility.
// Delete
should be put several lines lower, here it is not really relevant:
/// @dev Remove a NFT from an index mapping to a given address /// @param _from address of the sender /// @param _tokenId uint ID Of the token to be removed function _removeTokenFromOwnerList(address _from, uint _tokenId) internal { // Delete uint current_count = _balance(_from) - 1; uint current_index = tokenToOwnerIndex[_tokenId];
To be /// @dev Execute
:
/// @dev Exeute transfer of a NFT.
IRouter internal immutable router; // TODO make modifiable?
// TODO delegates
// TODO add delegates
// TODO add delegates
Consider removing TODO comments from the production code
Assert will consume all the available gas, providing no additional benefits when being used instead of require, which both returns gas and allows for error message.
assert
is used instead of require across the system:
RewardsDistributor contract:
assert(msg.sender == depositor);
4 occurrences in Router contract, starting with:
assert(msg.sender == address(weth)); // only accept ETH via fallback from the WETH contract
13 occurrences in VotingEscrow contract, starting with:
assert(_operator != msg.sender);
Using assert in production isn't recommended, consider substituting it with require in all the cases.
#0 - GalloDaSballo
2022-07-04T00:33:46Z
Valid NC
Valid NC
##Β 3. Comment irrelevant (non-critical) Valid NC
Valid NC
Valid NC
Valid Low
Really interesting report
1 L, 5 NC