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: 35/169
Findings: 7
Award: $609.17
🌟 Selected for report: 2
🚀 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
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L186
An attacker can drain all the tokens from MultiRewardStaking
In claimtRewards
important state changes are done after interactions with tokens:
File: MultiRewardStaking.sol function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); } else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } accruedRewards[user][_rewardTokens[i]] = 0; } }
If an erc777 token (or other ERC20 that supports callbacks for transfers) is used as rewardToken, an attacker could use reentrancy to steal all the tokens since the rewards is zeroed after interactions.
manual audit, vs code
Follow checks, effects, interactions:
diff --git a/src/utils/MultiRewardStaking.sol b/src/utils/MultiRewardStaking.sol index 95ebefd..f8433aa 100644 --- a/src/utils/MultiRewardStaking.sol +++ b/src/utils/MultiRewardStaking.sol @@ -175,6 +175,8 @@ contract MultiRewardStaking is ERC4626Upgradeable, OwnedUpgradeable { EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; + accruedRewards[user][_rewardTokens[i]] = 0; + if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); @@ -182,8 +184,6 @@ contract MultiRewardStaking is ERC4626Upgradeable, OwnedUpgradeable { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } - - accruedRewards[user][_rewardTokens[i]] = 0; } }
or use an OpenZepplin reentrancy guard.
#0 - c4-judge
2023-02-16T07:40:26Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:11:21Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:46:58Z
dmvt marked the issue as partial-50
#3 - c4-judge
2023-03-01T00:42:11Z
dmvt marked the issue as satisfactory
🌟 Selected for report: 0xNineDec
Also found by: 0xBeirao, 0xNazgul, 0xRajkumar, Blockian, Breeje, CRYP70, Josiah, KIntern_NA, MyFDsYours, Qeew, RaymondFam, Ruhum, UdarTeam, chaduke, giovannidisiena, gjaldon, immeas, koxuan, nadin, peanuts, rbserver, rvi0x, savi0ur
14.2839 USDC - $14.28
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L285-L287
This is the classic share inflation attack described here: https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
The popcorn Vault is an abstraction on top of other vaults which acts like adapters to wrap other yield bearing protocols. Hence the asset in Vault are the shares in this adapter.
An early user can get shares in the underlying vault by depositing assets directly there. Then deposit 1 wei
into the vault and donate the minted adapter shares to the vault. Effectively exploding the share value.
An early user can drastically impact the share value for later users.
PoC test in Vault.t.sol
:
function test__inital_share_price_manipulation() public { // setup asset.mint(alice, 1e18); vm.startPrank(alice); asset.approve(address(vault), 1); asset.approve(address(adapter), 1e18-1); // lock some assets directly in the adapter to get adapter shares adapter.deposit(1e18-1,alice); // deposit 1 wei into vault vault.deposit(1); // vault conversion is 1:1 (1 share requires 1 asset) console.log("assets required per share before",vault.convertToAssets(1)); // transfer adapter shares to vault adapter.transfer(address(vault),1e18-1); vm.stopPrank(); // vault conversion is now (1e18:1, 1 share requires 1e18 assets) console.log("assets required per share after",vault.convertToAssets(1)); }
manual audit, vs code, forge
There are a couple of different approaches to mitigating this:
Burn first 1000 shares
In the same way that the asset ERC4626 contract keeps an internal balance the vault could
only allow Vault to deposit/mint in the adapter.
Follow the recommendation in https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677 and inflate the decimals in the shares.
#0 - c4-judge
2023-02-16T03:31:23Z
dmvt marked the issue as duplicate of #15
#1 - c4-sponsor
2023-02-18T11:54:57Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:10:17Z
dmvt marked the issue as satisfactory
114.5251 USDC - $114.53
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L629-L636
The quitPeriod
is supposed to give users time to rage quit if there are changes they don't agree with. The quit period is limited to be within 1 day and a week and can only be changed by owner
:
File: Vault.sol 629: function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { 630: if (_quitPeriod < 1 days || _quitPeriod > 7 days) 631: revert InvalidQuitPeriod(); 632: 633: quitPeriod = _quitPeriod; 634: 635: emit QuitPeriodSet(quitPeriod); 636: }
However the change can be done instantly. An owner can propose a change, users will expect to wait three days for it to be applied, and after one day change the quitPeriod
to 1 day
and apply the changes.
Changes to fees and adapters can happen faster than users expect not giving them time enough to react.
Small PoC in Vault.t.sol
:
function test__set_fees_after_1_day() public { vault.proposeFees( VaultFees({ deposit: 1e17, withdrawal: 1e17, management: 1e17, performance: 1e17 }) ); // users expect to have three days console.log("quit period",vault.quitPeriod()); // jump 1 day vm.warp(block.timestamp + 1 days); // owner changes quit period vault.setQuitPeriod(1 days); // and does the changes vault.changeFees(); }
manual audit, vs code, forge
Either lock quitPeriod
changes for the old quitPeriod
.
Or apply the duration when the change is proposed:
diff --git a/src/vault/Vault.sol b/src/vault/Vault.sol index 7a8f941..bccc561 100644 --- a/src/vault/Vault.sol +++ b/src/vault/Vault.sol @@ -531,14 +531,14 @@ contract Vault is ) revert InvalidVaultFees(); proposedFees = newFees; - proposedFeeTime = block.timestamp; + proposedFeeTime = block.timestamp + quitPeriod; emit NewFeesProposed(newFees, block.timestamp); } /// @notice Change fees to the previously proposed fees after the quit period has passed. function changeFees() external { - if (block.timestamp < proposedFeeTime + quitPeriod) + if (block.timestamp < proposedFeeTime) revert NotPassedQuitPeriod(quitPeriod); emit ChangedFees(fees, proposedFees);
Same applies for changeAdapter
#0 - c4-judge
2023-02-16T06:36:12Z
dmvt marked the issue as duplicate of #363
#1 - c4-sponsor
2023-02-18T12:06:13Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T22:06:13Z
dmvt marked the issue as selected for report
🌟 Selected for report: immeas
Also found by: KIntern_NA, bin2chen, minhtrng, rbserver, rvierdiiev, ustas, yellowBirdy
90.1885 USDC - $90.19
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L496-L499
performanceFee
is a fee on the profits of the vault. The feeRecipient
(or any user) can collect these at any point.
They rely on the difference between the current share value and the highWaterMark
that records a historical share value.
The issue is that this highWaterMark
is written on interactions with the vault: deposit
, mint
, withdraw
. Hence a user can front run the fee collection with any of these calls. That will set the highWaterMark
to the current share value and remove the performance fee.
A malicious user can maximize the yield and deny any performance fee by front running the fee collection with a call to either deposit
, mint
or withdraw
with only dust amounts.
PoC test in Vault.t.sol
function test__front_run_performance_fee() public { _setFees(0, 0, 0, 1e17); // 10% performance fee asset.mint(alice, 1e18); vm.startPrank(alice); asset.approve(address(vault), 1e18); vault.deposit(1e18); vm.stopPrank(); asset.mint(address(adapter),1e18); // fake yield // performanceFee is 1e17 (10% of 1e18) console.log("performanceFee before",vault.accruedPerformanceFee()); vm.prank(alice); vault.withdraw(1); // malicious user withdraws dust which triggers update of highWaterMark // performanceFee is 0 console.log("performanceFee after",vault.accruedPerformanceFee()); }
manual audit, vs code, forge
At every deposit, mint, redeem and withdraw, take fees before adding or removing the new users shares and assets.
#0 - c4-judge
2023-02-16T06:11:49Z
dmvt marked the issue as duplicate of #30
#1 - c4-sponsor
2023-02-18T12:05:13Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-02-22T16:29:11Z
RedVeil marked the issue as sponsor acknowledged
#3 - c4-judge
2023-02-23T01:20:57Z
dmvt marked the issue as selected for report
313.3026 USDC - $313.30
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L491
managementFee
is a fee that is taken on the TVL. performanceFee
is a fee taken on the yield received. Both as a percentage of assets.
However, they are used directly to hand out shares:
File: Vault.sol 480: modifier takeFees() { 481: uint256 managementFee = accruedManagementFee(); 482: uint256 totalFee = managementFee + accruedPerformanceFee(); ... 488: if (managementFee > 0) feesUpdatedAt = block.timestamp; 489: 490: if (totalFee > 0 && currentAssets > 0) 491: _mint(feeRecipient, convertToShares(totalFee)); 492: 493: _; 494: }
Since the share minting will lessen the value per share, feeRecipient
will receive shares worth less in asset than what the fees are configured for.
feeRecipient
will receive a less than expected managementFee
and performanceFee
.
PoC test in Vault.t.sol
:
function test__fee_calculation() public { _setFees(0, 0, 1e17, 1e17); // 10% fees // reset feesUpdatedAt vault.takeManagementAndPerformanceFees(); asset.mint(alice, 1e18); vm.startPrank(alice); asset.approve(address(vault), 1e18); vault.deposit(1e18); vm.stopPrank(); // time passes vm.warp(block.timestamp + 356.25 days); asset.mint(address(adapter),1e18); // fake yield // fees are 10% console.log("managementFee, 10%% of assets",vault.accruedManagementFee()); console.log("performanceFee, 10%% of price increase",vault.accruedPerformanceFee()); vault.takeManagementAndPerformanceFees(); console.log("shares minted for feeRecipient",vault.balanceOf(feeRecipient)); // 10% of 2e18 = 2e17 (mgmt), 10% of 1e18 (yield) = 1e17 should give ~3e17 in fees console.log("assets received by feeRecipient",vault.convertToAssets(vault.balanceOf(feeRecipient))); }
manual audit, vs code, forge
diff --git a/src/vault/Vault.sol b/src/vault/Vault.sol index 7a8f941..1799a06 100644 --- a/src/vault/Vault.sol +++ b/src/vault/Vault.sol @ -488,7 +488,7 @@ contract Vault is if (managementFee > 0) feesUpdatedAt = block.timestamp; if (totalFee > 0 && currentAssets > 0) - _mint(feeRecipient, convertToShares(totalFee)); + _mint(feeRecipient, totalFee.mulDiv(totalSupply(),currentAssets - totalFee,Math.Rounding.Up)); _; }
Some explanation of the equation totalFee.mulDiv(totalSupply(),currentAssets - totalFee,Math.Rounding.Up)
:
you get the increase in shares you need from this equation, which is the end state you want to achieve:
Which you can re-arrange to:
which gives you the equation in the code above:
#0 - c4-judge
2023-02-16T07:31:08Z
dmvt marked the issue as duplicate of #491
#1 - c4-sponsor
2023-02-18T12:09:19Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-28T17:19:09Z
dmvt marked the issue as satisfactory
🌟 Selected for report: rvierdiiev
Also found by: Lirios, Ruhum, ayeslick, bin2chen, critical-or-high, hansfriese, hashminer0725, immeas, jasonxiale, mookimgo
36.7818 USDC - $36.78
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L540-L546
Popcorn vaults have a variety of fees that can be configured. Once deployed, the owner of the vault has the ability to change the fees with a timelock, 3 days
by default. When the timelock has passed anyone can apply the changes.
The issue is that the call to change the fees does not check that there are any proposed fees:
File: vault/Vault.sol 540: function changeFees() external { 541: if (block.timestamp < proposedFeeTime + quitPeriod) 542: revert NotPassedQuitPeriod(quitPeriod); 543: 544: emit ChangedFees(fees, proposedFees); 545: fees = proposedFees; 546: }
proposedFeeTime
is initialized to 0
so proposedFeeTime + quitPeriod
is 1970-01-03
. proposedFees
is also initialized to 0
hence calling changeFees
after deploy, before any proposed fee changes are done, will set all fees to 0
.
This can be fixed by proposing a new fee, but that has to wait quitPeriod
(default 3 days
) until it can be applied.
Anyone can set all fees to 0
after deploy. This can be fixed after quitPeriod
which by default is 3 days
but can be changed to 1 day
. Until then fees will be 0
.
PoC test in Vault.t.sol
:
function test__change_fees_after_deploy() public { // must jump forward a bit since test timestamp starts at 0 vm.warp(block.timestamp + 3 days + 1); // create a vault with fees address vaultAddress = address(new Vault()); Vault _vault = Vault(vaultAddress); _vault.initialize( IERC20(address(asset)), IERC4626(address(adapter)), VaultFees({ deposit: 1e17, withdrawal: 1e17, management: 1e17, performance: 1e17 }), feeRecipient, address(this) ); // fees before (uint64 deposit, uint64 withdrawal, uint64 management, uint64 performance) = _vault.fees(); assertEq(1e17,deposit); assertEq(1e17,withdrawal); assertEq(1e17,management); assertEq(1e17,performance); // anyone can call change fees and set them to 0 vm.prank(alice); _vault.changeFees(); // all fees are set to 0 after (deposit, withdrawal, management, performance) = _vault.fees(); assertEq(0,deposit); assertEq(0,withdrawal); assertEq(0,management); assertEq(0,performance); }
manual audit, vs code, forge
Check that propsedFeeTime != 0
and set it to 0
after fees are changed:
diff --git a/src/vault/Vault.sol b/src/vault/Vault.sol index 7a8f941..d7fc47d 100644 --- a/src/vault/Vault.sol +++ b/src/vault/Vault.sol @@ -538,11 +538,15 @@ contract Vault is /// @notice Change fees to the previously proposed fees after the quit period has passed. function changeFees() external { + if (proposedFeeTime == 0) + revert("No proposed fees"); // replace with a proper error + if (block.timestamp < proposedFeeTime + quitPeriod) revert NotPassedQuitPeriod(quitPeriod); emit ChangedFees(fees, proposedFees); fees = proposedFees; + proposedFeeTime = 0; } /**
#0 - c4-judge
2023-02-16T08:09:45Z
dmvt marked the issue as duplicate of #78
#1 - c4-sponsor
2023-02-18T12:16:47Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:25:21Z
dmvt marked the issue as satisfactory
🌟 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
35.4779 USDC - $35.48
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L659
If a user wants to withdraw their permit there's no way currently.
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L88
There's validation when changing them but not creating the vault. possibly you could be stuck with bad fee configuration for three days
Vault::withdraw
triggers syncFeeCheckpoint
but not Vault::redeem
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L257
Either none of them should trigger or both of them.
vault
is not used
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L390
remove if not used
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L448
the state variable highWaterMark
is mistakenly used instead of highWaterMark_
in the code snippet:
File: Vault.sol 453: performanceFee > 0 && shareValue > highWaterMark 454: ? performanceFee.mulDiv( 455: (shareValue - highWaterMark) * totalSupply(), 456: 1e36, 457: Math.Rounding.Down 458: ) 459: : 0;
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/DeploymentController.sol#L60
adds a new template not new category and caller must not be owner at all
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L425
calculation is done based on seconds not minutes
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L181
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L274
caller is canCreate not owner
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardEscrow.sol#L98
duration
is not an amount
id
from MultiRewardEscrow::lock
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardEscrow.sol#L88-L130
#0 - c4-judge
2023-02-28T14:19:30Z
dmvt marked the issue as grade-b