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
Rank: 5/36
Findings: 5
Award: $10,899.43
🌟 Selected for report: 3
🚀 Solo Findings: 1
🌟 Selected for report: JCN
Also found by: 0xStalin, Draiakoo, serial-coder
2538.3947 USDC - $2,538.39
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
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:
incentiveOwnerA
, incentiveOwnerB
, and all beneficiaries
cannot claim incentives and gathered fees permanently.Please refer to the Proof of Concept
for more details.
This PoC section can be categorized into two subsections, as follows.
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:
Execute the FeeManager::increaseTotalBadDebtLiquidation()
to increase the totalBadDebtETH
variable by the position's bad debt (i.e., debt
variable).
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 }
@1.1 -- The increaseTotalBadDebtLiquidation() will eventually execute the _increaseTotalBadDebt() to increase the totalBadDebtETH (global debt) -- see @1.2
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L427-L429@1.2 -- Increase the totalBadDebtETH (global debt)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L94@2.1 -- The setBadDebtUserLiquidation() will eventually execute the _setBadDebtPosition(). This step will replace the position's previous bad debt with the new one. -- see @2.2
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L431-L434@2.2 -- The position's previous bad debt will be replaced with the new bad debt
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L83This 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); ... } }
@3 -- Here, the _updateUserBadDebt() will load the position's previous bad debt (currentBadDebt)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L147-L149@4 -- If there is no more bad debt, the function will remove the position's previous bad debt (currentBadDebt) from the global bad debt (totalBadDebtETH)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L157-L159@5 -- 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
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManagerHelper.sol#L179-L181This 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 ); ... }
@6 -- After the bug is triggered, the incentiveOwnerA and incentiveOwnerB will no longer receive their incentives (the _distributeIncentives() will no longer be executed)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol#L663-L670@7 -- After the bug is triggered, all beneficiaries will no longer claim gathered fees (the transaction will always be reverted)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol#L697-L699This 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 debttotalBadDebtETH
= 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
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 ); } }
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
🌟 Selected for report: serial-coder
Also found by: SBSecurity, serial-coder
2444.3801 USDC - $2,444.38
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
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.
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; } }
No minimum borrowing amount check
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L306-L330Even 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; }
@1 -- Even if there is a minimum deposit amount check, this protection can be bypassed
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseCore.sol#L260-L263@2 -- By default, the minimum deposit amount check would be overridden (disabled)
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L1100-L1102@3 -- Even though the minimum deposit amount check will be enabled, this protection can be bypassed by withdrawing the deposited fund later
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L1104-L1106As 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; } }
No minimum withdrawal amount check
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurity.sol#L237-L270Manual Review
Implement the minimum borrowing amount check to limit the minimum size of borrowing positions.
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.
🌟 Selected for report: serial-coder
Also found by: SBSecurity, serial-coder
2444.3801 USDC - $2,444.38
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
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.
This section will refer to the
paybackExactAmountETH()
only for brevity's sake. ThepaybackExactAmount()
andpaybackExactShares()
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; }
No minimum leftover borrowing amount/share check
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L1088-L1155Manual Review
Do not allow a borrower to pay back their position and leave in borrowing amount/share less than the proper minimum threshold.
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.
🌟 Selected for report: serial-coder
5431.9558 USDC - $5,431.96
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.
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(); } }
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(); } }
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
.
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
* @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
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.
🌟 Selected for report: serial-coder
Also found by: 0x11singh99, Jorgect, Mrxstrange, Rhaydden, josephdara, nonseodion, unix515
324.761 USDC - $324.76
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
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()
:
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).
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 ) ); }
@1 -- On some tokens, the transferFrom() will return false instead of reverting a transaction
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L21@2 -- If the transferFrom() returns false, the results == false
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L26-L29@3 -- If the results == false, the _callOptionalReturn() will return false
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/CallOptionalReturn.sol#L35-L37@4 -- 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
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/TransferHub/TransferHelper.sol#L42Note: 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; }
@5 -- Liquidator can steal collateral (_receiveToken) from the target liquidable position
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseCore.sol#L674-L679@6 -- Depositor can increase their collateral without supplying any token
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L476-L481Manual 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"); }
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.
🌟 Selected for report: Dup1337
Also found by: 0x11singh99, NentoR, cheatc0d3, nonseodion, serial-coder, shealtielanz
159.9366 USDC - $159.94
collateralFactor
upper limitIn 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.
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; ... } ... }
@1 -- The MAX_COLLATERAL_FACTOR is defined at 95% maximum
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmDeclarations.sol#L62
@2 -- In the constructor(), the PRECISION_FACTOR_E18 (at 100%) is used as the maximum value instead of the MAX_COLLATERAL_FACTOR
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmDeclarations.sol#L243
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; ... }
MAX_COLLATERAL_FACTOR
constantsThere are different MAX_COLLATERAL_FACTOR
constants defined in the PendlePowerFarmDeclarations
and WiseLendingDeclaration
contracts that can lead to an inconsistency issue.
The MAX_COLLATERAL_FACTOR
constant of the PendlePowerFarmDeclarations
contract is 95e16
(95%
).
The MAX_COLLATERAL_FACTOR
constant of the WiseLendingDeclaration
contract is 85e16
(85%
).
Manual Review
Make both constants to be consistent.
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.
OracleHelper::_addOracle()
will revert the transaction if the price feed
is already set.OracleHelper::_addAggregator()
will revert the transaction if the aggregator
is already set.OracleHelper::_validateTwapOracle()
will revert the transaction if the TWAP oracle
(for a specific token) is already set.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 } }
@1 -- Chainlink's price feed cannot be changed/updated
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseOracleHub/OracleHelper.sol#L19-L21
@2 -- Chainlink's aggregator cannot be changed/updated
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseOracleHub/OracleHelper.sol#L41-L43
@3 -- TWAP oracle for each specific token cannot be changed/updated
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseOracleHub/OracleHelper.sol#L365-L367
Manual Review
Make the oracles to be updatable.
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()
.
OracleHelper::_getChainlinkAnswer()
OracleHelper::sequencerIsDead()
OracleHelper::getLatestRoundId()
PendleLpOracle::latestAnswer()
PtOracleDerivative::latestAnswer()
#1PtOracleDerivative::latestAnswer()
#2PtOraclePure::latestAnswer()
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 ); }
OracleHelper::_getChainlinkAnswer()
: https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseOracleHub/OracleHelper.sol#L253-L258Manual 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