Platform: Code4rena
Start Date: 31/01/2023
Pot Size: $90,500 USDC
Total HM: 47
Participants: 169
Period: 7 days
Judge: LSDan
Total Solo HM: 9
Id: 211
League: ETH
Rank: 39/169
Findings: 5
Award: $450.03
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xNazgul, 0xRobocop, Aymen0909, KIntern_NA, Kenshin, KingNFT, Krace, Kumpa, SadBase, aashar, apvlki, btk, cccz, critical-or-high, eccentricexit, fs0c, gjaldon, hansfriese, immeas, mert_eren, mgf15, mrpathfindr, orion, peanuts, rvi0x, rvierdiiev, supernova, ulqiorra, waldenyan20, y1cunhui
4.6115 USDC - $4.61
The claimRewards
function of MultiRewardStaking.sol
updates the user's accrued rewards after sending the tokens, violating Check-Effects-Interaction. There is also no access or reentry control, which means an adversary can reenter the function and drain the contract.
This is critical if ERC777 token is ever used with this contract.
Here is a simplified version of the interaction for better readability.
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { // amount to withdraw is read uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; // Dangerous token transfer happens here. Before the call is over, the erc777 calls the recipient // which in turns calls `claimRewards` again and the adversary gets to withdraw more tokens. token.transfer(msg.sender, rewardsAmount); // Too late, the state is updated. accruedRewards[user][_rewardTokens[i]] = 0; } }
Example of an attack contract:
contract Exploit is ERC777TokensRecipient { function attack() external { stakingContract.claimRewards(...); } function tokensReceived(...) override external { // Here the attacker would add logic to stop reentering // before the transaction runs out of gas or the victim // contract runs out of tokens. // ... // Reenter. stakingContract.claimRewards(...); } }
Manual review
Follow the Check-Effects-Interaction and update accruedRewards
before transferring tokens or add guards against reentry.
#0 - c4-judge
2023-02-16T07:38:42Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:10:50Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-02-18T12:11:52Z
RedVeil marked the issue as disagree with severity
#3 - c4-judge
2023-02-23T00:52:07Z
dmvt marked the issue as partial-50
#4 - c4-judge
2023-03-01T00:30:40Z
dmvt marked the issue as full credit
#5 - c4-judge
2023-03-01T00:45:17Z
dmvt marked the issue as satisfactory
88.0962 USDC - $88.10
Vault.sol
includes a ragequit period so users have a chance to leave to Vault before a change is executed. The code makes it look like it gives the users between 1 and 7 days, but in reality the rage quit duration is always 1 day, because the owner can just decrease it at any moment. The owner can use this to his advantage by setting it to 7 days and after 1 day has passed, drop it to 1 day, locking all users who would've rage quit between 1 and 7 days.
There is no proposeQuitPeriod
function and nothing stops the owner from calling this whenever they want:
/** * @notice Set a quitPeriod for rage quitting after new adapter or fees are proposed. Caller must be Owner. * @param _quitPeriod Time to rage quit after proposal. */ function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { if (_quitPeriod < 1 days || _quitPeriod > 7 days) revert InvalidQuitPeriod(); quitPeriod = _quitPeriod; emit QuitPeriodSet(quitPeriod); }
Changes to the fee and the adapter have at most 1 day of rage quit time.
Manual review.
Implement the propose + accept ragequit pattern for changes to the quite period duration as well:
... uint256 public quitPeriod = 3 days; + uint256 public proposedQuitPeriod; + uint256 public proposedQuitPeriodTime; + event QuitPeriodProposed(uint quitPeriod); event QuitPeriodSet(uint256 quitPeriod); ... + /** + * @notice Propose a new quit period duration for rage quitting. Caller must be Owner. + * @param _quitPeriod The new quit period proposed. + */ + function proposeQuitPeriod(uint256 _quitPeriod) external onlyOwner { + if (_quitPeriod < 1 days || _quitPeriod > 7 days) + revert InvalidQuitPeriod(); + + proposedQuitPeriod = _quitPeriod; + proposedQuitPeriodTime = block.timestamp; + + emit QuitPeriodProposed(_quitPeriod); + } ... - /** - * @notice Set a quitPeriod for rage quitting after new adapter or fees are proposed. Caller must be Owner. - * @param _quitPeriod Time to rage quit after proposal. - */ - function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { - if (_quitPeriod < 1 days || _quitPeriod > 7 days) - revert InvalidQuitPeriod(); - - quitPeriod = _quitPeriod; - - emit QuitPeriodSet(quitPeriod); - } + /** + * @notice Accept a new ragequit period duration. Caller must be Owner. + */ + function setQuitPeriod() external onlyOwner { + if (block.timestamp < proposedQuitPeriodTime + quitPeriod) revert NotPassedQuitPeriod(quitPeriod); + + quitPeriod = proposedQuitPeriod; + + emit QuitPeriodSet(quitPeriod); + } ...
#0 - c4-judge
2023-02-16T06:36:07Z
dmvt marked the issue as duplicate of #363
#1 - c4-sponsor
2023-02-18T12:06:20Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T22:36:49Z
dmvt marked the issue as satisfactory
🌟 Selected for report: imare
Also found by: 7siech, Ch_301, Hawkeye, KIntern_NA, Malatrax, Ruhum, Walter, bin2chen, eccentricexit, hansfriese, ladboy233, peakbolt, rbserver, rvierdiiev, thecatking
7.466 USDC - $7.47
Delegate calls to the strategy are supposed to be regulated by a cooldown. However, lastHarverst
is never actually updated with block.timestamp
which means anyone can call it as much as they want.
function harvest() public takeFees { if ( address(strategy) != address(0) && ((lastHarvest + harvestCooldown) < block.timestamp) ) { // solhint-disable address(strategy).delegatecall( abi.encodeWithSignature("harvest()") ); // Missing lastHarvest = block.timestamp; here. } emit Harvested(); }
We don't know how future strategies will behave. It's better to plug this hole.
Manual review
Add the missing update to lastHarvest
.
#0 - c4-judge
2023-02-16T04:25:55Z
dmvt marked the issue as duplicate of #199
#1 - c4-sponsor
2023-02-18T12:00:58Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T22:52:42Z
dmvt marked the issue as partial-50
🌟 Selected for report: rbserver
Also found by: bin2chen, cccz, chaduke, eccentricexit, hansfriese, ustas
44.0481 USDC - $44.05
Vault deposits and withdrawals are supposed to update the high water mark, but users can avoid the sync when burning shares by using the redeem function instead of withdraw as it is lacking the modifier.
function withdraw( uint256 assets, address receiver, address owner ) public nonReentrant syncFeeCheckpoint returns (uint256 shares) { // Withdraw has the modifier. ... } function redeem( uint256 shares, address receiver, address owner ) public nonReentrant returns (uint256 assets) { // missing syncFeeCheckpoint modifier. ... }
Manual review.
Add the missing modifier:
function redeem( uint256 shares, address receiver, address owner - ) public nonReentrant returns (uint256 assets) { + ) public nonReentrant syncFeeCheckpoint returns (uint256 assets) { ... }
#0 - c4-judge
2023-02-16T02:58:49Z
dmvt marked the issue as duplicate of #118
#1 - c4-sponsor
2023-02-18T11:52:13Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T11:28:14Z
dmvt marked the issue as partial-50
🌟 Selected for report: IllIllI
Also found by: 0x3b, 0xAgro, 0xBeirao, 0xMirce, 0xNineDec, 0xRobocop, 0xSmartContract, 0xTraub, 0xWeiss, 2997ms, 41i3xn, Awesome, Aymen0909, Bauer, Bnke0x0, Breeje, Cryptor, DadeKuma, Deathstore, Deekshith99, DevABDee, DevTimSch, Dewaxindo, Diana, Ermaniwe, Guild_3, H0, IceBear, Inspectah, JDeryl, Kaiziron, Kaysoft, Kenshin, Mukund, Praise, RaymondFam, Rickard, Rolezn, Ruhum, Sathish9098, SkyWalkerMan, SleepingBugs, UdarTeam, Udsen, Walter, aashar, adeolu, apvlki, arialblack14, ast3ros, btk, chaduke, chandkommanaboyina, chrisdior4, climber2002, codetilda, cryptonue, cryptostellar5, csanuragjain, ddimitrov22, descharre, dharma09, doublesharp, eccentricexit, ethernomad, fs0c, georgits, halden, hansfriese, hashminer0725, immeas, lukris02, luxartvinsec, matrix_0wl, merlin, mookimgo, mrpathfindr, nadin, olegthegoat, pavankv, rbserver, rebase, savi0ur, sayan, scokaf, seeu, shark, simon135, tnevler, tsvetanovv, ulqiorra, ustas, waldenyan20, y1cunhui, yongskiws, yosuke
305.798 USDC - $305.80
MultiRewardEscrow uses the transaction inclusion time (block.timestamp) as the starting point of the escrow. This can lead to unintended behavior in times of high transaction fee volatility. In case of sudden gas price increase, an adversary could also hold on to the transaction and re-publish it again when gas prices drop.
Suggestion: Let the user set the starting time of the escrow. It may also be interesting to add a mechanism to prevent republishing of the transaction, such as a nonce or inclusion deadline:
function lock( IERC20 token, address account, uint256 amount, uint32 duration, - uint32 offset + uint32 offset, + uint256 startTime ) external { ... - uint32 start = block.timestamp.safeCastTo32() + offset; + uint32 start = startTime + offset; ... }
MultiRewardEscrow.claimRewards and MultiRewardStaking.claimRewards revert if there is a single item with 0 claimable amount. This can make building convenience tools that claim for users hard to build and waste gas if the call is included after one of the items is individually claimed.
Suggestion: continue
to the next item instead of revert
ing so other they can continue being claimed:
function claimRewards(bytes32[] memory escrowIds) external { for (uint256 i = 0; i < escrowIds.length; i++) { ... uint256 claimable = _getClaimableAmount(escrow); - if (claimable == 0) revert NotClaimable(escrowId); + if (claimable == 0) continue; ... } }
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { ... - if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); + if (rewardAmount == 0) continue; ... } }
isClaimable
returns true even for unclaimable escrowsIf an escrow is in the offset period, it is not claimable. MultiRewardEscrow.isClaimable
, however, will still return true, which is unexpected and can break potential future integrations:
Suggestion: Return false if the escrow is still in the offset period and document the function.
function isClaimable(bytes32 escrowId) external view returns (bool) { + if (block.timestamp < escrows[escrowId].start) return false; return escrows[escrowId].lastUpdateTime != 0 && escrows[escrowId].balance > 0; }
CloneRegistry.addClone does not check if the item was already added before, pushing a duplicate into the clones
mapping and allClones
array as well as emitting a new CloneAdded
event which can confuse offchain tooling.
Suggestion: Disallow adding a clone that already exists:
... + error CloneAlreadyExists(); ... function addClone( bytes32 templateCategory, bytes32 templateId, address clone ) external onlyOwner { + if(cloneExists[clone]) revert CloneAlreadyExists(); cloneExists[clone] = true; clones[templateCategory][templateId].push(clone); allClones.push(clone); emit CloneAdded(clone); }
According to the documentation, DeploymentController.addTemplate should only be callable by the owner (VaultController
or AdminProxy
) but it is callable by anyone because it is missing a onlyOwner
modifier.
Suggestion: Add the missing onlyOwner
modifier:
/** * @notice Adds a new category for templates. Caller must be owner. (`VaultController` via `AdminProxy`) * @param templateCategory Category of the new template. * @param templateId Unique Id of the new template. * @param template New template (See ITemplateRegistry for more details) * @dev (See TemplateRegistry for more details) */ function addTemplate( bytes32 templateCategory, bytes32 templateId, Template calldata template - ) external { + ) external onlyOwner { templateRegistry.addTemplate(templateCategory, templateId, template); }
Documentation and code on setHarvestCooldown
disallow >= 1 day durations, but the adapter can be initialized with any value.
Suggestion: Disallow initializing the adapter with illegal values:
function __AdapterBase_init(bytes memory popERC4626InitData) internal onlyInitializing { ... + if (_harvestCooldown >= 1 days) revert InvalidHarvestCooldown(_harvestCooldown); harvestCooldown = _harvestCooldown; if (_strategy != address(0)) _verifyAndSetupStrategy(_requiredSigs); ... }
2.1 IMultiRewardEscrow.Fee.accrued field is never used.
2.2 EIP165: Use interface
or abstract
instead of contract
.
2.3 MultiRewardStaking, BeefyAdapter: Instead of declaring _name
and _symbol
again, use the fields already available from the parent ERC20
contracts by calling __ERC20_init
inside initialize
.
2.4 WithRewards: Add the missing abstract
in the contract declaration.
2.5 AdapterBase.harvest: Only emit Harvested
from inside the if since it does not actually harvest if the condition passes. This will also save gas.
3.1 ITemplateRegistry.Template: Remove trailing dots from natspec.
3.2 AdminProxy, VaultController: Typo in documentation. Change Ownes
to Owns
.
3.3 Vault.changeAdapter: Neither highWaterMark
nor assetsCheckpoint
are updated here. Remove the misleading docs.
3.4 VaultController.deployVault: Caller does not need to be the owner if address(1)
is set to false
. Clarify this in the documentation.
3.5 VaultController.deployAdapter: As in 3.4, caller does not need to be the owner if address(1)
is set to false
. Clarify this in the documentation.
#0 - c4-judge
2023-02-28T17:40:18Z
dmvt marked the issue as grade-a