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: 54/169
Findings: 4
Award: $239.37
π Selected for report: 0
π Solo Findings: 0
π Selected for report: waldenyan20
Also found by: 0xRobocop, KIntern_NA, Ruhum, cccz, hansfriese, mert_eren, minhtrng, peanuts
185.0021 USDC - $185.00
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L296-L315 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L408
Reward speed will be calculated incorrectly which will affect rewardsEndTimestamp
.
changeRewardSpeed()
is called by the owner when the owner intends to change the reward speed.
function changeRewardSpeed(IERC20 rewardToken, uint160 rewardsPerSecond) external onlyOwner { RewardInfo memory rewards = rewardInfos[rewardToken]; if (rewardsPerSecond == 0) revert ZeroAmount(); if (rewards.lastUpdatedTimestamp == 0) revert RewardTokenDoesntExist(rewardToken); if (rewards.rewardsPerSecond == 0) revert RewardsAreDynamic(rewardToken); _accrueRewards(rewardToken, _accrueStatic(rewards)); uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, remainder ); rewardInfos[rewardToken].rewardsPerSecond = rewardsPerSecond; rewardInfos[rewardToken].rewardsEndTimestamp = rewardsEndTimestamp; }
The function takes the balance of the MultiRewardStaking contract and puts into the variable remainder
. This variable checks how much rewards are left in the contract and tabulates the rewardsEndTimestamp
accordingly. However, the function naively assumes that all users will call claimRewards()
to withdraw their rewards everytime. For example, if there is 10 ETH of reward tokens being given out at 0.1 ETH per second (as per the test conditions), then after 10 seconds there should be 9 ETH of rewards being given out. However, if no one claims the 1 ETH of reward, the balance in the contract will still stay at 10 ETH. When changeRewardSpeed()
is called, the remainder
will still be 10 ETH instead of 9 ETH, which affects the calculation of rewardsEndTimestamp
.
When remainder
is added to _calcRewardsEnd, if remainder
does not change at all (no one claim rewards), then end time stamp will not be calculated correctly.
function _calcRewardsEnd( uint32 rewardsEndTimestamp, uint160 rewardsPerSecond, uint256 amount ) internal returns (uint32) { if (rewardsEndTimestamp > block.timestamp) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); }
return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32();
block.timestamp + (full amount / altered rewards per second), end timestamp will be greater than what is should be.
VSCode
Recommend to use the rewardInfos[_rewardToken].index instead in _accrueRewards
because this Index is always up to date, whether the user claims their rewards or not.
#0 - c4-judge
2023-02-16T03:56:17Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:59:00Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T15:30:34Z
dmvt marked the issue as not a duplicate
#3 - c4-judge
2023-02-23T15:30:46Z
dmvt marked the issue as duplicate of #191
#4 - c4-judge
2023-02-23T22:27:38Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2023-02-25T14:48:45Z
dmvt changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-25T15:12:07Z
dmvt marked the issue as partial-50
#7 - c4-judge
2023-03-01T00:29:51Z
dmvt marked the issue as full credit
#8 - c4-judge
2023-03-01T00:45:38Z
dmvt marked the issue as satisfactory
π 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
Users can claim more rewards than they are expected to claim.
In MultiRewardStaking#claimRewards(), the user loops through a bunch of ERC20 reward tokens and claims the relevant amount for each token. The amount that the user claims is accounted in rewardAmount = accruedRewards[user][_rewardTokens[i]]. If there is no escrow, this rewardAmount will be transferred from the contract to the user. The rewardAmount variable is then set to zero.
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; } }
This sort of contract writing fails to consider the CEI (check, effects, interaction) pattern. The accruedRewards[user][_rewardTokens[i]] is set to zero at the last part. During the transfer hook, the user can call an attack contract which loops through the claimRewards function again. Because accruedRewards[user][_rewardTokens[i]] is not set to 0 yet, the user can withdraw the rewardAmount again and again until the contract funds is drained. This is a common reentrancy exploit.
} else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } accruedRewards[user][_rewardTokens[i]] = 0;
Once again, the accruedRewards[user][_rewardTokens[i]] should be set to 0 first before the transfer happens.
Reference: https://hackernoon.com/hack-solidity-reentrancy-attack
Manual Review
Add a reentrancy guard or set accruedRewards[user][_rewardTokens[i]] = 0 before the transfer pattern.
#0 - c4-judge
2023-02-16T07:38:28Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:10:47Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-02-18T12:11:49Z
RedVeil marked the issue as disagree with severity
#3 - c4-judge
2023-02-23T00:52:51Z
dmvt marked the issue as partial-50
#4 - c4-judge
2023-03-01T00:29:30Z
dmvt marked the issue as full credit
#5 - c4-judge
2023-03-01T00:45:59Z
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
The calculation of exchange rate for shares in Popcorn Vault is done by dividing the total supply of shares by the totalAssets of the vault. The first depositor can mint a very small number of shares, then donate to the vault to manipulate the share price. When subsequent depositors deposit, they will lose value due to precision loss. This is a common attack vector for almost all shares based liquidity pool contracts using ERC4626.
First depositor can manipulate shares from later users; later users will not get equivalent shares when converting their underlying asset.
Malicious user Alice can deposit() with 1 wei of asset token to get 1 wei of shares.
Next, Alice sends 10000e18 -1 of asset tokens and inflate the price per share from 1 to 1e22.
Subsequent depositor who deposits shares, eg 19999e18 of assets, will only receive 1 wei of shares token.
Victim will lose 9999e18 if they redeem() right after deposit() due to precision loss.
Relatable issue:
Manual Review
Consider requiring a minimum amount of share tokens to be minted for the first minter or follow Uniswap V2 which mints 10,000 share first to balance liquidity whenever someone wants to open a vault.
#0 - c4-judge
2023-02-16T03:33:12Z
dmvt marked the issue as duplicate of #15
#1 - c4-sponsor
2023-02-18T11:54:58Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:35:47Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2023-02-23T00:37:03Z
dmvt marked the issue as partial-50
#4 - c4-judge
2023-03-01T00:42:38Z
dmvt marked the issue as satisfactory