Sublime contest - cmichel's results

Democratizing credit via Web3.

General Information

Platform: Code4rena

Start Date: 09/12/2021

Pot Size: $50,000 USDC

Total HM: 19

Participants: 21

Period: 7 days

Judge: 0xean

Total Solo HM: 14

Id: 61

League: ETH

Sublime

Findings Distribution

Researcher Performance

Rank: 1/21

Findings: 6

Award: $7,086.37

🌟 Selected for report: 9

πŸš€ Solo Findings: 2

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

2816.7538 USDC - $2,816.75

External Links

Handle

cmichel

Vulnerability details

The yearn strategy YearnYield converts shares to tokens by doing pricePerFullShare * shares / 1e18:

function getTokensForShares(uint256 shares, address asset) public view override returns (uint256 amount) { if (shares == 0) return 0; // @audit should divided by vaultDecimals amount = IyVault(liquidityToken[asset]).getPricePerFullShare().mul(shares).div(1e18); }

But Yearn's getPricePerFullShare seems to be in vault.decimals() precision, i.e., it should convert it as pricePerFullShare * shares / (10 ** vault.decimals()). The vault decimals are the same as the underlying token decimals

Impact

The token and shares conversions do not work correctly for underlying tokens that do not have 18 decimals. Too much or too little might be paid out leading to a loss for either the protocol or user.

Divide by 10**vault.decimals() instead of 1e18 in getTokensForShares. Apply a similar fix in getSharesForTokens.

Findings Information

🌟 Selected for report: cmichel

Also found by: WatchPug, leastwood

Labels

bug
3 (High Risk)
sponsor acknowledged

Awards

760.5235 USDC - $760.52

External Links

Handle

cmichel

Vulnerability details

When depositing into Aave through the AaveYield.lockTokens contract strategy, one receives the sharesReceived amount corresponding to the diff of aToken balance, which is just always the deposited amount as aave is a rebasing token and 1.0 aToken = 1.0 underlying at each deposit / withdrawal.

Note that this sharesReceived (the underlying deposit amount) is cached in a balanceInShares map in SavingsAccount.deposit which makes this share static and not dynamically rebasing anymore:

function deposit(
    uint256 _amount,
    address _token,
    address _strategy,
    address _to
) external payable override nonReentrant returns (uint256) {
    require(_to != address(0), 'SavingsAccount::deposit receiver address should not be zero address');
    uint256 _sharesReceived = _deposit(_amount, _token, _strategy);
    balanceInShares[_to][_token][_strategy] = balanceInShares[_to][_token][_strategy].add(_sharesReceived);
    emit Deposited(_to, _sharesReceived, _token, _strategy);
    return _sharesReceived;
}

function getTokensForShares(uint256 shares, address asset) public view override returns (uint256 amount) {
    if (shares == 0) return 0;
    address aToken = liquidityToken(asset);

    (, , , , , , , uint256 liquidityIndex, , ) = IProtocolDataProvider(protocolDataProvider).getReserveData(asset);

    // @audit-info tries to do (user shares / total shares) * underlying amount where underlying amount = scaledBalance * liquidityIndex
    amount = IScaledBalanceToken(aToken).scaledBalanceOf(address(this)).mul(liquidityIndex).mul(shares).div(
        IERC20(aToken).balanceOf(address(this))
    );
}

However, the getTokensForShares function uses a rebasing total share supply of IERC20(aToken).balanceOf(this).

POC
  • SavingsAccount deposits 1000 DAI for user and user receives 1000 aDAI as shares. These shares are cached in balanceInShares[user][dai][aave].
  • Time passes, Aave accrues interest for lenders, and the initial 1000 aTokens balance has rebased to 1200 aTokens
  • SavingsAccount withdraws 1000 aDAI shares for user which calls AaveYield.unlockTokens. The user receives only 1000 DAI. The interest owed to the user is not paid out.
  • Note that getTokensForShares also returns the wrong amount as 1200 * 1000 / 1200 = 1000

Impact

Interest is not paid out to users. Pool collateral is measured without the interest accrued as it uses getTokensForShares which will lead to early liquidations and further loss.

If the user shares are not rebasing, you cannot have the "total shares supply" (the shares in the contract) be rebasing as in getTokensForShares. Also withdrawing the share amount directly from Aave as in _withdrawERC does not withdraw the yield. A fix could be to create a non-rebasing wrapper LP token that is paid out to the user proportional to the current strategy TVL at time of user deposit.

#0 - ritik99

2021-12-27T09:48:07Z

We've been aware of this issue for some time.. ended up including the AaveYield file in the scope by mistake! We do not plan to include the Aave strategy in our launch (we maintain a strategy registry that allows us to add/drop yield strategies), and as noted in #128, we will be utilizing wrapper contracts that mimics behaviour of non-rebasing LP tokens

#1 - 0xean

2022-01-21T00:37:35Z

going to side with the warden since they believed the contract to be in scope and it's a valid concern.

Findings Information

🌟 Selected for report: hyh

Also found by: cmichel

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

1267.5392 USDC - $1,267.54

External Links

Handle

cmichel

Vulnerability details

The CreditLine.borrow function increases the principal by the _amount parameter, but the borrower only receives _tokenDiffBalance.

// @audit increases by _amount instead of _tokenDiffBalance
creditLineVariables[_id].principal = creditLineVariables[_id].principal.add(_amount);
creditLineVariables[_id].lastPrincipalUpdateTime = block.timestamp;

uint256 _tokenDiffBalance;
if (_borrowAsset != address(0)) {
    uint256 _balanceBefore = IERC20(_borrowAsset).balanceOf(address(this));
    _withdrawBorrowAmount(_borrowAsset, _amount, _lender);
    uint256 _balanceAfter = IERC20(_borrowAsset).balanceOf(address(this));
    _tokenDiffBalance = _balanceAfter.sub(_balanceBefore);
}

It could be that one of the strategies does currently not return the entire desired withdraw amount but less, for example, if a lending protocol's pool is currently fully lent out. The borrower is credited more debt than they received.

Increase principal by the _tokenDiffBalance (before fees).

#0 - 0xean

2022-01-21T20:38:29Z

dupe of #80

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

845.0261 USDC - $845.03

External Links

Handle

cmichel

Vulnerability details

The Extension contract correctly reduces votes from the from address of a transfer and adds it to the to address of the transfer (in case both of them voted on it before), but it does not rerun the voting logic in voteOnExtension that actually grants the extension. This leads to issues where an extension should be granted but is not:

POC
  • to address has 100 tokens and votes for the extension
  • from address has 100 tokens but does not vote for the extension and transfers the 100 tokens to to
  • to now has 200 tokens, removeVotes is run, the totalExtensionSupport is increased by 100 to 200. In theory, the threshold is reached and the vote should pass if to could call voteOnExtension again.
  • But their call to voteOnExtension with the new balance will fail as they already voted on it (lastVotedExtension == _extensionVoteEndTime). The extension is not granted.

Impact

Extensions that should be granted after a token transfer are not granted.

Rerun the threshold logic in removeVotes as it has the potential to increase the total support if to voted for the extension but from did not.

Findings Information

🌟 Selected for report: WatchPug

Also found by: cmichel, leastwood, robee

Labels

bug
duplicate
1 (Low Risk)

Awards

51.3353 USDC - $51.34

External Links

Handle

cmichel

Vulnerability details

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

See:

  • PriceOracle.initialize
  • PoolFactory.initialize
  • Repayments.initialize
  • CreditLine.initialize

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 non-proxied contracts. For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.

#0 - ritik99

2021-12-27T13:33:58Z

Duplicate of #106

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
disagree with severity

Awards

281.6754 USDC - $281.68

External Links

Handle

cmichel

Vulnerability details

The Extension contract uses the current balance (IPool.getBalanceDetails(msg.sender)) as the voting power. If this passes the threshold the extension is granted, doesn't matter that removeVotes removes the votes when transferring the tokens away from the voter.

If there's a secondary market for the pool tokens that allows flashloans, this means a lender (for example, the borrower could have approved itself before) can flashloan this amount, extend their repayment duration, and pay back the flashloan.

Use a snapshot-based voting system like the Comp token does, or ungrant the extension in removeVotes if it doesn't reach the threshold anymore.

#0 - ritik99

2021-12-24T14:48:27Z

An extension can only be granted once for a particular loan, so this attack cannot be replayed, and there are no assets at risk. We would suggest a (1) Low-Risk factor for this issue

#1 - 0xean

2022-01-21T20:47:41Z

Downgrading to low risk based on the fact that the lenders would have to willingly lend out their tokens in order for this to happen so they in fact control the attack vector.

1 β€” Low: Low: Assets are not at risk. State handling, function incorrect as to spec, issues with comments.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
disagree with severity

Awards

281.6754 USDC - $281.68

External Links

Handle

cmichel

Vulnerability details

Direct deposits to the savings account from the pool that don't have a strategy take the following path: Pool.depositCollateral -> Pool._deposit(..., _toSavingsAccount=true, strategy=poolConstants.poolSavingsStrategy) -> SavingsAccountUtils.directDeposit -> SavingsAccountUtils.directSavingsAccountDeposit -> SavingsAccount.deposit -> SavingsAccount._deposit -> SavingsAccount._depositToYield -> _strategy.lockTockens

While directSavingsAccountDeposit has a special case for _strategy == address(0), the _depositToYield function ignores it and reverts in the registry check or when calling lockTokens on the zero address.

function _depositToYield(
    uint256 _amount,
    address _token,
    address _strategy
) internal returns (uint256 _sharesReceived) {
    // @audit-info sometimes this is called with _strategy == 0 from `directSavingsAccountDeposit`
    require(IStrategyRegistry(strategyRegistry).registry(_strategy), 'SavingsAccount::deposit strategy do not exist');
    uint256 _ethValue;

    if (_token == address(0)) {
        _ethValue = _amount;
        require(msg.value == _amount, 'SavingsAccount::deposit ETH sent must be equal to amount');
    }
    // @audit-ok if strategy is attacker controlled, can mint billion shares. checks strategy here, but not everywhere? actually everywhere it gets increased
    _sharesReceived = IYield(_strategy).lockTokens{value: _ethValue}(msg.sender, _token, _amount);
}

One cannot deposit to the savings account if the pool.SavingsStrategy is zero.

Handle the case where _strategy == 0 in _deposit or _depositToYield.

#0 - ritik99

2021-12-27T06:46:45Z

the _strategy == address(0) check in directSavingsAccountDeposit() is remnant of old code, we'll be removing it since it's an invalid case. We suggest reducing severity to (1) Low-risk since _strategy == address(0) would have failed in the subsequent calls as expected, so there are no loss of funds

#1 - 0xean

2022-01-21T20:49:41Z

lower severity due to the revert eventually occurring.

Findings Information

🌟 Selected for report: cmichel

Also found by: gpersoon

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

126.7539 USDC - $126.75

External Links

Handle

cmichel

Vulnerability details

When transferring pool tokens to oneself the Pool._beforeTokenTransfer overwrites the effectiveInterestWithdrawn of the user with a higher amount than expected. It uses the previous balance + the transfer amount instead of just the previous balance:

// @audit if from == to: overwrites with last _to statement => bug
lenders[_from].effectiveInterestWithdrawn = (_fromBalance.sub(_amount)).mul(_totalRepaidAmount).div(_totalSupply);
lenders[_to].effectiveInterestWithdrawn = (_toBalance.add(_amount)).mul(_totalRepaidAmount).div(_totalSupply);

Impact

The bug is not in the user's favor and would lead to them being able to withdraw fewer repayments in the future.

POC
  • user calls Pool.transfer(from=user, to=user, amount=pool.balanceOf(user))
  • pending repayments are withdrawn first by the _withdrawRepayment calls. (second one does not lead to a second withdrawal as the effectiveInterestWithdrawn is already increased in the first call)
  • lenders[user].effectiveInterestWithdrawn is then set using 2 * userBalance.
  • This has the effect that the user appears to have claimed twice as many repayments as their balance indicates already and they won't be able to claim anymore for a while.

We still recommend fixing this bug, for example, by disallowing self-transfers.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
disagree with severity
sponsor acknowledged

Awards

281.6754 USDC - $281.68

External Links

Handle

cmichel

Vulnerability details

Certain ERC20 tokens make modifications to their ERC20's transfer or balanceOf functions. One type of these tokens is deflationary tokens that charge a certain fee for every transfer() or transferFrom().

The CreditLine._depositCollateral function does not work well with fee-on-transfer tokens. An _amount is first transferred to this address and then a strategy is approved with the same amount. However, when the strategy tries to transfer this _amount from the pool, it will fail as the pool received less than _amount due to the fee.

One possible mitigation is to measure the asset change right before and after the asset-transferring calls.

#0 - ritik99

2022-01-07T23:02:56Z

We're aware that the current logic does not support deflationary assets. We maintain a token whitelist that enables us to choose which assets are used and which aren't, thus we suggest a (1) Low-Risk severity

#1 - 0xean

2022-01-21T21:05:58Z

Downgrading to sev-1 based on token whitelist

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

281.6754 USDC - $281.68

External Links

Handle

cmichel

Vulnerability details

The strategy contracts define an unlockShares function that must accept an asset parameter as the share token (yield token, aToken, cToken, etc.), otherwise, the code does not work. However, all comments say that asset is the address of the underlying token.

/**
  * @notice Used to unlock shares
  * @param asset the address of underlying token
  * @param amount the amount of shares to unlock
  * @return received amount of shares received
  **/
function unlockShares(address asset, uint256 amount) external override onlySavingsAccount nonReentrant returns (uint256) {
    if (amount == 0) {
        return 0;
    }
}

Fix the comments for all unlockShares by saying asset is the share token, not the underlying token.

Findings Information

🌟 Selected for report: sirhashalot

Also found by: 0x0x0x, WatchPug, cmichel, pmerkleplant, robee

Labels

bug
duplicate
G (Gas Optimization)

Awards

8.2172 USDC - $8.22

External Links

Handle

cmichel

Vulnerability details

The function computes uint256 _amount = balanceInShares[msg.sender][_token][_strategyList[i]]; but this value is never used and reassigned in the next line to _amount = IYield(_strategyList[i]).unlockTokens(_token, balanceInShares[msg.sender][_token][_strategyList[i]]);. The first line can therefore be removed.

#0 - ritik99

2022-01-07T22:47:54Z

Duplicate of #36 since both issues deal with unnecessary initializations. Hence, the issue is the same even if the instances mentioned in both the places are different

Findings Information

🌟 Selected for report: cmichel

Labels

bug
G (Gas Optimization)
sponsor confirmed

Awards

83.4956 USDC - $83.50

External Links

Handle

cmichel

Vulnerability details

The if conditions in Pool.withdrawLiquidity are distinct conditions on the pool status. Therefore, else if is semantically equivalent but more gas efficient.

if (_loanStatus == LoanStatus.DEFAULTED || _loanStatus == LoanStatus.TERMINATED) {
    uint256 _totalAsset;
    if (poolConstants.borrowAsset != address(0)) {
        _totalAsset = IERC20(poolConstants.borrowAsset).balanceOf(address(this));
    } else {
        _totalAsset = address(this).balance;
    }
    //assuming their will be no tokens in pool in any case except liquidation (to be checked) or we should store the amount in liquidate()
    _toTransfer = _toTransfer.mul(_totalAsset).div(totalSupply());
}
// @audit gas: use else if, status fields are distinct, only one of the branches is (if ever) executed anyway
if (_loanStatus == LoanStatus.CANCELLED) {
    _toTransfer = _toTransfer.add(_toTransfer.mul(poolVariables.penaltyLiquidityAmount).div(totalSupply()));
}

if (_loanStatus == LoanStatus.CLOSED) {
    //transfer repayment
    _withdrawRepayment(msg.sender);
}
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