Revert Lend - 0xDemon's results

A lending protocol specifically designed for liquidity providers on Uniswap v3.

General Information

Platform: Code4rena

Start Date: 04/03/2024

Pot Size: $88,500 USDC

Total HM: 31

Participants: 105

Period: 11 days

Judge: ronnyx2017

Total Solo HM: 7

Id: 342

League: ETH

Revert

Findings Distribution

Researcher Performance

Rank: 81/105

Findings: 2

Award: $28.79

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

Findings Information

Awards

18.5042 USDC - $18.50

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
:robot:_49_group
duplicate-249

External Links

Lines of code

https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/V3Vault.sol#L359-L381 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L300-L320

Vulnerability details

Impact

Functionality is not working as expected

Proof of Concept

  1. deposit, mint, withdraw, and redeem functions

As per EIP-4626 :

If implementors intend to support EOA account access directly, they should consider adding an additional function call forย deposit/mint/withdraw/redeemย with the means to accommodate slippage loss or unexpected deposit/withdrawal limits, since they have no other means to revert the transaction if the exact output amount is not achieved.

But in this codebase, the protocol does not follow this. There is no slippage protection for these four main functions which results in users receiving shares (deposit) less than they should and users receiving assets (withdraw) less than they should. Lets take a look these function in the codebase :

    function deposit(uint256 assets, address receiver) external override returns (uint256) {
        (, uint256 shares) = _deposit(receiver, assets, false, "");
        return shares;
    }

    /// @inheritdoc IERC4626
    function mint(uint256 shares, address receiver) external override returns (uint256) {
        (uint256 assets,) = _deposit(receiver, shares, true, "");
        return assets;
    }

    /// @inheritdoc IERC4626
    function withdraw(uint256 assets, address receiver, address owner) external override returns (uint256) {
        (, uint256 shares) = _withdraw(receiver, owner, assets, false);
        return shares;
    }

    /// @inheritdoc IERC4626
    function redeem(uint256 shares, address receiver, address owner) external override returns (uint256) {
        (uint256 assets,) = _withdraw(receiver, owner, shares, true);
        return assets;
    }

