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: 3/25
Findings: 9
Award: $11,087.86
🌟 Selected for report: 10
🚀 Solo Findings: 4
🌟 Selected for report: cmichel
1593.8842 USDC - $1,593.88
cmichel
The sYETIToken.lastBuyBackPrice
is set in buyBack
and hardcoded as:
function buyBack(address routerAddress, uint256 YUSDToSell, uint256 YETIOutMin, address[] memory path) external onlyOwner { require(YUSDToSell > 0, "Zero amount"); require(lastBuybackTime + 69 hours < block.timestamp, "Must have 69 hours pass before another buyBack"); yusdToken.approve(routerAddress, YUSDToSell); uint256[] memory amounts = IRouter(routerAddress).swapExactTokensForTokens(YUSDToSell, YETIOutMin, path, address(this), block.timestamp + 5 minutes); lastBuybackTime = block.timestamp; // amounts[0] is the amount of YUSD that was sold, and amounts[1] is the amount of YETI that was gained in return. So the price is amounts[0] / amounts[1] // @audit this hardcoded lastBuybackPrice is wrong when using a different path (think path length 3) lastBuybackPrice = div(amounts[0].mul(1e18), amounts[1]); emit BuyBackExecuted(YUSDToSell, amounts[0], amounts[1]); }
It divides the first and second return amounts
of the swap, however, these amounts depend on the swap path
parameter that is used by the caller.
If a swap path of length 3 is used, then this is obviously wrong.
It also assumes that each router sorts the pairs the same way (which is true for Uniswap/Sushiswap).
The lastBuyBackPrice
will be wrong when using a different path.
This will lead rebase
s using a different yeti amount and the effectiveYetiTokenBalance
being updated wrong.
Verify the first and last element of the path are YETI/YUSD and use the first and last amount parameter.
#0 - kingyetifinance
2022-01-05T10:01:54Z
@LilYeti: The idea was that on launch we will likely use a curve pool to route through so this contract would change slightly. However it is valid and some more checks would be good to add. Moving to level 1 issue.
#1 - alcueca
2022-01-15T16:45:55Z
A medium severity rating is warranted.
🌟 Selected for report: cmichel
Also found by: csanuragjain, gzeon
cmichel
It's possible to repeatedly add the first collateral token in validCollateral
through the Whitelist.addCollateral
function.
The validCollateral[0] != _collateral
check will return false and skip further checks.
Owner calls addCollateral(collateral=validCollateral[0])
:
function addCollateral( address _collateral, uint256 _minRatio, address _oracle, uint256 _decimals, address _priceCurve, bool _isWrapped ) external onlyOwner { checkContract(_collateral); checkContract(_oracle); checkContract(_priceCurve); // If collateral list is not 0, and if the 0th index is not equal to this collateral, // then if index is 0 that means it is not set yet. // @audit evaluates validCollateral[0] != validCollateral[0] which is obv. false => skips require check if (validCollateral.length != 0 && validCollateral[0] != _collateral) { require(collateralParams[_collateral].index == 0, "collateral already exists"); } validCollateral.push(_collateral); // overwrites parameters collateralParams[_collateral] = CollateralParams( _minRatio, _oracle, _decimals, true, _priceCurve, validCollateral.length - 1, _isWrapped ); }
The collateral parameters collateralParams
are re-initialized which can break the existing accounting.
The collateral token also exists multiple times in validCollateral
.
Fix the check. It should be something like:
if (validCollateral.length > 0) { require(collateralParams[_collateral].index == 0 && validCollateral[0] != _collateral, "collateral already exists"); }
#0 - kingyetifinance
2022-01-05T09:30:36Z
Duplicate with #142
#1 - alcueca
2022-01-14T21:43:57Z
Taking as main
🌟 Selected for report: cmichel
1593.8842 USDC - $1,593.88
cmichel
The ThreePieceWiseLinearPriceCurve.adjustParams
function uses three functions f1, f2, f3
where y_i = f_i(x_i)
.
It computes the y-axis intersect (b2 = f_2(0), b3 = f_3(0)
) for each of these but uses unsigned integers for this, which means these values cannot become negative.
This rules out a whole class of functions, usually the ones that are desirable.
Check out this two-piece linear interest curve of Aave:
Example:
Imagine a curve that is flat at 10%
on the first 50% utilization but shoots up to 110%
at 100% utilization.
m1 = 0, b1 = 10%, cutoff1 = 50%
m2 = 200%
=> b2 = m1 * cutoff1 + b1 - m2 * cutoff1 = f1(cutoff1) - m2 * cutoff1 = 10% - 200% * 50% = 10% - 100% = -90%
. (f2(100%) = 200% * 100% - 90% = 110%
✅)
This function would revert in the b2
computation as it underflows due to being a negative value.Most curves that are actually desired for a lending platform (becoming steeper at higher utilization) cannot be used.
Evaluate the piecewise linear function in a different way that does not require computing the y-axis intersection value.
For example, for cutoff2 >= x > cutoff1
, use f(x) = f_1(cutoff) + f_2(x - cutoff)
.
See Compound.
#0 - kingyetifinance
2022-01-05T09:33:40Z
@LilYeti: Great find.
#1 - 0xtruco
2022-01-12T01:36:16Z
Resolved in https://github.com/code-423n4/2021-12-yetifinance/pull/23 by adding negative possibility
🌟 Selected for report: cmichel
1593.8842 USDC - $1,593.88
cmichel
The ThreePieceWiseLinearPriceCurve.getFee
comment states that the total + the input must be less than the cap:
If dollarCap == 0, then it is not capped. Otherwise, then the total + the total input must be less than the cap.
The code only checks if the input is less than the cap:
// @param _collateralVCInput is how much collateral is being input by the user into the system if (dollarCap != 0) { require(_collateralVCInput <= dollarCap, "Collateral input exceeds cap"); }
Clarify the desired behavior and reconcile the code with the comments.
#0 - kingyetifinance
2022-01-05T09:47:20Z
@LilYeti: This was an issue also found by one of our independent economic auditors. Good find. Is actually more like a medium (severity 2) issue.
#1 - 0xtruco
2022-01-11T11:24:27Z
Fixed in line 92
🌟 Selected for report: cmichel
1593.8842 USDC - $1,593.88
cmichel
The ThreePieceWiseLinearPriceCurve.calculateDecayedFee
function is supposed to decay the lastFeePercent
over time.
This is correctly done in the decay > 0 && decay < decayTime
case, but for the decay > decayTime
case it does not decay at all but should set it to 0 instead..
if (decay > 0 && decay < decayTime) { // @audit if decay is close to decayTime, this fee will be zero. but below it'll be 1. the more time passes, the higher the decay. but then decay > decayTime should return 0. fee = lastFeePercent.sub(lastFeePercent.mul(decay).div(decayTime)); } else { fee = lastFeePercent; }
It seems wrong to handle the decay == 0
case (decay happened in same block) the same way as the decay >= decayTime
case (decay happened long time ago) as is done in the else
branch.
I believe it should be like this instead:
// decay == 0 case should be full lastFeePercent if(decay < decayTime) { fee = lastFeePercent.sub(lastFeePercent.mul(decay).div(decayTime)); } else { // reset to zero if decay >= decayTime fee = 0; }
#0 - kingyetifinance
2022-01-05T09:51:02Z
@LilYeti: Good find. The fee would be reset to not 0 in this case.
#1 - 0xtruco
2022-01-11T11:33:26Z
cmichel
The WJLP.wrap
function accepts a from
parameter and a to
parameter.
The tokens are transferred from the from
account to the to
account:
function wrap(uint _amount, address _from, address _to, address _rewardOwner) external override { // @audit can frontrun and steal => use from=victim, to=attacker JLP.transferFrom(_from, address(this), _amount); JLP.approve(address(_MasterChefJoe), _amount); // stake LP tokens in Trader Joe's. // In process of depositing, all this contract's // accumulated JOE rewards are sent into this contract _MasterChefJoe.deposit(_poolPid, _amount); // update user reward tracking _userUpdate(_rewardOwner, _amount, true); _mint(_to, _amount); }
When a user wants to wrap their JLP tokens, they first need to approve the contracts with their token and in a second transaction call the wrap
function.
However, an attacker can frontrun the actual wrap
function and call their own wrap(from=victim, to=attacker)
which will make the victim pay with their approved tokens but the WJLP are minted to the attacker.
WJLP tokens can be stolen.
Always transfer from msg.sender
instead of using a caller-provided from
parameter.
#0 - kingyetifinance
2022-01-07T06:33:48Z
@LilYeti : Duplicate #58
#1 - alcueca
2022-01-15T06:41:28Z
Taking as main
🌟 Selected for report: cmichel
Also found by: UncleGrandpa925
2390.8263 USDC - $2,390.83
cmichel
Calling WJLP.unwrap
burns WJLP, withdraws the amount from the master chef and returns the same amount of JLP back to the to
address.
However, it does not update the internal accounting in WJLP
with a _userUpdate
call.
This needs to be done on the caller side according to the comment in the WJLP.unwrap
function:
"Prior to this being called, the user whose assets we are burning should have their rewards updated"
This happens when being called from the StabilityPool
but not when being called from the ActivePool.sendCollateralsUnwrap
:
function sendCollateralsUnwrap(address _to, address[] memory _tokens, uint[] memory _amounts, bool _collectRewards) external override returns (bool) { _requireCallerIsBOorTroveMorTMLorSP(); require(_tokens.length == _amounts.length); for (uint i = 0; i < _tokens.length; i++) { if (whitelist.isWrapped(_tokens[i])) { // @audit this burns the tokens for _to but does not reduce their amount. so there are no tokens in WJLP masterchef but can keep claiming IWAsset(_tokens[i]).unwrapFor(_to, _amounts[i]); if (_collectRewards) { IWAsset(_tokens[i]).claimRewardFor(_to); } } else { _sendCollateral(_to, _tokens[i], _amounts[i]); // reverts if send fails } } return true; }
The unwrapFor
call withdraws the tokens from the Masterchef and pays out the user, but their user balance is never decreased by the withdrawn amount.
They can still use their previous balance to claim rewards through WJLP.claimReward
which updated their unclaimed joe reward according to the old balance.
Funds from the WJLP pool can be stolen.
As the comment says, make sure the user is updated before each unwrap
call.
It might be easier and safer to have a second authorized unwrapFor
function that accepts a rewardOwner
parameter, the user that needs to be updated.
#0 - kingyetifinance
2022-01-07T07:49:17Z
@LilYeti : This is indeed an issue which would cause loss of rewards from wrapper contract usage.
🌟 Selected for report: defsec
Also found by: 0x1f8b, Jujic, WatchPug, broccolirob, certora, cmichel, csanuragjain, hyh, jayjonah8, kenzo, robee, sirhashalot
11.5426 USDC - $11.54
cmichel
The ERC20.transfer()
, ERC20.transferFrom()
, ERC20.approve()
functions return a boolean value indicating success. This parameter needs to be checked for success.
Some tokens do not revert if the transfer failed but return false
instead.
Examples:
WJLP.wrap:
JLP.transferFrom(_from, address(this), _amount);
WJLP.wrap:
JLP.approve(address(_MasterChefJoe), _amount);
As the checks are only missing on specific JLP tokens that assumably revert on failed transfers, the impact is low.
We still recommend checking at least the return value as a best practice.
Consider using OpenZeppelin’s SafeERC20
versions with the safeTransfer
and safeTransferFrom
functions that handle the return value check as well as non-standard-compliant staking tokens.
#0 - kingyetifinance
2022-01-05T10:02:53Z
@LilYeti: Duplicate #1
#1 - kingyetifinance
2022-01-10T06:13:01Z
Fixed, nothing new compared to https://github.com/code-423n4/2021-12-yetifinance-findings/issues/94
#2 - alcueca
2022-01-15T07:31:03Z
Duplicate of #94
cmichel
The setAddresses
function that initializes important contract state can be called by anyone.
See:
WJLP.setAddresses
The attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract. In the best case for the victim, they notice it and have to redeploy their contract costing gas.
Use the constructor to initialize the addresses.
#0 - kingyetifinance
2022-01-05T17:36:40Z
Duplicate #105
cmichel
The BorrowerOperations.withdrawColl
function does not check that the _collsOut
token array is white-listed and unique before calling _adjustTrove
with a _requireValidDepositCollateral(params._collsOut)
call.
One can call withdrawColl
with duplicate tokens in the collateral array which can lead to unexpected issues in the _getNewPortfolio
call's _subColls
which assumes the _tokens
don't have duplicates.
// @audit-info _tokens is params._collsOut for (uint256 i = 0; i < _tokens.length; i++) { uint256 tokenIndex = whitelist.getIndex(_tokens[i]); require(coll3.amounts[tokenIndex] >= _amounts[i], "illegal sub"); coll3.amounts[tokenIndex] = coll3.amounts[tokenIndex].sub(_amounts[i]); if (coll3.amounts[tokenIndex] == 0) { // @audit-info assumes tokenIndex is unique => wrong. can reduce `n` several times with same token n--; } }
It does not seem to be economically exploitable, we still recommend adding this check early in the withdrawColl
function as the code path for _adjustTrove
is very complex.
#0 - kingyetifinance
2022-01-05T09:30:11Z
@LilYeti: Duplicate with #96
#1 - alcueca
2022-01-15T06:18:08Z
Taking as main
🌟 Selected for report: cmichel
531.2947 USDC - $531.29
cmichel
The ThreePieceWiseLinearPriceCurve.adjustParams
function does not check that _cutoff1 <= _cutoff2
and also does not revert in this case.
However, this always indicates an error in how this function should be used.
Add a _cutoff1 <= _cutoff2
check.
#0 - 0xtruco
2022-01-11T11:22:46Z
Fixed in line 45.
26.3494 USDC - $26.35
cmichel
The sYETIToken.mint
function requires msg.sender != address(0)
which is never the case as nobody has the private key for the zero EOA.
The code can be removed to save gas.
#0 - kingyetifinance
2022-01-06T09:06:57Z
@LilYeti: Duplicate #103
cmichel
The sYETIToken.buyBack
calls the Uniswap-like router with a deadline parameter of block.timestamp + 5 minutes
.
This addition is unnecessary and a simple block.timestamp
can be used as the deadline.
#0 - kingyetifinance
2022-01-06T09:08:06Z
@LilYeti : Duplicate #92
#1 - alcueca
2022-01-15T06:22:17Z
Taking as main