Wise Lending - serial-coder's results

Decentralized liquidity market that allows users to supply crypto assets and start earning a variable APY from borrowers.

General Information

Platform: Code4rena

Start Date: 21/02/2024

Pot Size: $200,000 USDC

Total HM: 22

Participants: 36

Period: 19 days

Judge: Trust

Total Solo HM: 12

Id: 330

League: ETH

Wise Lending

Findings Distribution

Researcher Performance

Rank: 5/36

Findings: 5

Award: $10,899.43

🌟 Selected for report: 3

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: JCN

Also found by: 0xStalin, Draiakoo, serial-coder

Labels

bug
3 (High Risk)
:robot:_74_group
sufficient quality report
satisfactory
duplicate-74

Awards

2538.3947 USDC - $2,538.39

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L427-L429 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L94 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L431-L434 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L83 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L147-L149 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L157-L159 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L179-L181 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol#L663-L670 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol#L697-L699

Vulnerability details

Impact

The WiseSecurity::checkBadDebtLiquidation() got a bad accounting bug while updating the global bad debt (i.e., totalBadDebtETH variable).

This bug can be triggered by normal liquidation events or even by an attacker who forces the liquidable position (with bad debt) to be liquidated twice (i.e., executing the WiseLending::liquidatePartiallyFromTokens() upon the target position twice).

In addition to the attack cost, an attacker can even pay 1 wei (in terms of the payback token) for each liquidation by specifying the _shareAmountToPay parameter == 0 when invoking the liquidatePartiallyFromTokens(). Thus, the attack cost is very cheap.

Subsequently, this bug will permanently increment the totalBadDebtETH variable (global bad debt). In other words, the totalBadDebtETH variable will not be able to decrease to 0 anymore, even if all bad debt of that associated position is paid back in full.

As a result, the incentiveOwnerA and incentiveOwnerB will no longer receive their incentives via the FeeManager::claimWiseFees() since the _distributeIncentives() will no longer be executed. Furthermore, all beneficiaries will no longer claim gathered fees via the FeeManager::claimFeesBeneficial() since the transaction will always be reverted.

For this reason, this issue deserves a high severity rating:

  • Likelihood: High -- The bug can be triggered by normal liquidation events or by an attacker (with ease and a cheap attack cost).
  • Impact: High -- The incentiveOwnerA, incentiveOwnerB, and all beneficiaries cannot claim incentives and gathered fees permanently.

Please refer to the Proof of Concept for more details.

Proof of Concept

This PoC section can be categorized into two subsections, as follows.

  1. Code Walkthrough
  2. Attack Illustration With Simple Math

1. Code Walkthrough

The WiseSecurity::checkBadDebtLiquidation() got a bad accounting bug while updating the global bad debt (i.e., totalBadDebtETH variable).

Specifically, if the liquidating position has bad debt, the checkBadDebtLiquidation() will perform two primary steps:

  1. Execute the FeeManager::increaseTotalBadDebtLiquidation() to increase the totalBadDebtETH variable by the position's bad debt (i.e., debt variable).

  2. Execute the FeeManager::setBadDebtUserLiquidation() to set the position's bad debt (badDebtPosition[_nftId]).

The bad accounting bug in question is the 2nd step. The setBadDebtUserLiquidation() will execute the FeeManagerHelper::_setBadDebtPosition() to replace the position's previous bad debt with the new bad debt.

To elaborate, assume that the position (with bad debt) is liquidated twice. The checkBadDebtLiquidation() will increase the totalBadDebtETH variable (global bad debt) twice, but the function will account for the position's bad debt only once.

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol
    function checkBadDebtLiquidation(
        uint256 _nftId
    )
        external
        onlyWiseLending
    {
        uint256 bareCollateral = overallETHCollateralsBare(
            _nftId
        );

        uint256 totalBorrow = overallETHBorrowBare(
            _nftId
        );

        if (totalBorrow < bareCollateral) {
            return;
        }

        unchecked {
            uint256 diff = totalBorrow
                - bareCollateral;

@1.1        FEE_MANAGER.increaseTotalBadDebtLiquidation(
@1.1            diff
@1.1        ); //@audit -- The increaseTotalBadDebtLiquidation() will eventually execute the _increaseTotalBadDebt() to increase the totalBadDebtETH (global debt) -- see @1.2

@2.1        FEE_MANAGER.setBadDebtUserLiquidation(
@2.1            _nftId,
@2.1            diff
@2.1        ); //@audit -- The setBadDebtUserLiquidation() will eventually execute the _setBadDebtPosition(). This step will replace the position's previous bad debt with the new one. -- see @2.2
        }
    }

	...

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol
    function _increaseTotalBadDebt(
        uint256 _amount
    )
        internal
    {
@1.2    totalBadDebtETH += _amount; //@audit -- Increase the totalBadDebtETH (global debt)

        emit TotalBadDebtIncreased(
            _amount,
            block.timestamp
        );
    }

    ...

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol
    function _setBadDebtPosition(
        uint256 _nftId,
        uint256 _amount
    )
        internal
    {
@2.2    badDebtPosition[_nftId] = _amount; //@audit -- The position's previous bad debt will be replaced with the new bad debt
    }

This bug will lead to a permanent increment of the totalBadDebtETH variable (global bad debt). When a user executes the FeeManager::paybackBadDebtForToken() or FeeManager::paybackBadDebtNoReward() to pay back the position's bad debt, the FeeManagerHelper::_updateUserBadDebt() will be triggered to update the position's bad debt.

If there is no more bad debt, the _updateUserBadDebt() will remove the position's tracked bad debt (currentBadDebt) from the global bad debt (totalBadDebtETH). But if there exists bad debt, the _updateUserBadDebt() will update the global bad debt (totalBadDebtETH) by accordingly adjusting (increasing/decreasing) the debt value from the newBadDebt and currentBadDebt (previously tracked bad debt) variables.