It can be seen from the codebase, when the user calls these four functions they will be passed to the internal _deposit and _withdraw functions, the difference is the isShares variable. Lets take a look those functions :

    function _deposit(address receiver, uint256 amount, bool isShare, bytes memory permitData)
        internal
        returns (uint256 assets, uint256 shares)
    {
        (, uint256 newLendExchangeRateX96) = _updateGlobalInterest();

        _resetDailyLendIncreaseLimit(newLendExchangeRateX96, false);

        if (isShare) {
            shares = amount;
            assets = _convertToAssets(shares, newLendExchangeRateX96, Math.Rounding.Up);
        } else {
            assets = amount;
            shares = _convertToShares(assets, newLendExchangeRateX96, Math.Rounding.Down);
        }

        if (permitData.length > 0) {
            (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) =
                abi.decode(permitData, (ISignatureTransfer.PermitTransferFrom, bytes));
            permit2.permitTransferFrom(
                permit, ISignatureTransfer.SignatureTransferDetails(address(this), assets), msg.sender, signature
            );
        } else {
            // fails if not enough token approved
            SafeERC20.safeTransferFrom(IERC20(asset), msg.sender, address(this), assets);
        }

        _mint(receiver, shares);

        if (totalSupply() > globalLendLimit) {
            revert GlobalLendLimit();
        }

        if (assets > dailyLendIncreaseLimitLeft) {
            revert DailyLendIncreaseLimit();
        } else {
            dailyLendIncreaseLimitLeft -= assets;
        }

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    // withdraws lent tokens. can be denominated in token or share amount
    function _withdraw(address receiver, address owner, uint256 amount, bool isShare)
        internal
        returns (uint256 assets, uint256 shares)
    {
        (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) = _updateGlobalInterest();

        if (isShare) {
            shares = amount;
            assets = _convertToAssets(amount, newLendExchangeRateX96, Math.Rounding.Down);
        } else {
            assets = amount;
            shares = _convertToShares(amount, newLendExchangeRateX96, Math.Rounding.Up);
        }

        // if caller has allowance for owners shares - may call withdraw
        if (msg.sender != owner) {
            _spendAllowance(owner, msg.sender, shares);
        }

        (, uint256 available,) = _getAvailableBalance(newDebtExchangeRateX96, newLendExchangeRateX96);
        if (available < assets) {
            revert InsufficientLiquidity();
        }

        // fails if not enough shares
        _burn(owner, shares);
        SafeERC20.safeTransfer(IERC20(asset), receiver, assets);

        // when amounts are withdrawn - they may be deposited again
        dailyLendIncreaseLimitLeft += assets;

        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

In the _deposit function, the protocol only takes into account the user's upper deposit limit with the DailyLendIncreaseLimit and GlobalLendLimit variables but does not take into account how many shares the user should receive when making a deposit. So when a user makes a deposit, he may receive shares less than the amount of the deposit he made.

Meanwhile, for the _withdraw function, the protocol only takes into account the amount of liquidity available when the user makes a withdrawal but does not take into account whether the assets received by the user correspond to the shares burned. So when a user makes a withdrawal, the user may receive assets less than they should be and burning more shares than necessary.

  1. maxDeposit and maxMint functions

As per EIP-4626 :

MUST factor in both global and user-specific limits, like if deposits are entirely disabled (even temporarily) it MUST return 0.

But in the codebase, the protocol does not follow this. When the user calls these two functions, in certain circumstances the results of these two functions do not match what the user can do. Lets take a look these function in the codebase :

    /// @inheritdoc IERC4626
    function maxDeposit(address) external view override returns (uint256) {
        (, uint256 lendExchangeRateX96) = _calculateGlobalInterest();
        uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up);
        if (value >= globalLendLimit) {
            return 0;
        } else {
            return globalLendLimit - value;
        }
    }

    /// @inheritdoc IERC4626
    function maxMint(address) external view override returns (uint256) {
        (, uint256 lendExchangeRateX96) = _calculateGlobalInterest();
        uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up);
        if (value >= globalLendLimit) {
            return 0;
        } else {
            return _convertToShares(globalLendLimit - value, lendExchangeRateX96, Math.Rounding.Down);
        }
    }

As seen in the functions above, the protocol only takes into account the upper limit using globalLendLimit, whereas in the codebase there is also a dailyLendIncreaseLimitLeft variable which functions to limit the number of deposits / mints made each day. When the user's dailyLendIncreaseLimitLeft value reaches the maximum value, the user cannot make deposits or mints. Thus, the maxDeposit and maxMint functions should have a return value = 0 because deposit and mint are in the disabled state.

Tools Used

Manual review

  1. Go through the standard and follow it all.
  2. Add slippage protection
    function deposit(uint256 assets, address receiver, uint256 minShares) external override returns (uint256) {
        (, uint256 shares) = _deposit(receiver, assets, false, "");
        if (shares < minShares) {
	        revert slippageProtection }
        return shares;
    }

    /// @inheritdoc IERC4626
    function mint(uint256 shares, address receiver, uint256 minAssets) external override returns (uint256) {
        (uint256 assets,) = _deposit(receiver, shares, true, "");
        if (assets < minAssets) {
	        revert slippageProtection }
        return assets;
    }

    /// @inheritdoc IERC4626
    function withdraw(uint256 assets, address receiver, address owner, uint256 maxShares) external override returns (uint256) {
        (, uint256 shares) = _withdraw(receiver, owner, assets, false);
        if (shares > maxShares) {
	        revert slippageProtection }
        return shares;
    }

    /// @inheritdoc IERC4626
    function redeem(uint256 shares, address receiver, address owner, uint256 mintAssets) external override returns (uint256) {
        (uint256 assets,) = _withdraw(receiver, owner, shares, true);
        if (assets < minAssets) {
	        revert slippageProtection }
        return assets;
    }
  1. Also pay attention to user specific variables
    /// @inheritdoc IERC4626
    function maxDeposit(address) external view override returns (uint256) {
        (, uint256 lendExchangeRateX96) = _calculateGlobalInterest();
        uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up);
        if (value >= globalLendLimit || value > dailyLendIcreaseLimitLeft) {
            return 0;
        } else {
            return globalLendLimit - value;
        }
    }

    /// @inheritdoc IERC4626
    function maxMint(address) external view override returns (uint256) {
        (, uint256 lendExchangeRateX96) = _calculateGlobalInterest();
        uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up);
        if (value >= globalLendLimit || value > dailyLendIcreaseLimitLeft) {
            return 0;
        } else {
            return _convertToShares(globalLendLimit - value, lendExchangeRateX96, Math.Rounding.Down);
        }
    }

