Yeti Finance contest - cmichel's results

Portfolio borrowing. 11x leverage. 0% interest.

General Information

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

Yeti Finance

Findings Distribution

Researcher Performance

Rank: 3/25

Findings: 9

Award: $11,087.86

🌟 Selected for report: 10

🚀 Solo Findings: 4

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

1593.8842 USDC - $1,593.88

External Links

Handle

cmichel

Vulnerability details

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).

Impact

The lastBuyBackPrice will be wrong when using a different path. This will lead rebases 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.

Findings Information

🌟 Selected for report: cmichel

Also found by: csanuragjain, gzeon

Labels

bug
2 (Med Risk)

Awards

430.3487 USDC - $430.35

External Links

Handle

cmichel

Vulnerability details

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.

POC

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
    );
}

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1593.8842 USDC - $1,593.88

External Links

Handle

cmichel

Vulnerability details

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.

Example:

Check out this two-piece linear interest curve of Aave:

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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

1593.8842 USDC - $1,593.88

External Links

Handle

cmichel

Vulnerability details

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1593.8842 USDC - $1,593.88

External Links

Handle

cmichel

Vulnerability details

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

Findings Information

🌟 Selected for report: cmichel

Also found by: WatchPug, kenzo, pauliax

Labels

bug
duplicate
3 (High Risk)

Awards

968.2846 USDC - $968.28

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Also found by: UncleGrandpa925

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

2390.8263 USDC - $2,390.83

External Links

Handle

cmichel

Vulnerability details

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;
}

Impact

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.

Findings Information

🌟 Selected for report: defsec

Also found by: 0x1f8b, Jujic, WatchPug, broccolirob, certora, cmichel, csanuragjain, hyh, jayjonah8, kenzo, robee, sirhashalot

Labels

bug
duplicate
1 (Low Risk)
disagree with severity

Awards

11.5426 USDC - $11.54

External Links

Handle

cmichel

Vulnerability details

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);

Impact

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

#2 - alcueca

2022-01-15T07:31:03Z

Duplicate of #94

Findings Information

🌟 Selected for report: hyh

Also found by: Ruhum, WatchPug, cmichel

Labels

bug
duplicate
1 (Low Risk)

Awards

96.8285 USDC - $96.83

External Links

Handle

cmichel

Vulnerability details

The setAddresses function that initializes important contract state can be called by anyone.

See:

  • WJLP.setAddresses

Impact

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

Findings Information

🌟 Selected for report: cmichel

Also found by: gpersoon

Labels

bug
1 (Low Risk)

Awards

239.0826 USDC - $239.08

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

531.2947 USDC - $531.29

External Links

Handle

cmichel

Vulnerability details

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.

Findings Information

🌟 Selected for report: dalgarim

Also found by: cmichel, p4st13r4

Labels

bug
duplicate
G (Gas Optimization)

Awards

26.3494 USDC - $26.35

External Links

Handle

cmichel

Vulnerability details

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

Findings Information

🌟 Selected for report: cmichel

Also found by: WatchPug, certora, defsec

Labels

bug
G (Gas Optimization)

Awards

17.7859 USDC - $17.79

External Links

Handle

cmichel

Vulnerability details

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter