Platform: Code4rena
Start Date: 16/12/2021
Pot Size: $100,000 USDC
Total HM: 21
Participants: 25
Period: 7 days
Judge: alcueca
Total Solo HM: 12
Id: 66
League: ETH
Rank: 1/25
Findings: 8
Award: $14,768.76
π Selected for report: 7
π Solo Findings: 3
1434.4958 USDC - $1,434.50
kenzo
StabilityPool's receiveCollateral
should only be called by ActivePool, but that check is missing. Anybody can call it and update StabilityPool's total collateral variable.
Wrong amounts of total collateral in StabilityPool (totalColl
).
As far as I can see, this value is only used in view functions.
receiveCollateral
: (notice comment and lack of caller check) (Code ref)
// Should be called by ActivePool // __after__ collateral is transferred to this contract from Active Pool function receiveCollateral(address[] memory _tokens, uint256[] memory _amounts) external override { totalColl.amounts = _leftSumColls(totalColl, _tokens, _amounts); emit StabilityPoolBalancesUpdated(_tokens, _amounts); }
The rest of the pools do check the caller on this function.
Add _requireCallerIsActivePool()
to the function.
#0 - kingyetifinance
2022-01-06T06:55:57Z
@LilYeti: Duplicate with #74 and should be severity level 3
π Selected for report: kenzo
5312.9473 USDC - $5,312.95
kenzo
WJLP doesn't update the inner accounting (for JOE rewards) when unwrapping user's tokens. The user will continue to receive rewards, on the expanse of users who haven't claimed their rewards yet.
Loss of yield for users.
The unwrap function just withdraws JLP from MasterChefJoe, burns the user's WJLP, and sends the JLP back to the user. It does not update the inner accounting (userInfo
). (Code ref)
function unwrapFor(address _to, uint _amount) external override { _requireCallerIsAPorSP(); _MasterChefJoe.withdraw(_poolPid, _amount); // msg.sender is either Active Pool or Stability Pool // each one has the ability to unwrap and burn WAssets they own and // send them to someone else _burn(msg.sender, _amount); JLP.transfer(_to, _amount); }
Need to keep userInfo updated. Have to take into consideration the fact that user can choose to set the reward claiming address to be a different account than the one that holds the WJLP.
#0 - kingyetifinance
2022-01-07T08:00:02Z
@LilYeti : Due to the specific issue mentioned here, it is actually different than #141 . If indeed the token is transferred before sending it to the protocol, it will be accepted as collateral but will never be able to leave the system, via liquidation, redemption, and should not have been able to leave except the flawed implementation of unwrap for in borrower operations allows this to be withdrawn. Large error nonetheless, recommend upgrading to severity 3.
π Selected for report: kenzo
Also found by: UncleGrandpa925
2390.8263 USDC - $2,390.83
kenzo
After updating user's rewards in _userUpdate
, if the user has not claimed them, and _userUpdate
is called again (eg. on another wrap
), the user's unclaimed rewards will lose the previous unclaimed due to wrong calculation.
Loss of yield for user.
When updating the user's unclaimedJoeReward, the function doesn't save it's previous value. (Code ref)
if (user.amount > 0) { user.unclaimedJOEReward = user.amount.mul(accJoePerShare).div(1e12).sub(user.rewardDebt); } if (_isDeposit) { user.amount = user.amount.add(_amount); } else { user.amount = user.amount.sub(_amount); } // update for JOE rewards that are already accounted for in user.unclaimedJOEReward user.rewardDebt = user.amount.mul(accJoePerShare).div(1e12);
So for example, rewards can be lost in the following scenario. We'll mark "acc1" for the value of "accJoePerShare" at step 1.
_userUpdate
is called: unclaimedJOEReward = 0, rewardDebt = 100*acc1.Change the unclaimed rewards calculation to:
user.unclaimedJOEReward = user.unclaimedJOEReward.add(user.amount.mul(accJoePerShare).div(1e12).sub(user.rewardDebt));
#0 - kingyetifinance
2022-01-07T07:53:54Z
@LilYeti : Probably severity 3 due to loss of allocation of funds for other users wrapping LP tokens.
#1 - 0xtruco
2022-01-16T10:45:18Z
Actually not a problem, the accrued reward is updated as time goes on and should be overridden. Follows same logic from Master chef v2 here: https://snowtrace.io/address/0xd6a4F121CA35509aF06A0Be99093d08462f53052#code
#2 - alcueca
2022-01-17T10:00:19Z
@0xtruco, are you sure of what you are saying? To me the issue seems to still exist. Could you elaborate?
#3 - 0xtruco
2022-01-19T02:13:09Z
@alcueca Yes you are correct this actually is an issue. I initially thought that we were harvesting rewards just like TJ when users wrapped tokens but turns out that line is not there. This is in the new version of our code but was not in the version submitted at the time of the contest. Thanks for looking into it more! Back to high risk.
kenzo
WJLP's wrap
takes _from
and _to
as parameter, and doesn't check that msg.sender=_from
.
This means that anybody can claim to himself tokens that a user approved for WJLP.
Loss of user funds.
The problem is in the wrap
function. (Code ref)
function wrap(uint _amount, address _from, address _to, address _rewardOwner) external override { JLP.transferFrom(_from, address(this), _amount); ... _mint(_to, _amount); }
No check is being done on from\to parameters.
After a user has .approve
d WJLP, a malicious user can call wrap(_amount, fromLegitimateUser, toMaliciousUser, toMaliciousUser)
and gain the legitimate user's tokens.
Probably remove _from
from the parameters, and wrap only msg.sender
's tokens.
#0 - kingyetifinance
2022-01-07T06:34:12Z
@LilYeti : Duplicate #58
#1 - alcueca
2022-01-15T06:44:38Z
Duplicate #208
π Selected for report: kenzo
1593.8842 USDC - $1,593.88
kenzo
WJLP can only be unwrapped from the Active Pool or Stability Pool. A user who decided to wrap his JLP, but not use all of them in a trove, Wouldn't be able to just unwrap them.
Impaired functionality for users. Would have to incur fees for simple unwrapping.
The unwrap functionality is only available from unwrapFor
function, and that function is only callable from AP or SP. (Code ref)
function unwrapFor(address _to, uint _amount) external override { _requireCallerIsAPorSP();
Allow anybody to call the function. As it will burn the holder's WJLP, a user could only unwrap tokens that are not in use.
#0 - kingyetifinance
2022-01-05T06:59:22Z
Added unwrap function
π Selected for report: kenzo
1593.8842 USDC - $1,593.88
kenzo
When ActivePool sends collateral which is a wrapped asset, it first unwraps the asset, and only after that updates the rewards.
This should be done in opposite order. As a comment in WJLP's unwrapFor
rightfully mentions - "Prior to this being called, the user whose assets we are burning should have their rewards updated".
Lost yield for user.
In ActivePool's sendCollateralsUnwrap
(which is used throughout the protocol), it firsts unwraps the asset, and only afterwards calls claimRewardFor
which will update the rewards:
(Code ref)
IWAsset(_tokens[i]).unwrapFor(_to, _amounts[i]); if (_collectRewards) { IWAsset(_tokens[i]).claimRewardFor(_to); }
claimRewardFor
will end up calling _userUpdate
: (Code ref)
function _userUpdate(address _user, uint256 _amount, bool _isDeposit) private returns (uint pendingJoeSent) { uint256 accJoePerShare = _MasterChefJoe.poolInfo(_poolPid).accJoePerShare; UserInfo storage user = userInfo[_user]; if (user.amount > 0) { user.unclaimedJOEReward = user.amount.mul(accJoePerShare).div(1e12).sub(user.rewardDebt); } if (_isDeposit) { user.amount = user.amount.add(_amount); } else { user.amount = user.amount.sub(_amount); } user.rewardDebt = user.amount.mul(accJoePerShare).div(1e12); }
Now, as ActivePool has already called unwrapFor
and has burnt the user's tokens, and let's assume they all were used as collateral, it means user.amount=0*, and the user's unclaimedJOEReward won't get updated to reflect the rewards from the last user update.
This is why, indeed as the comment in unwrapFor
says, user's reward should be updated prior to that.
*Note: at the moment unwrapFor
doesn't updates the user's user.amount, but as I detailed in another issue, that's a bug, as that means the user will continue accruing rewards even after his JLP were removed from the protocol.
Change the order of operations in sendCollateralsUnwrap
to first send the updated rewards and then unwrap the asset.
You can also consider adding to the beginning of unwrapFor
a call to _userUpdate(_to, 0, true)
to make sure the rewards are updated before unwrapping.
Note: as user can choose to have JOE rewards accrue to a different address than the address that uses WJLP as collateral, you'll have to make sure you update the current accounts. I'll detail this in another issue.
#0 - kingyetifinance
2022-01-05T07:54:24Z
@LilYeti: Thanks for the thorough explanation.
π Selected for report: defsec
Also found by: 0x1f8b, Jujic, WatchPug, broccolirob, certora, cmichel, csanuragjain, hyh, jayjonah8, kenzo, robee, sirhashalot
kenzo
Some ERC20 tokens only return false and do not revert on failed transfer
or transferForm
.
Yeti mostly handles this, but not in all places.
Wouldn't be able to easily tell if transfer succeeded.
When YetiFinanceTreasury sendsToken to the team wallet, it doesn't check whether the transfer succeeded. (Code ref)
function sendToken(IERC20 _token, address _to, uint _amount) external onlyTeam { _token.transfer(_to, _amount); }
Add a return value to the function and make sure the transfer succeeded.
#0 - kingyetifinance
2022-01-05T10:10:51Z
Duplicate #116
#1 - alcueca
2022-01-15T06:03:02Z
For now, a duplicate of #1
#2 - alcueca
2022-01-15T07:30:33Z
Duplicate of #94
239.0826 USDC - $239.08
kenzo
When changing collateral's ratio, it is rightly checked to be smaller than 110%. However when adding new collateral, the ratio check is not there, so it can be added with ratio that is larger than 110%.
Accidentally adding an asset with larger ratio would result in users being able to withdraw more YUSD than supplied VC.
When an asset is being added, there is no sanity check that the ratio is within the correct range. (Code ref)
This is unlike changeRatio
, which validates that the new ratio is in correct range. (Code ref)
require(_ratio < 1100000000000000000, "ratio must be less than 1.10 => greater than 1.1 would mean taking out more YUSD than collateral VC");
Add the same ratio check to addCollateral
.
#0 - 0xtruco
2022-01-11T11:39:05Z
π Selected for report: kenzo
531.2947 USDC - $531.29
kenzo
LockupContract, LockupContractFactory amd ShortLockupContract all have comments that say:
Within the first year from deployment, the deployer of the YETIToken (Liquity AG's address) may transfer YETI only to valid LockupContracts, and no other addresses (this is enforced in YETIToken.sol's transfer() function). The above two restrictions ensure that until one year after system deployment, YETI tokens originating from Liquity AG cannot enter circulating supply and cannot be staked to earn system revenue.
This comment is outdated (verified with sponsor). There is no such lockup on YETI tokens issued to team/treasury. (There might be other type of vesting which is probably implemented using TeamLockup.)
Confusion, wrong description of team's capability to use yeti tokens issued.
Remove outdated comments.
#0 - kingyetifinance
2022-01-05T17:52:51Z
@LilYeti: This is a comment error, is realistically risk 0.
#1 - 0xtruco
2022-01-12T01:16:17Z
#2 - alcueca
2022-01-15T16:35:26Z
Comment errors are low severity
143.4496 USDC - $143.45
kenzo
ERC20_8 which is inherited by WJLP doesn't update totalSupply upon mint/burn.
totalSupply()
will always return 0.
The mint and burn functions do not increase total supply. (Code ref) The only other place in the contract which uses totalSupply is the getter.
Keep totalSupply up to date within mint and burn functions.
#0 - alcueca
2022-01-14T21:48:38Z
Duplicate of #259
π Selected for report: kenzo
531.2947 USDC - $531.29
kenzo
TeamLockup mentions on "vestingLength" that it is the "number of YETI that are claimable every day after vesting starts". However, the vesting calculation treats it as if was the number of YETI that are claimable every second, not every day.
Tokens would be released faster than planned. Or, if the tokens are planned to be released every second and not every day (I'm guessing it's less likely), then this is a wrong comment.
The description of vestingLength
: (Code ref)
uint immutable vestingLength; // number of YETI that are claimable every day after vesting starts
The calculation to decide how many tokens can be released: (Code ref)
uint timePastVesting = block.timestamp.sub(vestingStart); uint available = _min(totalVest,(totalVest.mul(timePastVesting)).div(vestingLength));
The problem is that timePastVesting
is in seconds, and vestingLength
is in days.
Divide the calculation by 1 day
to align the units.
#0 - kingyetifinance
2022-01-06T06:14:47Z
@LilYeti: The comment was incorrect, but this could have produced problems. Recommend security level 1 though.
#1 - alcueca
2022-01-15T16:34:51Z
Issues with comments are low severity, which is usually excessive. However in this case, low severity fits perfectly.
17.7859 USDC - $17.79
kenzo
ERC20_8 uses Solidity 0.8.7, which already contains built in safe math operations. So usage of SafeMath is not needed.
Unnecessary gas.
Uses version 0.8.7: (Code ref)
pragma solidity 0.8.7;
But also using SafeMath for some of the math operations: (Code ref 1), 2)
using SafeMath for uint256;
_approve(msg.sender, spender, allowed[msg.sender][spender].add(addedValue));
Remove SafeMath and it's operations.
#0 - kingyetifinance
2022-01-06T07:58:59Z
@LilYeti: duplicate #53
#1 - alcueca
2022-01-15T06:55:57Z
Duplicate #246