Assessed type

ERC4626

#0 - c4-pre-sort

2024-03-21T07:46:46Z

0xEVom marked the issue as duplicate of #281

#1 - c4-pre-sort

2024-03-21T07:46:49Z

0xEVom marked the issue as sufficient quality report

#2 - 0xEVom

2024-03-21T07:47:10Z

  1. is a recommendation

#3 - c4-judge

2024-03-31T03:21:19Z

jhsagd76 changed the severity to QA (Quality Assurance)

#4 - Emanueldlvg

2024-04-02T11:30:44Z

Hello @jhsagd76, based on the results, I agree that the results from point number 1 of this issue are a duplicate of #281. But for the issue in point number 2, this should go into medium like issue #249. In point number 2, I explained 2 issues out of the 4 issues in #249. Thank you.

#5 - jhsagd76

2024-04-03T00:24:00Z

Hello @jhsagd76, based on the results, I agree that the results from point number 1 of this issue are a duplicate of #281. But for the issue in point number 2, this should go into medium like issue #249. In point number 2, I explained 2 issues out of the 4 issues in #249. Thank you.

Make sense. Dup of 249 with 50%.

#6 - c4-judge

2024-04-03T00:30:47Z

This previously downgraded issue has been upgraded by jhsagd76

#7 - c4-judge

2024-04-03T00:31:04Z

jhsagd76 marked the issue as not a duplicate

#8 - c4-judge

2024-04-03T00:31:21Z

jhsagd76 marked the issue as duplicate of #249

#9 - c4-judge

2024-04-03T00:31:27Z

jhsagd76 marked the issue as partial-50

#10 - c4-judge

2024-04-04T08:32:16Z

jhsagd76 marked the issue as full credit

#11 - c4-judge

2024-04-04T08:32:25Z

jhsagd76 marked the issue as satisfactory

Awards

10.2896 USDC - $10.29

Labels

bug
downgraded by judge
grade-b
primary issue
QA (Quality Assurance)
sponsor disputed
sufficient quality report
edited-by-warden
:robot:_112_group
Q-08

External Links

Lines of code

https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L877-L917 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L954-L1014 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L685-L757

Vulnerability details

Impact

V3Vault.mint(), V3Vault.repay() and V3Vault.liquidate() using signatures to approve user assets. But in those functions, the asset value can change easily so the method can easily revert and can be DoS.

Proof of Concept

Because this is a similar case for both functions, for a deeper explanation i will use V3Vault.mint() with permitData as an example. When the user call the V3Vault.mint() function with permitData it will be passed to the internal function _deposit. _deposit gets the share amount as an input and calculates the asset amount from the share. Then, it approves the asset amount with permit method.

        if (isShare) {
            shares = amount;
            assets = _convertToAssets(shares, newLendExchangeRateX96, Math.Rounding.Up);
        } else {
            assets = amount;
            shares = _convertToShares(assets, newLendExchangeRateX96, Math.Rounding.Down);
        }

        if (permitData.length > 0) {
            (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) =
                abi.decode(permitData, (ISignatureTransfer.PermitTransferFrom, bytes));
            permit2.permitTransferFrom(
                permit, ISignatureTransfer.SignatureTransferDetails(address(this), assets), msg.sender, signature
            );
        } else {
            // fails if not enough token approved
            SafeERC20.safeTransferFrom(IERC20(asset), msg.sender, address(this), assets);
        }

The signature is generated using the exact value of the expected asset amount calculated from the share amount, and the resulting asset amount depends on the exchange rate of current vault. newLendExchangeRateX96 will continue to update if a user makes a deposit or mint, so this value will continue to change which will have an impact on the amount of assets.

(, uint256 newLendExchangeRateX96) = _updateGlobalInterest();
    function _convertToAssets(uint256 shares, uint256 exchangeRateX96, Math.Rounding rounding)
        internal
        pure
        returns (uint256)
    {
        return shares.mulDiv(exchangeRateX96, Q96, rounding);
    }

The resulting asset amount can be different from the value of transaction start time. Thus, malicious actors can easily change newLendExchangeRateX96 so that the share exchange rate for assets will be different and the resulting assets will also be different. This causes, V3Vault.mint() with permitData mostly revert either intentionally or unintentionally and can be DoS by malicious actors.

Another instances :

  1. V3Vault.repay() with permitData :
    function _repay(uint256 tokenId, uint256 amount, bool isShare, bytes memory permitData) internal {
        (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) = _updateGlobalInterest();

        Loan storage loan = loans[tokenId];

        uint256 currentShares = loan.debtShares;

        uint256 shares;
        uint256 assets;

        if (isShare) {
            shares = amount;
            assets = _convertToAssets(amount, newDebtExchangeRateX96, Math.Rounding.Up);
        } else {
            assets = amount;
            shares = _convertToShares(amount, newDebtExchangeRateX96, Math.Rounding.Down);
        }

        // fails if too much repayed
        if (shares > currentShares) {
            revert RepayExceedsDebt();
        }

        if (assets > 0) {
            if (permitData.length > 0) {
                (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) =
                    abi.decode(permitData, (ISignatureTransfer.PermitTransferFrom, bytes));
                permit2.permitTransferFrom(
                    permit, ISignatureTransfer.SignatureTransferDetails(address(this), assets), msg.sender, signature
                );
            } else {
                // fails if not enough token approved
                SafeERC20.safeTransferFrom(IERC20(asset), msg.sender, address(this), assets);
            }
        }

        uint256 loanDebtShares = loan.debtShares - shares;
        loan.debtShares = loanDebtShares;
        debtSharesTotal -= shares;

        // when amounts are repayed - they maybe borrowed again
        dailyDebtIncreaseLimitLeft += assets;

        _updateAndCheckCollateral(
            tokenId, newDebtExchangeRateX96, newLendExchangeRateX96, loanDebtShares + shares, loanDebtShares
        );

        address owner = tokenOwner[tokenId];

        // if fully repayed
        if (currentShares == shares) {
            _cleanupLoan(tokenId, newDebtExchangeRateX96, newLendExchangeRateX96, owner);
        } else {
            // if resulting loan is too small - revert
            if (_convertToAssets(loanDebtShares, newDebtExchangeRateX96, Math.Rounding.Up) < minLoanSize) {
                revert MinLoanSize();
            }
        }

        emit Repay(tokenId, msg.sender, owner, assets, shares);
    }
  1. V3Vault.liquidate() with permitData
    function liquidate(LiquidateParams calldata params) external override returns (uint256 amount0, uint256 amount1) {
        // liquidation is not allowed during transformer mode
        if (transformedTokenId > 0) {
            revert TransformNotAllowed();
        }

        LiquidateState memory state;

        (state.newDebtExchangeRateX96, state.newLendExchangeRateX96) = _updateGlobalInterest();

        uint256 debtShares = loans[params.tokenId].debtShares;
        if (debtShares != params.debtShares) {
            revert DebtChanged();
        }

        state.debt = _convertToAssets(debtShares, state.newDebtExchangeRateX96, Math.Rounding.Up);

        (state.isHealthy, state.fullValue, state.collateralValue, state.feeValue) =
            _checkLoanIsHealthy(params.tokenId, state.debt);
        if (state.isHealthy) {
            revert NotLiquidatable();
        }

        (state.liquidationValue, state.liquidatorCost, state.reserveCost) =
            _calculateLiquidation(state.debt, state.fullValue, state.collateralValue);

        // calculate reserve (before transfering liquidation money - otherwise calculation is off)
        if (state.reserveCost > 0) {
            state.missing =
                _handleReserveLiquidation(state.reserveCost, state.newDebtExchangeRateX96, state.newLendExchangeRateX96);
        }

        if (params.permitData.length > 0) {
            (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) =
                abi.decode(params.permitData, (ISignatureTransfer.PermitTransferFrom, bytes));
            permit2.permitTransferFrom(
                permit,
                ISignatureTransfer.SignatureTransferDetails(address(this), state.liquidatorCost),
                msg.sender,
                signature
            );
        } else {
            // take value from liquidator
            SafeERC20.safeTransferFrom(IERC20(asset), msg.sender, address(this), state.liquidatorCost);
        }

        debtSharesTotal -= debtShares;

        // send promised collateral tokens to liquidator
        (amount0, amount1) =
            _sendPositionValue(params.tokenId, state.liquidationValue, state.fullValue, state.feeValue, msg.sender);

        if (amount0 < params.amount0Min || amount1 < params.amount1Min) {
            revert SlippageError();
        }

        address owner = tokenOwner[params.tokenId];

        // disarm loan and send remaining position to owner
        _cleanupLoan(params.tokenId, state.newDebtExchangeRateX96, state.newLendExchangeRateX96, owner);

        emit Liquidate(
            params.tokenId,
            msg.sender,
            owner,
            state.fullValue,
            state.liquidatorCost,
            amount0,
            amount1,
            state.reserveCost,
            state.missing
        );
    }