However, the previously tracked bad debt (currentBadDebt) will not include the replaced debt value previously mentioned. In other words, the totalBadDebtETH variable (global bad debt) will not be able to decrease to 0 anymore, even if all bad debt of that position is paid back in full.

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol
    function _updateUserBadDebt(
        uint256 _nftId
    )
        internal
    {
        uint256 currentBorrowETH = WISE_SECURITY.overallETHBorrowHeartbeat(
            _nftId
        );

        uint256 currentCollateralBareETH = WISE_SECURITY.overallETHCollateralsBare(
            _nftId
        );

@3      uint256 currentBadDebt = badDebtPosition[
@3          _nftId
@3      ]; //@audit -- Here, the _updateUserBadDebt() will load the position's previous bad debt (currentBadDebt)

        if (currentBorrowETH < currentCollateralBareETH) {

            _eraseBadDebtUser(
                _nftId
            );

@4          _decreaseTotalBadDebt(
@4              currentBadDebt
@4          ); //@audit -- If there is no more bad debt, the function will remove the position's previous bad debt (currentBadDebt) from the global bad debt (totalBadDebtETH)

            ...

            return;
        }

        unchecked {
            uint256 newBadDebt = currentBorrowETH
                - currentCollateralBareETH;

            _setBadDebtPosition(
                _nftId,
                newBadDebt
            );

@5          newBadDebt > currentBadDebt //@audit -- If there exists bad debt, the function will update the global bad debt (totalBadDebtETH) by accordingly adjusting (increasing/decreasing) the debt value from the newBadDebt and currentBadDebt variables
@5              ? _increaseTotalBadDebt(newBadDebt - currentBadDebt)
@5              : _decreaseTotalBadDebt(currentBadDebt - newBadDebt);

            ...
        }
    }

This bug can be triggered by normal liquidation events or even by an attacker who forces the liquidable position (with bad debt) to be liquidated twice (i.e., executing the WiseLending::liquidatePartiallyFromTokens() upon the target position twice).

After the bug is triggered, the incentiveOwnerA and incentiveOwnerB will no longer receive their incentives via the FeeManager::claimWiseFees() since the _distributeIncentives() will no longer be executed. Furthermore, all beneficiaries will no longer claim gathered fees via the FeeManager::claimFeesBeneficial() since the transaction will always be reverted.

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol
    function claimWiseFees(
        address _poolToken
    )
        public
    {
        ...

@6      if (totalBadDebtETH == 0) { //@audit -- After the bug is triggered, the incentiveOwnerA and incentiveOwnerB will no longer receive their incentives (the _distributeIncentives() will no longer be executed)
@6
@6          tokenAmount = _distributeIncentives(
@6              tokenAmount,
@6              _poolToken,
@6              underlyingTokenAddress
@6          );
@6      }

        _increaseFeeTokens(
            underlyingTokenAddress,
            tokenAmount
        );

        ...
    }

    ...

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol
    function claimFeesBeneficial(
        address _feeToken,
        uint256 _amount
    )
        external
    {
        address caller = msg.sender;

@7      if (totalBadDebtETH > 0) {
@7          revert ExistingBadDebt(); //@audit -- After the bug is triggered, all beneficiaries will no longer claim gathered fees (the transaction will always be reverted)
@7      }

        if (allowedTokens[caller][_feeToken] == false) {
            revert NotAllowed();
        }

        _decreaseFeeTokens(
            _feeToken,
            _amount
        );

        _safeTransfer(
            _feeToken,
            caller,
            _amount
        );

        ...
    }

2. Attack Illustration With Simple Math

This subsection will illustrate the possible attack with simple math that should be self-explanatory.

The attack consists of two liquidations on the same liquidable position with bad debt. The two liquidations can be executed in the same transaction, and the attacker can pay only 1 wei of the payback token for each liquidation to minimize the attack cost and preserve the bad debt ratio.

