Platform: Code4rena
Start Date: 03/02/2022
Pot Size: $75,000 USDC
Total HM: 42
Participants: 52
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 21
Id: 83
League: ETH
Rank: 3/52
Findings: 9
Award: $5,370.92
🌟 Selected for report: 2
🚀 Solo Findings: 1
The MasterChef.add
function changes the total pool allocation but does not update other pools first.
When other pools are finally updated at some point, then accConcurPerShare
will be wrongly computed with their smaller allocPoint / newTotalAllocPoint
share even for the period before the new allocation was set.
Users will receive fewer rewards than they should receive after an add
call.
Perform a massUpdatePool
in add
before changing totalAllocPoint
.
#0 - r2moon
2022-02-18T01:10:52Z
duplicated with https://github.com/code-423n4/2022-02-concur-findings/issues/219
#1 - GalloDaSballo
2022-04-17T16:25:02Z
Duplicate of #219
🌟 Selected for report: mtz
Also found by: 0x1f8b, Czar102, GalloDaSballo, GeekyLumberjack, Randyyy, Rhynorater, Ruhum, ShadowyNoobDev, bitbopper, cccz, cmichel, csanuragjain, danb, gzeon, hickuphh3, hyh, leastwood
31.0722 USDC - $31.07
The Sheler.withdraw
function sets the claimed[_token][user]
field but does not check if the user is allowed to claim by checking require(!claimed[_token][user], "already claimed")
.
function withdraw(IERC20 _token, address _to) external override { require(activated[_token] != 0 && activated[_token] + GRACE_PERIOD < block.timestamp, "shelter not activated"); // @audit doesn't even require claimed[_token][_to] == false uint256 amount = savedTokens[_token] * client.shareOf(_token, msg.sender) / client.totalShare(_token); claimed[_token][_to] = true; emit ExitShelter(_token, msg.sender, _to, amount); _token.safeTransfer(_to, amount); }
An attacker can withdraw
tokens several times.
Add a require(!claimed[_token][_to] = true, "already claimed")
check. (Actually require(!claimed[_token][msg.sender])
, see next issue.)
#0 - r2moon
2022-02-18T01:06:24Z
#1 - GalloDaSballo
2022-04-19T01:23:18Z
Dup of #246
🌟 Selected for report: WatchPug
Also found by: cmichel, harleythedog, hickuphh3, kirk-baird, leastwood
387.0982 USDC - $387.10
The ConvexStakingWrapper._calcRewardIntegral
function makes the d_reward = IERC20(reward.token).balanceOf(address(this)); - reward.remaining
amount available for claiming.
Then it updates the reward.remaining
value to the balance before the distribution.
RewardType memory reward = rewards[_pid][_index]; //get difference in balance and remaining rewards //getReward is unguarded so we use remaining to keep track of how much was actually claimed uint256 bal = IERC20(reward.token).balanceOf(address(this)); uint256 d_reward = bal - reward.remaining; // ... IERC20(reward.token).transfer(address(claimContract), d_reward); // ... //update remaining reward here since balance could have changed if claiming if (bal != reward.remaining) { reward.remaining = uint128(bal); } rewards[_pid][_index] = reward;
It's unclear what the reasoning is but it leads to delayed distributions and breaks withdrawals as uint256 d_reward = bal - reward.remaining;
will underflow next time.
_calcRewardIntegral
is called, bal = IERC20(reward.token).balanceOf(address(this)) := INITIAL_BALANCE
, d_reward = bal - 0 = INITIAL_BALANCE
.d_reward
is transferred out of the contractreward.remaining = INITIAL_BALANCE
is set.withdraw
tokens. It calls _checkpoint(_pid, msg.sender)
which calls _calcRewardIntegral()
which computes d_reward = IERC20(reward.token).balanceOf(address(this)) - INITIAL_BALANCE
which will most likely underflow now as the current balance is 0 and thus < reward.remaining = INITIAL_BALANCE
.Clarify how the reward.remaining
variable is supposed to work and fix it.
#0 - GalloDaSballo
2022-04-20T15:34:13Z
Dup of #199
The Sheler.withdraw
function sets the claimed[_token][user]
field but uses the shares of msg.sender
.
An attacker can withdraw
tokens several times passing different _to
addresses, each time the msg.sender
's shares will be used to receive tokens.
function withdraw(IERC20 _token, address _to) external override { require(activated[_token] != 0 && activated[_token] + GRACE_PERIOD < block.timestamp, "shelter not activated"); // @audit uses `msg.sender`'s share but sets `claimed` for _to! can claim for many `_to`s uint256 amount = savedTokens[_token] * client.shareOf(_token, msg.sender) / client.totalShare(_token); claimed[_token][_to] = true; emit ExitShelter(_token, msg.sender, _to, amount); _token.safeTransfer(_to, amount); }
Change withdraw
to:
function withdraw(IERC20 _token, address _to) external override { require(activated[_token] != 0 && activated[_token] + GRACE_PERIOD < block.timestamp, "shelter not activated"); + require(!claimed[_token][msg.sender], "already claimed"); uint256 amount = savedTokens[_token] * client.shareOf(_token, msg.sender) / client.totalShare(_token); - claimed[_token][_to] = true; + claimed[_token][msg.sender] = true; emit ExitShelter(_token, msg.sender, _to, amount); _token.safeTransfer(_to, amount); }
#0 - r2moon
2022-02-18T08:42:56Z
#1 - GalloDaSballo
2022-04-19T14:03:03Z
Duplicate of #103
🌟 Selected for report: leastwood
Also found by: cmichel, kirk-baird
1061.9978 USDC - $1,062.00
The ConvexStakingWrapper
contract uses several reward pool tokens rewards[_pid][_index].token
and it can be that the same token is used for different _pid
s.
Indeed, the CVX
/CRV
tokens are always at index 0 and 1.
The rewards will be distributed to the first pool id (_pid
) calling checkpoint() -> _calcRewardIntegral()
.
The other pools don't receive any rewards.
function _calcRewardIntegral( uint256 _pid, uint256 _index, address _account, uint256 _balance, uint256 _supply ) internal { RewardType memory reward = rewards[_pid][_index]; // @audit first pid to call this receives the current reward token balance uint256 bal = IERC20(reward.token).balanceOf(address(this)); // ... uint256 d_reward = bal - reward.remaining; IERC20(reward.token).transfer(address(claimContract), d_reward); // ... }
If several pools use the same reward token, it should be distributed fairly among these pools, similar to the allocation points in a MasterChef
contract.
#0 - GalloDaSballo
2022-04-19T01:21:36Z
Dup of #146
The StakingRewards.rewardToken
can be recovered by calling recoverERC20(stakingRewards.rewardToken, stakingRewards.rewardToken.balanceOf(stakingRewards))
.
It only checks that tokenAddress != stakingToken
.
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner { require( tokenAddress != address(stakingToken), "Cannot withdraw the staking token" ); IERC20(tokenAddress).safeTransfer(owner(), tokenAmount); emit Recovered(tokenAddress, tokenAmount); }
Note that this contract seems to keep the rewards in the contract as the notifyRewardAmount
function has code restricting the rewardRate
by balance = rewardsToken.balanceOf(address(this));
.
The owners can steal the users' rewards.
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner { require( + tokenAddress != address(rewardToken) && tokenAddress != address(stakingToken), - "Cannot withdraw the staking token" + "Cannot withdraw the staking and reward tokens" ); IERC20(tokenAddress).safeTransfer(owner(), tokenAmount); emit Recovered(tokenAddress, tokenAmount); }
#0 - r2moon
2022-02-18T01:01:38Z
duplicated with https://github.com/code-423n4/2022-02-concur-findings/issues/210
#1 - GalloDaSballo
2022-04-04T23:23:25Z
Duplicate of #210
215.0546 USDC - $215.05
The Sheler.donate
function transferFrom
s _amount
and adds the entire _amount
to savedTokens[_token]
.
But the actual received token amount from the transfer can be less for fee-on-transfer tokens.
The last person to withdraw will not be able to as withdraw
uses a share computation for the entire savedTokens[_token]
amount.
The calculated amount
will then be higher than the actual contract balance.
function donate(IERC20 _token, uint256 _amount) external { require(activated[_token] != 0, "!activated"); savedTokens[_token] += _amount; // @audit fee-on-transfer. then fails for last person in `withdraw` _token.safeTransferFrom(msg.sender, address(this), _amount); } function withdraw(IERC20 _token, address _to) external override { // @audit percentage on storage var, not on actual balance uint256 amount = savedTokens[_token] * client.shareOf(_token, msg.sender) / client.totalShare(_token); // @audit amount might not be in contract anymore as savedTokens[_token] is over-reported _token.safeTransfer(_to, amount); }
In donate
, add only the actual transferred amounts (computed by post-transfer balance - pre-transfer balance
) to savedTokens[_token]
.
#0 - GalloDaSballo
2022-04-04T23:30:04Z
The warden has identified a specific interaction between a feeOnTransfer
token and the Shelter Contract.
Because the Sheleter Contract can receive any token, and anyone could claim them based on percentage, and because some people will loose the ability to claim due to the internal accounting being incorrect, I believe that in this instance the finding is valid, and of medium severity
530.9989 USDC - $531.00
The MasterChef.deposit
function does not transferFrom
the _amount
token amount from the caller.
It's unclear if there are other callers besides the ConvexStakingWrapper
as there could be multiple depositor judging from the isDepositor
mapping instead of a single address depositor
variable.
Make sure the contract is not used on its own and all depositors require some token commitment from the user.
#0 - r2moon
2022-02-18T01:08:16Z
No need to transfer tokens to master chef. They will be handled in depositor contracts.
#1 - GalloDaSballo
2022-04-19T01:22:52Z
I appreciate the wardens considerations and believe they are worth entertaining
However this finding has no POC and no clear impact either, so the only thing I can do is mark as invalid
#2 - GalloDaSballo
2022-04-19T15:49:17Z
On second review this is dup of #208
Would recommend the warden to add more context to such findings and write a clear Risk as to avoid risking getting invalidated
🌟 Selected for report: cmichel
1179.9976 USDC - $1,180.00
The StakingRewards.notifyRewardAmount
function receives a reward
amount and extends the current reward end time to now + rewardsDuration
.
It rebases the currently remaining rewards + the new rewards (reward + leftover
) over this new rewardsDuration
period.
function withdraw(IERC20 _token, address _to) external override { require(activated[_token] != 0 && activated[_token] + GRACE_PERIOD < block.timestamp, "shelter not activated"); // @audit uses `msg.sender`'s share but sets `claimed` for _to! can claim for many `_to`s uint256 amount = savedTokens[_token] * client.shareOf(_token, msg.sender) / client.totalShare(_token); claimed[_token][_to] = true; emit ExitShelter(_token, msg.sender, _to, amount); _token.safeTransfer(_to, amount); }
This can lead to a dilution of the reward rate and rewards being dragged out forever by malicious new reward deposits.
Imagine the current rewardRate is 1000 rewards / rewardsDuration
.
20% of the rewardsDuration
passed, i.e., now = lastUpdateTime + 20% * rewardsDuration
.
A malicious actor notifies the contract with a reward of 0
: notifyRewardAmount(0)
.
Then the new rewardRate = (reward + leftover) / rewardsDuration = (0 + 800) / rewardsDuration = 800 / rewardsDuration
.
The rewardRate
just dropped by 20%.
This can be repeated infinitely.
After another 20% of reward time passed, they trigger notifyRewardAmount(0)
to reduce it by another 20% again:
rewardRate = (0 + 640) / rewardsDuration = 640 / rewardsDuration
.
Imo, the rewardRate
should never decrease by a notifyRewardAmount
call.
Consider not extending the reward payouts by rewardsDuration
on every call.
periodFinish
probably shouldn't change at all, the rewardRate
should just increase by rewardRate += reward / (periodFinish - block.timestamp)
.
Alternatively, consider keeping the rewardRate
constant but extend periodFinish
time by += reward / rewardRate
.
#0 - r2moon
2022-02-18T01:03:49Z
notifyRewardAmount check msg.sender's permission.
#1 - GalloDaSballo
2022-04-19T20:45:07Z
The warden is pointing out an admin privilege that would allow the admin to dilute current rewards.
While the sponsor claims this won't happen I can only judge based on the code that is available to me.
And at this point there seems to be no code for the rewardsDistribution
contract that would be calling notifyRewardAmount
Given this, I believe the finding to be valid as the POC works out to demonstrate how a malicious owner could dilute the rewardRate.
This would cause loss of yield for all depositors, which makes the finding of Medium Severity