Tools Used

Manual review

Consider using upper bound amount of asset as input for permit instead of exact value.

Assessed type

ERC4626

#0 - c4-pre-sort

2024-03-22T11:23:22Z

0xEVom marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-03-22T11:23:26Z

0xEVom marked the issue as primary issue

#2 - kalinbas

2024-03-26T14:59:32Z

If a user wants to use permit2 for operations with shares, he must include "an upper bound" as you say. Give permit for a bit more assets than expected. But this should work as it is implemented now. Its the same for "approved" assets, there needs to be enough approval to cover the "current asset amount".

#3 - c4-sponsor

2024-03-26T14:59:44Z

kalinbas (sponsor) disputed

#4 - jhsagd76

2024-03-31T00:02:08Z

Agreeing with the sponsor's viewpoint, but indeed it is a point that could enhance user experience, downgrading to low.

#5 - c4-judge

2024-03-31T00:02:18Z

jhsagd76 changed the severity to QA (Quality Assurance)

#6 - c4-judge

2024-03-31T00:02:23Z

jhsagd76 marked the issue as grade-a

#7 - Emanueldlvg

2024-04-03T04:00:18Z

Hello @jhsagd76 , I think there will be a different problem for the liquidate function, because the liquidator.cost value changes depending on the value of the position to be liquidated. If in the standard case liquidator.cost = debt this is not a problem because the liquidator knows the debt that must be paid. But if the position has a value that is more than debt with max penalty then the value of liquidator.cost will be different. This will be a problem because the permit value of liquidator.cost at the beginning will be different at the end and this value will have a large gap so it is very likely that this function will revert.

In my opinion, this issue is valid as a medium because it is very likely that there will be a change in the liquidator.cost value from the initial value to the final value and the liquidator cannot determine this value with certainty.

#8 - jhsagd76

2024-04-04T03:26:17Z

We don't need to discuss the functional issues any further; the implementation of permit2.permitTransferFrom is as you said, using an upper bound amount of asset as input. We don't need and can't determine the exact value, similar to approve, where you need to authorize a higher value to ensure execution. Please review this part of the code implementation https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/SignatureTransfer.sol#L51-L62

#9 - jhsagd76

2024-04-04T03:30:39Z

After further considering the validity of this issue, I believe this is a characteristic that must be accepted when using permit2, rather than a risk.

#10 - c4-judge

2024-04-04T03:30:49Z

jhsagd76 marked the issue as grade-b

#11 - Emanueldlvg

2024-04-04T03:32:11Z

Well if that's the final decision, thanks for explaining

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