After the attack, the totalBadDebtETH = 6 (global debt), whereas the badDebtPosition[nftId] = 3 (the position's bad debt). As you can see, the previous debt (from the 1st liquidation) tracked by the badDebtPosition[nftId] was replaced with the new debt (from the 2nd liquidation).

// --- Initial ---
totalBadDebtETH = 0
badDebtPosition[nftId] = 0


// --------------------------------------------------------------------------------- //
// --- The attacker can pay only 1 wei of the payback token for each liquidation --- //
// --- to minimize the attack cost and preserve the bad debt ratio               --- //
// --------------------------------------------------------------------------------- //


// --- Liquidation #1 (on WiseSecurity::checkBadDebtLiquidation()) ---
bareCollateral = 7
totalBorrow = 10 // (totalBorrow > bareCollateral) -> Bad debt exists

diff = totalBorrow - bareCollateral
     = 10 - 7
     = 3

// Execute the FeeManager::increaseTotalBadDebtLiquidation(diff)
totalBadDebtETH += diff
                = 0 + 3
                = 3

// Execute the FeeManager::setBadDebtUserLiquidation(nftId, diff)
badDebtPosition[nftId] = diff
                       = 3


// --------------------------------------------------------------------------------- //
// --- The attacker can even execute the 2nd liquidation in the same transaction --- //
// --------------------------------------------------------------------------------- //


// --- Liquidation #2 (on WiseSecurity::checkBadDebtLiquidation()) ---
bareCollateral = 7
totalBorrow = 10 // (totalBorrow > bareCollateral) -> Bad debt exists

diff = totalBorrow - bareCollateral
     = 10 - 7
     = 3

// Execute the FeeManager::increaseTotalBadDebtLiquidation(diff)
totalBadDebtETH += diff
                = 3 + 3
                = 6

// Execute the FeeManager::setBadDebtUserLiquidation(nftId, diff)
badDebtPosition[nftId] = diff
                       = 3 // The previous bad debt was replaced


// --- Summary ---
totalBadDebtETH = 6
badDebtPosition[nftId] = 3 // The previous bad debt was replaced

Below simply simulates the execution of the FeeManager::paybackBadDebtForToken() to pay back the total position's bad debt.

As a result:

  • badDebtPosition[nftId] = 0 (the position's bad debt) -- No more position's bad debt
  • totalBadDebtETH = 3 (global debt) -- Permanent increase of the global bad debt
// --- Initial ---
totalBadDebtETH = 6
badDebtPosition[nftId] = 3 // The previous bad debt was replaced


// --------------------------------------------------------------------- //
// --- During the execution of FeeManager::paybackBadDebtForToken(), --- //
// --- the FeeManagerHelper::_updateUserBadDebt() will be triggered  --- //
// --- to update the position's bad debt (badDebtPosition[nftId])    --- //
// --- and global debt (totalBadDebtETH)                             --- //
// --------------------------------------------------------------------- //


// --- Payback the total position's bad debt (on FeeManager::paybackBadDebtForToken()) ---
bareCollateral = 7
totalBorrow = 6 // (totalBorrow < bareCollateral) -> No bad debt

currentBadDebt = badDebtPosition[nftId] // Previously tracked bad debt
               = 3

// FeeManagerHelper::_updateUserBadDebt() executes the _eraseBadDebtUser(nftId)
badDebtPosition[nftId] = 0

// FeeManagerHelper::_updateUserBadDebt() executes the _decreaseTotalBadDebt(currentBadDebt)
totalBadDebtETH -= currentBadDebt
                = 6 - 3
                = 3


// --- Summary ---
totalBadDebtETH = 3 // Permanent increase of the global bad debt
badDebtPosition[nftId] = 0 // No more position's bad debt

Tools Used

Manual Review

Fixing this vulnerability depends on the developer's design decisions.

One possible solution is to rework the checkBadDebtLiquidation() to set the liquidating position's bad debt with the diff + currentBadDebt instead of only the diff, like the snippet below.

    function checkBadDebtLiquidation(
        uint256 _nftId
    )
        external
        onlyWiseLending
    {
        uint256 bareCollateral = overallETHCollateralsBare(
            _nftId
        );

        uint256 totalBorrow = overallETHBorrowBare(
            _nftId
        );

        if (totalBorrow < bareCollateral) {
            return;
        }

        unchecked {
            uint256 diff = totalBorrow
                - bareCollateral;

            FEE_MANAGER.increaseTotalBadDebtLiquidation(
                diff
            );

+           uint256 currentBadDebt = FEE_MANAGER.badDebtPosition(_nftId);
            FEE_MANAGER.setBadDebtUserLiquidation(
                _nftId,
-               diff
+               diff + currentBadDebt
            );
        }
    }

Assessed type

Other

#0 - c4-pre-sort

2024-03-17T15:42:40Z

GalloDaSballo marked the issue as primary issue

#1 - c4-pre-sort

2024-03-17T15:43:43Z

GalloDaSballo marked the issue as sufficient quality report

#2 - vonMangoldt

2024-03-20T13:24:11Z

This doesnt lead to loss of user funds though. Hence it should be downgraded since one could just migrate and redeploy after discovering that. Otherwise good find 👍

#3 - Foon256

2024-03-20T13:53:26Z

Would agree with that! This is a good insight, but users' funds are never at risk. This is realted to the feeManager and the fees taken from the protocol. Therefore a medium issue.

#4 - vm06007

2024-03-20T14:46:55Z

@GalloDaSballo please mark this as disagree with severity (add label)

#5 - trust1995

2024-03-26T16:43:16Z

Fully deserving of High severity from the C4 rulebook, which doesn't constrain loss to user's funds for High.

#6 - c4-judge

2024-03-26T16:43:28Z

trust1995 marked the issue as satisfactory

#7 - c4-judge

2024-03-26T18:46:37Z

trust1995 marked the issue as selected for report

#8 - vm06007

2024-03-27T14:49:45Z

Fully deserving of High severity from the C4 rulebook, which doesn't constrain loss to user's funds for High.

Based on our agreement and scope we've agreed that high will be only those that lead to loss of funds. @vonMangoldt and @Foon256 can agree on that

#9 - vm06007

2024-03-27T14:51:59Z

@trust1995 you might want to recheck with organizers on our initial agreement for what is high, not by C4 book

#10 - trust1995

2024-03-27T15:45:13Z

Hi, You are referred to this decision of the the Supreme Court, which I am part of. Loss of yield / Loss of fees shall be treated as any other loss of capital. It is within my authority to decide on the severities of each issue presented. If you have additional concerns you may reach out through your own communication channels with C4 staff.

#11 - 0xCiphky

2024-03-27T19:53:18Z

Hey @trust1995, shouldn't this issue be tagged together with #74?

#12 - c4-judge

2024-03-27T20:00:41Z

trust1995 marked the issue as not selected for report

#13 - c4-judge

2024-03-27T20:00:53Z

trust1995 marked the issue as duplicate of #74

Findings Information

🌟 Selected for report: serial-coder

Also found by: SBSecurity, serial-coder

Labels

bug
2 (Med Risk)
:robot:_255_group
sufficient quality report
primary issue
selected for report
M-05

Awards

2444.3801 USDC - $2,444.38

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L306-L330 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseCore.sol#L260-L263 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L1100-L1102 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L1104-L1106 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L237-L270

Vulnerability details

Impact

The Wise Lending protocol allows users to borrow small positions. Even if the protocol has a minimum deposit (collateral) amount check to mitigate the small borrowing position from creating bad debt, this protection can be bypassed.

With a small borrowing position, there is no incentive for a liquidator to liquidate the position, as the liquidation profit may not cover the liquidation cost (gas). As a result, small liquidable positions will not be liquidated, leaving bad debt to the protocol.

Proof of Concept

The protocol allows users to borrow small positions since no minimum borrowing amount is checked in the WiseSecurity::checksBorrow().

// FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseSecurity/WiseSecurity.sol
function checksBorrow(
    uint256 _nftId,
    address _caller,
    address _poolToken
)
    external
    view
    returns (bool specialCase) //@audit -- No minimum borrowing amount check
{
    _checkPoolCondition(
        _poolToken
    );

    checkTokenAllowed(
        _poolToken
    );

    if (WISE_LENDING.verifiedIsolationPool(_caller) == true) {
        return true;
    }

    if (WISE_LENDING.positionLocked(_nftId) == true) {
        return true;
    }
}

Even if the protocol has a minimum deposit (collateral) amount check in the WiseCore::_checkDeposit() to mitigate the small borrowing position from creating bad debt, this protection can be easily bypassed.

The WiseCore::_checkMinDepositValue() is a core function that checks a minimum deposit (collateral) amount. By default, this deposit amount check would be overridden (disabled). Even though this deposit amount check will be enabled, this protection can be bypassed by withdrawing the deposited fund later since there is no minimum withdrawal amount check in the WiseSecurity::checksWithdraw().

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseCore.sol
    function _checkDeposit(
        uint256 _nftId,
        address _caller,
        address _poolToken,
        uint256 _amount
    )
        internal
        view
    {

        if (WISE_ORACLE.chainLinkIsDead(_poolToken) == true) {
            revert DeadOracle();
        }

        _checkAllowDeposit(
            _nftId,
            _caller
        );

        _checkPositionLocked(
            _nftId,
            _caller
        );

@1      WISE_SECURITY.checkPoolWithMinDeposit( //@audit -- Even if there is a minimum deposit amount check, this protection can be bypassed
@1          _poolToken,
@1          _amount
@1      );

        _checkMaxDepositValue(
            _poolToken,
            _amount
        );
    }

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseSecurity/WiseSecurity.sol
    function _checkMinDepositValue(
        address _token,
        uint256 _amount
    )
        private
        view
        returns (bool)
    {
@2      if (minDepositEthValue == ONE_WEI) { //@audit -- By default, the minimum deposit amount check would be overridden (disabled)
@2          return true;
@2      }

@3      if (_getTokensInEth(_token, _amount) < minDepositEthValue) { //@audit -- Even though the minimum deposit amount check will be enabled, this protection can be bypassed by withdrawing the deposited fund later
@3          revert DepositAmountTooSmall();
@3      }

        return true;
    }

As you can see, there is no minimum withdrawal amount check in the WiseSecurity::checksWithdraw(). Hence, a user can deposit collateral at or above the minimum deposit amount (i.e., minDepositEthValue) and then withdraw the deposited fund to be under the minDepositEthValue. Later, they can borrow a small amount with small collateral.

With a small borrowing position (and small collateral), there is no incentive for a liquidator to liquidate the position, as the liquidation profit may not cover the liquidation cost (gas). As a result, small liquidable positions will not be liquidated, leaving bad debt to the protocol.

// FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseSecurity/WiseSecurity.sol
function checksWithdraw(
    uint256 _nftId,
    address _caller,
    address _poolToken
)
    external
    view
    returns (bool specialCase) //@audit -- No minimum withdrawal amount check
{
    if (_checkBlacklisted(_poolToken) == true) {

        if (overallETHBorrowBare(_nftId) > 0) {
            revert OpenBorrowPosition();
        }

        return true;
    }

    if (WISE_LENDING.verifiedIsolationPool(_caller) == true) {
        return true;
    }

    if (WISE_LENDING.positionLocked(_nftId) == true) {
        return true;
    }

    if (_isUncollateralized(_nftId, _poolToken) == true) {
        return true;
    }

    if (WISE_LENDING.getPositionBorrowTokenLength(_nftId) == 0) {
        return true;
    }
}

Tools Used

Manual Review

Implement the minimum borrowing amount check to limit the minimum size of borrowing positions.

Assessed type

Other

#0 - c4-pre-sort

2024-03-17T15:40:57Z

GalloDaSballo marked the issue as duplicate of #277

#1 - c4-pre-sort

2024-03-18T16:27:50Z

GalloDaSballo marked the issue as sufficient quality report

#2 - c4-judge

2024-03-26T16:38:07Z

trust1995 marked the issue as selected for report

#3 - trust1995

2024-03-26T16:39:44Z

The bug class is imho valid as either liquidator will liquidate at a loss or protocol will be losing money to protect from bad debt over time.

#4 - serial-coder

2024-03-27T11:47:47Z

Hi @trust1995,

Did this issue miss the satisfactory tag?

Thanks!

#5 - trust1995

2024-03-27T13:00:24Z

Hi @trust1995,

Did this issue miss the satisfactory tag?

Thanks!

I believe selected-for-report qualifies as satisfactory implicitly.

#6 - serial-coder

2024-03-27T13:06:02Z

Hi @trust1995, Did this issue miss the satisfactory tag? Thanks!

I believe selected-for-report qualifies as satisfactory implicitly.

Thanks for the quick response, sir.

#7 - thebrittfactor

2024-04-29T14:31:49Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

Findings Information

🌟 Selected for report: serial-coder

Also found by: SBSecurity, serial-coder

Labels

bug
2 (Med Risk)
:robot:_255_group
sufficient quality report
satisfactory
duplicate-255

Awards

2444.3801 USDC - $2,444.38

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L1088-L1155 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L1161-L1198 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L1204-L1238

Vulnerability details

Impact

The paybackExactAmountETH(), paybackExactAmount(), and paybackExactShares() allow borrowers to pay back their borrowing positions.

However, those functions do not check the minimum leftover borrowing amount/share. A rogue borrower can partially pay back their position and leave a small borrowing amount/share in the position. One possible objective is to disincentivize a liquidator from liquidating their position.

Since the rogue borrower's position is too small for liquidators to gain liquidation profit compared to the liquidation cost (gas), the position will not be liquidated even if it is liquidatable. This eventually leaves bad debt to the protocol.

Proof of Concept

This section will refer to the paybackExactAmountETH() only for brevity's sake. The paybackExactAmount() and paybackExactShares() should apply the same PoC concept.

As you can see, the paybackExactAmountETH() (and its dependent functions) does not check the minimum leftover borrowing amount/share.

Hence, a borrower can partially pay back and leave any borrowing amount/share in their position at will.

// https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseLending.sol
function paybackExactAmountETH(
    uint256 _nftId
)
    external
    payable
    syncPool(WETH_ADDRESS)
    returns (uint256) //@audit -- No minimum leftover borrowing amount/share check
{
    uint256 maxBorrowShares = userBorrowShares[_nftId][WETH_ADDRESS];

    _validateNonZero(
        maxBorrowShares
    );

    uint256 maxPaybackAmount = paybackAmount(
        WETH_ADDRESS,
        maxBorrowShares
    );

    uint256 paybackShares = calculateBorrowShares(
        {
            _poolToken: WETH_ADDRESS,
            _amount: msg.value,
            _maxSharePrice: false
        }
    );

    _validateNonZero(
        paybackShares
    );

    uint256 refundAmount;
    uint256 requiredAmount = msg.value;

    if (msg.value > maxPaybackAmount) {

        unchecked {
            refundAmount = msg.value
                - maxPaybackAmount;
        }

        requiredAmount = requiredAmount
            - refundAmount;

        paybackShares = maxBorrowShares;
    }

    _handlePayback(
        msg.sender,
        _nftId,
        WETH_ADDRESS,
        requiredAmount,
        paybackShares
    );

    _wrapETH(
        requiredAmount
    );

    if (refundAmount > 0) {
        _sendValue(
            msg.sender,
            refundAmount
        );
    }

    return paybackShares;
}

Tools Used

Manual Review

Do not allow a borrower to pay back their position and leave in borrowing amount/share less than the proper minimum threshold.

Assessed type

Other

#0 - c4-pre-sort

2024-03-17T15:40:53Z

GalloDaSballo marked the issue as duplicate of #277

#1 - c4-pre-sort

2024-03-18T16:27:48Z

GalloDaSballo marked the issue as sufficient quality report

#2 - c4-judge

2024-03-26T19:03:23Z

trust1995 marked the issue as satisfactory

#3 - serial-coder

2024-03-27T11:52:22Z

Hi @trust1995,

Issues #255 and #277 refer to the small position during the borrowing action, whereas this issue (#258) refers to the payback actions.

Fixing #255 and #277 cannot remediate this issue. Thus, this issue should be de-duplicated with #255 and #277.

Thanks for your time, sir!

#4 - trust1995

2024-03-27T13:14:39Z

Hi @trust1995,

Issues #255 and #277 refer to the small position during the borrowing action, whereas this issue (#258) refers to the payback actions.

Fixing #255 and #277 cannot remediate this issue. Thus, this issue should be de-duplicated with #255 and #277.

Thanks for your time, sir!

I have considered this and determined the root cause is very similar, and by the SC verdicts, if a reasonable fix of one issue would address another, they can be categorized together. Here I believe it would be very practical for the team to see both functionalities suffer from the small position flaw.

#5 - serial-coder

2024-03-27T13:17:52Z

Hi @trust1995, Issues #255 and #277 refer to the small position during the borrowing action, whereas this issue (#258) refers to the payback actions. Fixing #255 and #277 cannot remediate this issue. Thus, this issue should be de-duplicated with #255 and #277. Thanks for your time, sir!

I have considered this and determined the root cause is very similar, and by the SC verdicts, if a reasonable fix of one issue would address another, they can be categorized together. Here I believe it would be very practical for the team to see both functionalities suffer from the small position flaw.

Thanks for the clarification, sir.

Findings Information

🌟 Selected for report: serial-coder

Labels

bug
2 (Med Risk)
insufficient quality report
:robot:_04_group
satisfactory
selected for report
M-06

Awards

5431.9558 USDC - $5,431.96

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseOracleHub/OracleHelper.sol#L97

Vulnerability details

Impact

The Wise Lending protocol implements the OracleHelper::_compareMinMax() to detect the circuit-breaking events of Chainlink aggregators when an asset price goes outside of pre-determined min/max values. For instance, in case of a significant price drop (e.g., LUNA crash), the asset price reported by the Chainlink price feed will continue to be at the pre-determined minAnswer instead of the actual price.

The _compareMinMax()'s objective is to prevent such a crash event that would allow a user to borrow other assets with the wrongly reported asset price. For more, refer to the case of Venus Protocol and Blizz Finance in the crash of LUNA.

However, the current implementation of the _compareMinMax() got an off-by-one bug that would prevent the function from detecting the mentioned Chainlink aggregators' circuit-breaking events. In other words, the function will not revert the transaction if the flash crash event occurs as expected.

Proof of Concept

In the flash crash event, the Chainlink price feed will continue to return the _answer at the pre-determined minAnswer instead of the actual price. In other words, the possible minimum value of the _answer would be minAnswer.

Since the _compareMinMax() does not include the case of _answer == minAnswer (also, _answer == maxAnswer), the function could not detect whether or not the crash event happens.

    function _compareMinMax(
        IAggregator _tokenAggregator,
        int192 _answer
    )
        internal
        view
    {
        int192 maxAnswer = _tokenAggregator.maxAnswer();
        int192 minAnswer = _tokenAggregator.minAnswer();

@>      if (_answer > maxAnswer || _answer < minAnswer) {
            revert OracleIsDead();
        }
    }

Tools Used

Manual Review

Add the cases _answer == minAnswer and _answer == maxAnswer like the snippet below.

    function _compareMinMax(
        IAggregator _tokenAggregator,
        int192 _answer
    )
        internal
        view
    {
        int192 maxAnswer = _tokenAggregator.maxAnswer();
        int192 minAnswer = _tokenAggregator.minAnswer();

-       if (_answer > maxAnswer || _answer < minAnswer) {
+       if (_answer >= maxAnswer || _answer <= minAnswer) {
            revert OracleIsDead();
        }
    }

Assessed type

Oracle

#0 - GalloDaSballo

2024-03-16T17:05:09Z

Off by one are QA per SC

#1 - c4-pre-sort

2024-03-16T17:05:12Z

GalloDaSballo marked the issue as insufficient quality report

#2 - c4-judge

2024-03-26T18:05:11Z

trust1995 changed the severity to QA (Quality Assurance)

#3 - c4-judge

2024-03-26T18:50:13Z

trust1995 marked the issue as grade-c

#4 - serial-coder

2024-03-27T11:42:19Z

Hi @trust1995,

Thanks for judging the contest, sir.

This is a valid medium issue as the _compareMinMax() was implemented incorrectly.

Specifically, the _compareMinMax() does not include the edge cases _answer == minAnswer and _answer == maxAnswer. So, the function cannot detect the minAnswer or maxAnswer as expected.

To elaborate, for example, the minAnswer the aggregator can report for the LINK (one of the tokens in scope) is 100000000000000 (10 ** 14). Suppose, in the event of a flash crash, the price of the LINK token drops significantly below the minAnswer (below 10 ** 14).

However, the price feed will continue to report the pre-determined minAnswer (not the actual price). Since the _compareMinMax() does not include the case _answer == minAnswer, it cannot detect this flash crash event.

In other words, the least reported price (i.e., _answer) will be the pre-determined minAnswer, not the actual price. Thus, the _compareMinMax() will never enter the " if " case and revert the transaction as expected because the _answer will never be less than the aggregator's minAnswer.

    function _compareMinMax(
        IAggregator _tokenAggregator,
        int192 _answer
    )
        internal
        view
    {
        int192 maxAnswer = _tokenAggregator.maxAnswer();
        int192 minAnswer = _tokenAggregator.minAnswer();

@>      if (_answer > maxAnswer || _answer < minAnswer) { //@audit -- The least reported price (i.e., `_answer`) will be the `minAnswer`. So, the function will never enter the " if " case
            revert OracleIsDead();
        }
    }

An example reference is https://github.com/sherlock-audit/2023-02-blueberry-judging/issues/18. As you can see, their recommendation includes the edge cases _answer == minAnswer and _answer == maxAnswer.

M-03-02

Further on the TWAP oracle:

Someone may argue that the protocol has the TWAP.

I want to point out that the TWAP setup for each price feed is only optional. If the TWAP is not set, the price deviation comparison mechanism between the TWAP's price and Chainlink's price will not be triggered.

For this reason, this issue deserves a Medium severity.

Thanks for your time, sir!

#5 - c4-judge

2024-03-27T13:09:03Z

This previously downgraded issue has been upgraded by trust1995

#6 - trust1995

2024-03-27T13:11:04Z

The impact demonstrated is an incorrect validation check, which in the case maxAnswer/minAnswer are used by the feed, will make detecting black swans fail. As the team assumes those protections are in place, Med severity is appropriate.

#7 - serial-coder

2024-03-27T13:25:09Z

@trust1995

Thanks for your judge!

But did you forget to remove the tags: unsatisfactory, grade-c, and insufficient quality report?

#8 - 00xSEV

2024-03-28T04:06:14Z

@trust1995 Could you double-check that it's a valid issue? If we follow the link from the Chainlink docs and check the code https://docs.chain.link/data-feeds#monitoring-data-feeds

https://github.com/smartcontractkit/libocr/blob/9e4afd8896f365b964bdf769ca28f373a3fb0300/contract/OffchainAggregator.sol#L68-L69

   * @param _minAnswer lowest answer the median of a report is allowed to be
   * @param _maxAnswer highest answer the median of a report is allowed to be

https://github.com/smartcontractkit/libocr/blob/9e4afd8896f365b964bdf769ca28f373a3fb0300/contract/OffchainAggregator.sol#L640

require(minAnswer <= median && median <= maxAnswer, "median is out of min-max range");

It means that if median == minAnswer or median == maxAnswer it still a valid value and we don't have to revert

#9 - serial-coder

2024-03-28T04:22:03Z

@00xSEV

The least reported price will be the pre-determined minAnswer. If you do not include the case ==, the _compareMinMax() will never enter the " if " case and revert the transaction. You cannot detect the black swan events without it.

The reported price will never be less than theminAnswer. (Without the ==, the if condition will always be false)

    function _compareMinMax(
        IAggregator _tokenAggregator,
        int192 _answer
    )
        internal
        view
    {
        int192 maxAnswer = _tokenAggregator.maxAnswer();
        int192 minAnswer = _tokenAggregator.minAnswer();

@>      if (_answer > maxAnswer || _answer < minAnswer) { //@audit -- The least reported price (i.e., `_answer`) will be the `minAnswer`. So, the function will never enter the " if " case
            revert OracleIsDead();
        }
    }

#10 - c4-judge

2024-03-28T13:32:18Z

trust1995 marked the issue as satisfactory

#11 - c4-judge

2024-03-28T17:34:23Z

trust1995 marked the issue as selected for report

#12 - thebrittfactor

2024-04-29T14:32:14Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

Findings Information

🌟 Selected for report: serial-coder

Also found by: 0x11singh99, Jorgect, Mrxstrange, Rhaydden, josephdara, nonseodion, unix515

Labels

bug
2 (Med Risk)
:robot:_245_group
sufficient quality report
primary issue
satisfactory
selected for report
M-07

Awards

324.761 USDC - $324.76

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L21 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L26-L29 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L35-L37 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/TransferHelper.sol#L42

Vulnerability details

Impact

Currently, the Wise Lending protocol supports several ERC-20 tokens and will also support more tokens in the future:

ERC20 in scope: WETH, WBTC, LINK, DAI, WstETH, sDAI, USDC, USDT, WISE and may others in the future. (Also corresponding Aave tokens if existing)

On some ERC-20 tokens, the transferFrom() will return false on failure instead of reverting a transaction. I noticed that the TransferHelper::_safeTransferFrom(), which is used throughout the protocol, is vulnerable to detecting the token transfer failure if the transferFrom() returns false due to the unchecked return value bug.

The following lists the functions directly invoking the vulnerable _safeTransferFrom():

  1. WiseCore::_coreLiquidation()
  2. WiseLending::depositExactAmount()
  3. WiseLending::solelyDeposit()
  4. WiseLending::paybackExactAmount()
  5. WiseLending::paybackExactShares()
  6. FeeManager::paybackBadDebtForToken()
  7. FeeManager::paybackBadDebtNoReward()
  8. PendlePowerFarm::_manuallyPaybackShares()
  9. PendlePowerManager::enterFarm()
  10. PendlePowerFarmController::exchangeRewardsForCompoundingWithIncentive()
  11. PendlePowerFarmController::exchangeLpFeesForPendleWithIncentive()
  12. PendlePowerFarmController::lockPendle()
  13. PendlePowerFarmToken::addCompoundRewards()
  14. PendlePowerFarmToken::depositExactAmount()
  15. AaveHub::depositExactAmount()
  16. AaveHub::paybackExactAmount()
  17. AaveHub::paybackExactShares()

Due to the unchecked return value bug, users or attackers can exploit these protocol's functions without supplying a token (please refer to the Proof of Concept section for more details).

Proof of Concept

On some ERC-20 tokens, the transferFrom() will return false on failure instead of reverting a transaction.

The Wise Lending protocol implements the TransferHub::_callOptionalReturn() as a helper function for executing low-level calls. In case the transferFrom() returns false on failure, the _callOptionalReturn() will receive the returned parameters: success == true and returndata != bytes(0).

Then, the _callOptionalReturn() will decode the received returndata for the success status. If the transferFrom() returns false, the results will become false.

With the results == false, finally, the _callOptionalReturn() will return false to its function caller.

On the TransferHelper::_safeTransferFrom(), I noticed that the function does not evaluate the return value (i.e., unchecked return value bug) of the _callOptionalReturn(). Subsequently, the _safeTransferFrom() cannot detect the token transfer failure if the transferFrom() returns false.

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol
    function _callOptionalReturn(
        address token,
        bytes memory data
    )
        internal
        returns (bool call)
    {
        (
            bool success,
@1          bytes memory returndata //@audit -- On some tokens, the transferFrom() will return false instead of reverting a transaction
        ) = token.call(
            data
        );

@2      bool results = returndata.length == 0 || abi.decode(
@2          returndata, //@audit -- If the transferFrom() returns false, the results == false
@2          (bool)
@2      );

        if (success == false) {
            revert();
        }

@3      call = success
@3          && results // @audit -- If the results == false, the _callOptionalReturn() will return false
@3          && token.code.length > 0;
    }

    ...

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/TransferHelper.sol
    function _safeTransferFrom(
        address _token,
        address _from,
        address _to,
        uint256 _value
    )
        internal
    {
@4      _callOptionalReturn( //@audit -- The _safeTransferFrom() cannot detect the token transfer failure if the transferFrom() returns false instead of reverting a transaction due to the unchecked return value bug
            _token,
            abi.encodeWithSelector(
                IERC20.transferFrom.selector,
                _from,
                _to,
                _value
            )
        );
    }

Note: Please refer to the Impact section for a complete list of the functions directly invoking the vulnerable _safeTransferFrom().

The following briefly analyzes 2 of the 17 functions that would affect the exploitation as examples.

  • Due to the unchecked return value bug, the WiseCore::_coreLiquidation() cannot be aware of the token transfer failure. Thus, a rogue liquidator can steal collateral from the target liquidable position without supplying any debt token (tokenToPayback).

  • On the WiseLending::depositExactAmount(), a rogue depositor can increase their collateral without spending any token.

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseCore.sol
    function _coreLiquidation(
        CoreLiquidationStruct memory _data
    )
        internal
        returns (uint256 receiveAmount)
    {
        ...

@5      _safeTransferFrom( //@audit -- Liquidator can steal collateral (_receiveToken) from the target liquidable position
@5          _data.tokenToPayback,
@5          _data.caller,
@5          address(this),
@5          _data.paybackAmount
@5      );

        ...
    }

    ...

    // FILE: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol
    function depositExactAmount(
        uint256 _nftId,
        address _poolToken,
        uint256 _amount
    )
        public
        syncPool(_poolToken)
        returns (uint256)
    {
        uint256 shareAmount = _handleDeposit(
            msg.sender,
            _nftId,
            _poolToken,
            _amount
        );

@6      _safeTransferFrom( //@audit -- Depositor can increase their collateral without supplying any token
@6          _poolToken,
@6          msg.sender,
@6          address(this),
@6          _amount
@6      );

        return shareAmount;
    }

Tools Used

Manual Review

Improve the _safeTransferFrom() by checking the return value from the _callOptionalReturn() and reverting a transaction if it is false.

    function _safeTransferFrom(
        address _token,
        address _from,
        address _to,
        uint256 _value
    )
        internal
    {
-       _callOptionalReturn(
+       bool success = _callOptionalReturn(
            _token,
            abi.encodeWithSelector(
                IERC20.transferFrom.selector,
                _from,
                _to,
                _value
            )
        );

+       require(success, "Token transfer failed");
    }

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-03-17T14:30:31Z

GalloDaSballo marked the issue as duplicate of #212

#1 - c4-pre-sort

2024-03-18T16:26:57Z

GalloDaSballo marked the issue as sufficient quality report

#2 - c4-judge

2024-03-26T14:28:56Z

trust1995 marked the issue as satisfactory

#3 - c4-judge

2024-03-26T14:34:02Z

trust1995 marked the issue as selected for report

#4 - vm06007

2024-04-02T14:32:08Z

This then leads to some tokens unusable (like USDT for example) and this topic was already discussed severely during hats competition where I can send links to findings, so should be scraped in my opinion. Also sufficient checks are already done in _callOptionalReturn directly.

#5 - thebrittfactor

2024-04-29T14:32:46Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

#6 - vm06007

2024-05-02T12:42:58Z

Additionally: WiseLending is not meant to be used with corrupted tokens that have unsupported transfer/transferFrom implementations.

Findings Information

🌟 Selected for report: Dup1337

Also found by: 0x11singh99, NentoR, cheatc0d3, nonseodion, serial-coder, shealtielanz

Labels

bug
grade-b
QA (Quality Assurance)
sufficient quality report
Q-04

Awards

159.9366 USDC - $159.94

External Links

[L-01] Incorrect collateralFactor upper limit

Impact

In the PendlePowerFarmDeclarations::constructor(), the _collateralFactor parameter is validated against the incorrect upper limit constant. The PRECISION_FACTOR_E18, which indicates 1e18 or 100%, is used instead of the MAX_COLLATERAL_FACTOR, which indicates 95e16 or 95%.

Therefore, the _collateralFactor parameter can be greater than the expected upper limit value, leading to the incorrect configuration of the protocol's parameter.

Proof of Concept

The MAX_COLLATERAL_FACTOR constant is defined as 95% maximum (95e16).

However, in the PendlePowerFarmDeclarations::constructor(), the PRECISION_FACTOR_E18 (at 100%) is used as the upper limit instead.

    contract PendlePowerFarmDeclarations is
        WrapperHelper,
        TransferHelper,
        ApprovalHelper,
        SendValueHelper
    {
        ...

        // RESTRICTION VALUES

@1      uint256 internal constant MAX_COLLATERAL_FACTOR = 95 * PRECISION_FACTOR_E16; //@audit -- The MAX_COLLATERAL_FACTOR is defined at 95% maximum

        ...

        constructor(
            address _wiseLendingAddress,
            address _pendleChilTokenAddress,
            address _pendleRouter,
            address _entryAsset,
            address _pendleSy,
            address _underlyingMarket,
            address _routerStatic,
            address _dexAddress,
            uint256 _collateralFactor
        )
            WrapperHelper(
                IWiseLending(
                    _wiseLendingAddress
                ).WETH_ADDRESS()
            )
        {
            ...

@2          if (_collateralFactor > PRECISION_FACTOR_E18) { //@audit -- In the constructor(), the PRECISION_FACTOR_E18 (at 100%) is used as the maximum value instead of the MAX_COLLATERAL_FACTOR
                revert CollateralFactorTooHigh();
            }

            collateralFactor = _collateralFactor;

            ...
        }

        ...
    }

Tools Used

Manual Review

Use the MAX_COLLATERAL_FACTOR instead of the PRECISION_FACTOR_E18.

    constructor(
        address _wiseLendingAddress,
        address _pendleChilTokenAddress,
        address _pendleRouter,
        address _entryAsset,
        address _pendleSy,
        address _underlyingMarket,
        address _routerStatic,
        address _dexAddress,
        uint256 _collateralFactor
    )
        WrapperHelper(
            IWiseLending(
                _wiseLendingAddress
            ).WETH_ADDRESS()
        )
    {
        ...

-       if (_collateralFactor > PRECISION_FACTOR_E18) {
+       if (_collateralFactor > MAX_COLLATERAL_FACTOR) {
            revert CollateralFactorTooHigh();
        }

        collateralFactor = _collateralFactor;

        ...
    }

[L-02] Inconsistent MAX_COLLATERAL_FACTOR constants

Impact

There are different MAX_COLLATERAL_FACTOR constants defined in the PendlePowerFarmDeclarations and WiseLendingDeclaration contracts that can lead to an inconsistency issue.

Proof of Concept

  • The MAX_COLLATERAL_FACTOR constant of the PendlePowerFarmDeclarations contract is 95e16 (95%).

  • The MAX_COLLATERAL_FACTOR constant of the WiseLendingDeclaration contract is 85e16 (85%).

Tools Used

Manual Review

Make both constants to be consistent.


[L-03] Use of unchangeable oracles

Impact

In the Wise Lending protocol, Chainlink's price feed, Chainlink's aggregator, and TWAP oracle for each specific token cannot be changed or updated. If one is down or offline, the protocol will be dos'ed.

Another risk is that if an admin somehow misconfigures oracle data, there will be no functionality to update them.

Proof of Concept

    function _addOracle(
        address _tokenAddress,
        IPriceFeed _priceFeedAddress,
        address[] calldata _underlyingFeedTokens
    )
        internal
    {
@1      if (priceFeed[_tokenAddress] > ZERO_FEED) { //@audit -- Chainlink's price feed cannot be changed/updated
@1          revert OracleAlreadySet();
@1      }

        priceFeed[_tokenAddress] = _priceFeedAddress;

        _tokenDecimals[_tokenAddress] = IERC20(
            _tokenAddress
        ).decimals();

        underlyingFeedTokens[_tokenAddress] = _underlyingFeedTokens;
    }

    ...

    function _addAggregator(
        address _tokenAddress
    )
        internal
    {
        IAggregator tokenAggregator = IAggregator(
            priceFeed[_tokenAddress].aggregator()
        );

@2      if (tokenAggregatorFromTokenAddress[_tokenAddress] > ZERO_AGGREGATOR) { //@audit -- Chainlink's aggregator cannot be changed/updated
@2          revert AggregatorAlreadySet();
@2      }

        if (_checkFunctionExistence(address(tokenAggregator)) == false) {
            revert FunctionDoesntExist();
        }

        UniTwapPoolInfo memory uniTwapPoolInfoStruct = uniTwapPoolInfo[
            _tokenAddress
        ];

        if (uniTwapPoolInfoStruct.oracle > ZERO_ADDRESS) {
            revert AggregatorNotNecessary();
        }

        tokenAggregatorFromTokenAddress[_tokenAddress] = tokenAggregator;
    }

    ...

    function _validateTwapOracle(
        address _tokenAddress
    )
        internal
        view
    {
@3      if (uniTwapPoolInfo[_tokenAddress].oracle > ZERO_ADDRESS) { //@audit -- TWAP oracle for each specific token cannot be changed/updated
@3          revert TwapOracleAlreadySet();
@3      }
    }

Tools Used

Manual Review

Make the oracles to be updatable.


[L-04] Unhandled Chainlink revert can deny access to oracle prices

Impact

The protocol lacks a preventive approach to handling the Chainlink's latestRoundData() reverts, resulting in a denial of service to accessing oracle prices.

The following is a complete list of the functions that execute the latestRoundData().

Proof of Concept

For example, the OracleHelper::_getChainlinkAnswer() uses Chainlink's latestRoundData(). The calls to the latestRoundData() can be reverted for several reasons, such as Chainlink's multisigs block access to price feeds, etc.

However, there is no preventive approach to handling the case of the latestRoundData() reverts, resulting in a denial of service when accessing oracle prices.

    function _getChainlinkAnswer(
        address _tokenAddress
    )
        internal
        view
        returns (uint256)
    {
@>      (
@>          ,
@>          int256 answer,
@>          ,
@>          ,
@>      ) = priceFeed[_tokenAddress].latestRoundData();

        return uint256(
            answer
        );
    }

Tools Used

Manual Review

Wrap the call to the latestRoundData() in a try/catch block and handle any errors appropriately—for instance, fallback to Uniswap's TWAP oracle or other off-chain oracle services such as Tellor or Band. The fallback solution will also ensure the price data is available for the protocol.

    function _getChainlinkAnswer(
        address _tokenAddress
    )
        internal
        view
        returns (uint256)
    {
-       (
-           ,
-           int256 answer,
-           ,
-           ,
-       ) = priceFeed[_tokenAddress].latestRoundData();
-
-       return uint256(
-           answer
-       );
+       try priceFeed[_tokenAddress].latestRoundData() returns (
+           ,
+           int256 answer,
+           ,
+           ,
+       ) {
+           // Handle the successful execution here
+           ...
+
+           return answer;
+
+       } catch Error(string memory) {
+           // Handle errors here: (e.g., fallback to Uniswap's TWAP oracle or other off-chain oracle services such as Tellor or Band)
+           ...
+       }
    }

#0 - c4-pre-sort

2024-03-17T10:46:42Z

GalloDaSballo marked the issue as sufficient quality report

#1 - c4-judge

2024-03-26T11:12:04Z

trust1995 marked the issue as grade-b

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