GithubHelp home page GithubHelp logo

2024-04-gondi-findings's Introduction

Gondi Audit

Audit findings are submitted to this repo.

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.

Note that when the repo is public, after all issues are mitigated, your comments will be publicly visible; they may also be included in your C4 audit report.


Review phase

Sponsors have three critical tasks in the audit process: Reviewing the two lists of curated issues, and once you have mitigated your findings, sharing those mitigations.

  1. Respond to curated High- and Medium-risk submissions ↓
  2. Respond to curated Low/Non-critical submissions and Gas optimizations ↓
  3. Share your mitigation of findings (optional) ↓

Note: It’s important to be sure to only review issues from the curated lists. There are two lists of curated issues to review, which filter out unsatisfactory issues that don't require your attention.


     

Types of findings

(expand to read more)

High or Medium risk findings

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

QA reports, Gas reports, and Analyses

Any warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

1. Respond to curated High- and Medium-risk submissions

This curated list will shorten as you work. View the original, longer list →

For each curated High or Medium risk finding, please:

1a. Label as one of the following:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note: Adding or changing labels other than those in this list will be automatically reverted by our bot, which will note the change in a comment on the issue.

1b. Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, leave a comment indicating your reasoning for the judge to review. For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.


2. Respond to curated Low/Non-critical submissions and Gas optimizations

This curated list will shorten as you work. View the original, longer list →

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once Step 1 and 2 are complete

When you have finished labeling and responding to findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.


3. Share your mitigation of findings (Optional)

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-04-gondi-findings's People

Contributors

c4-bot-8 avatar c4-bot-6 avatar c4-bot-7 avatar c4-bot-5 avatar c4-bot-9 avatar c4-bot-2 avatar c4-bot-10 avatar c4-bot-1 avatar c4-bot-4 avatar c4-bot-3 avatar kartoonjoy avatar liveactionllama avatar c4-judge avatar geoffchan23 avatar code4rena-id[bot] avatar

Watchers

Ashok avatar

2024-04-gondi-findings's Issues

confirmBaseInterestAllocator() change BaseInterestAllocator may pay large getReallocationBonus

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L211

Vulnerability details

Vulnerability details

owner can submit getPendingBaseInterestAllocator first, and then anyone can enable it by confirmBaseInterestAllocator().

    function confirmBaseInterestAllocator(address _newBaseInterestAllocator) external {
        address cachedAllocator = getBaseInterestAllocator;
        if (cachedAllocator != address(0)) {
            if (getPendingBaseInterestAllocatorSetTime + UPDATE_WAITING_TIME > block.timestamp) {
                revert TooSoonError();
            }
            if (getPendingBaseInterestAllocator != _newBaseInterestAllocator) {
                revert InvalidInputError();
            }
@>          IBaseInterestAllocator(cachedAllocator).transferAll();
            asset.approve(cachedAllocator, 0);
        }
        asset.approve(_newBaseInterestAllocator, type(uint256).max);

        getBaseInterestAllocator = _newBaseInterestAllocator;
        getPendingBaseInterestAllocator = address(0);
        getPendingBaseInterestAllocatorSetTime = type(uint256).max;

        emit BaseInterestAllocatorSet(_newBaseInterestAllocator);
    }

The current logic is

  1. take all the balance of the old BaseInterestAllocator and put it in Pool.
  2. change getBaseInterestAllocator to the new BaseInterestAllocator.

If the old BaseInterestAllocator already has a large balance, the balance of the Pool will increase dramatically.

Subsequent users executing reallocate() will get a big bonus getReallocationBonus.

    function reallocate() external nonReentrant returns (uint256) {
        (uint256 currentBalance, uint256 targetIdle) = _reallocate();
        uint256 delta = currentBalance > targetIdle ? currentBalance - targetIdle : targetIdle - currentBalance;
@>      uint256 shares = delta.mulDivDown(totalSupply * getReallocationBonus, totalAssets() * _BPS);

        _mint(msg.sender, shares);

        emit Reallocated(delta, shares);

        return shares;
    }

Assuming old BaseInterestAllocator balance:1 M

shares = 1 M * (1 - optimalIdleRange.mid) * totalSupply * getReallocationBonus / totalAssets()

Impact

after change BaseInterestAllocator, may pay large getReallocationBonus

Recommended Mitigation

Execute _reallocate() in the confirmBaseInterestAllocator() method without paying any getReallocationBonus.

    function confirmBaseInterestAllocator(address _newBaseInterestAllocator) external {
        address cachedAllocator = getBaseInterestAllocator;
        if (cachedAllocator != address(0)) {
            if (getPendingBaseInterestAllocatorSetTime + UPDATE_WAITING_TIME > block.timestamp) {
                revert TooSoonError();
            }
            if (getPendingBaseInterestAllocator != _newBaseInterestAllocator) {
                revert InvalidInputError();
            }
            IBaseInterestAllocator(cachedAllocator).transferAll();
            asset.approve(cachedAllocator, 0);
        }
        asset.approve(_newBaseInterestAllocator, type(uint256).max);

        getBaseInterestAllocator = _newBaseInterestAllocator;
        getPendingBaseInterestAllocator = address(0);
        getPendingBaseInterestAllocatorSetTime = type(uint256).max;
+       if (cachedAllocator != address(0)) {
+             _reallocate();
+       }
        emit BaseInterestAllocatorSet(_newBaseInterestAllocator);
    }

Assessed type

Context

Incorrect accounting of _pendingWithdrawal in queueClaiming flow

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L678

Vulnerability details

Impact

Incorrect accounting of _pendingWithdrawal in queueClaiming flow, funds received from a previous queue index will be lost.

Proof of Concept

In Pool.sol - queueClaimAll(), each queue's received funds getTotalReceived[_idx] (total returned funds from loans for that queue) will be distributed to all newer queues in a for-loop.

There are two for-loops in this flow. First for-loop iterate through each pendingWithdrawal index to get the received funds for that queue index (getTotalReceived[_idx]). Second for-loop iterates through each queue index again to distribute funds from _idx.

The problem is in the second for-loop, _pendingWithdrawal[secondIdx] will not accumulate distributed funds from previous queue indexes, instead it erases the value from previous loops and only records the last queue's received funds.

//src/lib/pools/Pool.sol
    function _updatePendingWithdrawalWithQueue(
        uint256 _idx,
        uint256 _cachedPendingQueueIndex,
        uint256[] memory _pendingWithdrawal
    ) private returns (uint256[] memory) {
        uint256 totalReceived = getTotalReceived[_idx];
        uint256 totalQueues = getMaxTotalWithdrawalQueues + 1;
...
        getTotalReceived[_idx] = 0;
...
        for (uint256 i; i < totalQueues;) {
...
              //@audit this should be _pendingWithdraw[secondIdx] += pendingForQueue; Current implementation directly erases `pendingForQueue` value distributed from other queues. 
|>            _pendingWithdrawal[secondIdx] = pendingForQueue;
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L678)
Note that getTotalReceived[_idx] will be cleared before the for-loop(getTotalReceived[_idx] = 0), meaning that the erased pendingForQueue values from previous loops cannot be recovered. _pendingWithdrawal will be incorrect.

Tools Used

Manual

Recommended Mitigation Steps

Change into _pendingWithdrawal[secondIdx] + = pendingForQueue;

Assessed type

Error

validateOffer() shouldn't be able to use getCollectedFees

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L396

Vulnerability details

Vulnerability details

in validateOffer() can use getCollectedFees for payments

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
        if (!isActive) {
            revert PoolStatusError();
        }
@>      uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
        uint256 undeployedAssets = currentBalance + baseRateBalance;
        (uint256 principalAmount, uint256 apr) = IPoolOfferHandler(getUnderwriter).validateOffer(
            IBaseInterestAllocator(getBaseInterestAllocator).getBaseAprWithUpdate(), _offer
        );

currentBalance contains getCollectedFees.

But _getUndeployedAssets() subtracts getCollectedFees.

This may cause _getUndeployedAssets() to underflow

    function _getUndeployedAssets() private view returns (uint256) {
        return asset.balanceOf(address(this)) + IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated()
@>          - getAvailableToWithdraw - getCollectedFees;
    }

Example:
balanceOf(address(this)) = 150
getCollectedFees = 50
getAvailableToWithdraw = 0
IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated() = 0
offer.principalAmount = 150

This way when the offer is executed, it is transferred away 150
_getUndeployedAssets() = 0 + 0 - 0 - 50 = -50 (underflow)

Impact

_getUndeployedAssets() underflow will cause most of ERC4626 methods to fail.

Recommended Mitigation

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
        if (!isActive) {
            revert PoolStatusError();
        }
-       uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
+      uint256 currentBalance =  asset.balanceOf(address(this)) - getAvailableToWithdraw - getCollectedFees;
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
        uint256 undeployedAssets = currentBalance + baseRateBalance;
        (uint256 principalAmount, uint256 apr) = IPoolOfferHandler(getUnderwriter).validateOffer(
            IBaseInterestAllocator(getBaseInterestAllocator).getBaseAprWithUpdate(), _offer
        );

Assessed type

Context

Loans that are being liquidated can still perform mergeTranches/refinance operations

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L649

Vulnerability details

Impact

For the check of loan time, the case _loan.startTime + _loan.duration = block.timestamp is not taken into account.
If liquidateLoan is executed at block.timestamp = _loan.startTime + _loan.duration,
The mergeTranches/refinance function can also be executed within the same block, generating a new loanId that will result in the collateral not being auctioned.

Proof of Concept

First let's look at the loan maturity check:

  1. In _liquidateLoan, if block.timestamp = _loan.startTime + _loan.duration, revert will not be generated.
    The liquidateLoan function can be executed.
    function _liquidateLoan(uint256 _loanId, IMultiSourceLoan.Loan calldata _loan, bool _canClaim)
        internal
        returns (bool liquidated, bytes memory liquidation)
    {
        uint256 expirationTime = _loan.startTime + _loan.duration;
        if (expirationTime > block.timestamp) {
            revert LoanNotDueError(expirationTime);
        }
    }
  1. The _baseLoanChecks function will not revert if block.timestamp = _loan.startTime + _loan.duration,
    can pass the inspection.
    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
        if (_loan.startTime + _loan.duration < block.timestamp) {
            revert LoanExpiredError();
        }
    }

Then let's look at the liquidateLoan function:

    function liquidateLoan(uint256 _loanId, Loan calldata _loan)
        external
        override
        nonReentrant
        returns (bytes memory)
    {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
        (bool liquidated, bytes memory liquidation) = _liquidateLoan(
            _loanId, _loan, _loan.tranche.length == 1 && !getLoanManagerRegistry.isLoanManager(_loan.tranche[0].lender)
        );
        
@>      if (liquidated) {
            delete _loans[_loanId];
        }
        return liquidation;
    }

liquidateLoan function, in some cases (_canClaim! = true), will not immediately delete _loans[_loanId],

  function _liquidateLoan(uint256 _loanId, IMultiSourceLoan.Loan calldata _loan, bool _canClaim)
        internal
        returns (bool liquidated, bytes memory liquidation)
    {
        uint256 expirationTime = _loan.startTime + _loan.duration;
        // if(_loan.startTime + _loan.duration > block.timestamp) 没到期
        if (expirationTime > block.timestamp) {
            revert LoanNotDueError(expirationTime);
        }
@>      if (_canClaim) {
            ERC721(_loan.nftCollateralAddress).transferFrom(
                address(this), _loan.tranche[0].lender, _loan.nftCollateralTokenId
            );
            emit LoanForeclosed(_loanId);

@>          liquidated = true;
        } else {
            ERC721(_loan.nftCollateralAddress).transferFrom(address(this), _loanLiquidator, _loan.nftCollateralTokenId);
            liquidation = ILoanLiquidator(_loanLiquidator).liquidateLoan(
                _loanId,
                _loan.nftCollateralAddress,
                _loan.nftCollateralTokenId,
                _loan.principalAddress,
                _liquidationAuctionDuration,
                msg.sender
            );

            emit LoanSentToLiquidator(_loanId, _loanLiquidator);
        }
    }

when _canClaim! = true, will enter the auction, after the successful auction will be called back in the MultiSourceLoan#loanLiquidated.
_loans[_loanId] is deleted.

    function loanLiquidated(uint256 _loanId, Loan calldata _loan) external override onlyLiquidator {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
        emit LoanLiquidated(_loanId);
        /// @dev Reclaim space.
        delete _loans[_loanId];
    }

The loanLiquidated function will also check _loanId and revert if it does not exist.

    //AuctionLoanLiquidator#settleAuction
    function settleAuction(Auction calldata _auction, IMultiSourceLoan.Loan calldata _loan) external nonReentrant {
        .....
        IMultiSourceLoan(_auction.loanAddress).loanLiquidated(_auction.loanId, _loan);
        .....
    }

Let's look at the mergeTranches and refinanceFull functions again
These functions call _baseLoanChecks to check that the loan exists and has not expired.
As mentioned earlier, if block.timestamp = _loan.startTime + _loan.duration this expired check will pass.

Therefore, in the case where the expiration time is exactly equal to the current time, if liquidation occurs at this time, and _canClaim! = true,
At this time in the same block mergeTranches/refinanceXXX can still be invoked, because _loanId were not immediately deleted.

If the mergeTranches/refinanceXXX function _loanId is called, it is reset, the old id is removed, and the new loanId is added.

But by this time the loan had been liquidated, replacing _loanId will cause the loanLiquidated call to fail and the auction will not complete.

Tools Used

vscode, manual

Recommended Mitigation Steps

    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
-        if (_loan.startTime + _loan.duration < block.timestamp) {
+        if (_loan.startTime + _loan.duration <= block.timestamp) {
            revert LoanExpiredError();
        }
    }

Assessed type

Other

distribute() when can't repay all lenders, may lack of notification to LoanManager for accounting

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/LiquidationDistributor.sol#L63

Vulnerability details

Vulnerability details

The LiquidationDistributor is used to distribute funds after an auction.
When the auction amount is insufficient, lenders are repaid in sequence.

    function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
...
        if (_proceeds > totalPrincipalAndPaidInterestOwed + totalPendingInterestOwed) {
            for (uint256 i = 0; i < _loan.tranche.length;) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                _handleTrancheExcess(
                    _loan.principalAddress,
                    thisTranche,
                    msg.sender,
                    _proceeds,
                    totalPrincipalAndPaidInterestOwed + totalPendingInterestOwed
                );
                unchecked {
                    ++i;
                }
            }
        } else {
@>          for (uint256 i = 0; i < _loan.tranche.length && _proceeds > 0;) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                _proceeds = _handleTrancheInsufficient(
                    _loan.principalAddress, thisTranche, msg.sender, _proceeds, owedPerTranche[i]
                );
                unchecked {
                    ++i;
                }
            }
        }
    }

The code snippet above introduces a condition _proceeds > 0 to terminate the loop when there's no remaining balance in _proceeds, thereby preventing further execution of _handleTrancheInsufficient().

However, this approach creates an issue: if the subsequent lender is a LoanManager, it won't be notified for accounting via _handleTrancheInsufficient()->_handleLoanManagerCall() -> LoanManager(_tranche.lender).loanLiquidation()

Although no funds can be repaid, accounting is still necessary to notice and prevent incorrect accounting. causing inaccuracies in totalAssets() and continued accumulation of interest.
This outstanding debt should be shared among current users and prevent it from persisting as bad debt.

Impact

Failure to notify LoanManager.loanLiquidation() may result in accounting inaccuracies.

Recommended Mitigation

    function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
...
        } else {
-           for (uint256 i = 0; i < _loan.tranche.length && _proceeds > 0;) {
+           for (uint256 i = 0; i < _loan.tranche.length;) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                _proceeds = _handleTrancheInsufficient(
                    _loan.principalAddress, thisTranche, msg.sender, _proceeds, owedPerTranche[i]
                );
                unchecked {
                    ++i;
                }
            }
        }
    }

Assessed type

Context

Incorrect protocol fee implementation results in outstandingValues to be mis-accounted in Pool.sol

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/LiquidationDistributor.sol#L117

Vulnerability details

Impact

Incorrect protocol fee implementation results in outstandingValues to be mis-accounted in Pool.sol

Proof of Concept

The vulnerability is that LiquidationDistributer::_handleLoanMangerCall hardcode 0 as protocol fee when calling LoanManager(_tranche.lender).loanLiquidation().

//src/lib/LiquidationDistributor.sol
    function _handleLoanManagerCall(IMultiSourceLoan.Tranche calldata _tranche, uint256 _sent) private {
        if (getLoanManagerRegistry.isLoanManager(_tranche.lender)) {
            LoanManager(_tranche.lender).loanLiquidation(
                _tranche.loanId,
                _tranche.principalAmount,
                _tranche.aprBps,
                _tranche.accruedInterest,
   |>           0,  //@audit this should be the actual protocol fee fraction
                _sent,
                _tranche.startTime
            );
        }
    }

_handleLoanManagerCall() will be called as part of the flow to distribute proceeds from a liquidation.

When protocol fee is hardcoded 0, in the Pool::loanliquidation call, netApr will not account for protocol fee fraction which will inflate the _apr used to offset _outstandingValues.sumApr, a state variable that accounts for the total annual apr of outstanding loans.

//src/lib/pools/Pool.sol
        OutstandingValues memory __outstandingValues,
        uint256 _principalAmount,
        uint256 _apr,
        uint256 _interestEarned
    ) private view returns (OutstandingValues memory) {
...
         //@audit inflated _apr will offset __outstandingValues.sumApr to an incorrect lower value, causing accounting error
|>        __outstandingValues.sumApr -= uint128(_apr * _principalAmount);
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L751)

For comparison, when a loan is created (pool::validateOffer), the actual protocol fee (protocolFee.fraction) will be passed, and __outstandingValues.sumApr will be added with the post-fee apr value, instead of the before-fee apr.

State accounting __outstandingValues will be incorrect, all flows that consume __outstandingValues.sumApr when calculating interests will be affected.

Tools Used

Manual

Recommended Mitigation Steps

User _loan.protocolFee instead of 0

Assessed type

Other

Hardcoded incorrect getLidoData timestamp, resulting in incorrect base point Apr. Loans can be validated with a substantially low baseRate interest

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/LidoEthBaseInterestAllocator.sol#L62

Vulnerability details

Impact

Hardcoded incorrect getLidoData timestamp, resulting in incorrect base point Apr. Loans can be validated with a substantially low baseRate interest

Proof of Concept

In LidoEthBaseInterestAllocator.sol, getLidoData is initialized with an incorrect timestamp, causing subsequent baseApr to be incorrect.

In constructor, getLidoData is initialized with 0 timestamp. This should be block.timestamp instead. As a result, baseApr updates will be much lower.

//src/lib/pools/LidoEthBaseInterestAllocator.sol

    struct LidoData {
        uint96 lastTs;
        uint144 shareRate;
        uint16 aprBps;
    }
...
    constructor(
        address _pool,
        address payable __curvePool,
        address payable __weth,
        address __lido,
        uint256 _currentBaseAprBps,
        uint96 _lidoUpdateTolerance
    ) Owned(tx.origin) {
...
          //@audit 0-> block.timestamp
|>        getLidoData = LidoData(0, uint144(_currentShareRate()), uint16(_currentBaseAprBps));
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/LidoEthBaseInterestAllocator.sol#L62)

For example, in _updateLidoValue(), _lidoData.aprBps is calculated based on delta shareRate, divided by delta timespan. (_BPS * _SECONDS_PER_YEAR * (shareRate - _lidoData.shareRate) / _lidoData.shareRate / (block.timestamp - _lidoData.lastTs)) shareRate and _lidoData.shareRate are associated with current timestamp, and deployment timestamp respectively. But timespan would be (block.timestamp - 0). This deflated _lidoData.aprBps value, which is used to validate Loan offers in Pool.sol during loan initiation.

In PoolOfferHandler.sol, this allows loan offers with substantially low aprBps to pass the minimal apr check.

//src/lib/pools/PoolOfferHandler.sol
    function validateOffer(uint256 _baseRate, bytes calldata _offer)
        external
        view
        override
        returns (uint256 principalAmount, uint256 aprBps)
    {
...
        if (offerExecution.offer.aprBps < _baseRate + aprPremium || aprPremium == 0) {
            revert InvalidAprError();
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/PoolOfferHandler.sol#L165)

Loans with invalid aprs can be created.

Tools Used

Manual

Recommended Mitigation Steps

Use block.timestamp to initialize getLidoData.

Assessed type

Error

The setTerms/confirmTerms in PoolOfferHandler can be attack

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/PoolOfferHandler.sol#L100

Vulnerability details

Impact

An attacker calls confirmTerms by passing only one _termKeys, leaving the other _termKeys unset.

Proof of Concept

In PoolOfferHandler, setTerms is a function that can only have administrative calls,confirmTerms can be called anywhere.

The administrator calls setTerms and puts the argument in _pendingTerms with a time lock,

After the time lock expires, anyone can call confirmTerms to set the actual _terms.

    function setTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external onlyOwner {
        if (_termKeys.length != __terms.length) {
            revert InvalidInputError();
        }
        for (uint256 i = 0; i < __terms.length; i++) {
            if (_termKeys[i].duration > getMaxDuration) {
                revert InvalidDurationError();
            }
            _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i].maxSeniorRepayment][__terms[i]
                .principalAmount] = __terms[i].aprPremium;
        }
        uint256 ts = block.timestamp;
        pendingTermsSetTime = ts;

        emit PendingTermsSet(_termKeys, __terms, ts);
    }

        function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external {
        if (block.timestamp - pendingTermsSetTime < NEW_TERMS_WAITING_TIME) {
            revert TooSoonError();
        }
        for (uint256 i = 0; i < __terms.length; i++) {
            if (_termKeys[i].duration > getMaxDuration) {
                revert InvalidDurationError();
            }
            uint256 pendingAprPremium = _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i]
                .maxSeniorRepayment][__terms[i].principalAmount];
            if (pendingAprPremium != __terms[i].aprPremium) {
                revert InvalidTermsError();
            }
@>          _terms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i].maxSeniorRepayment][__terms[i]
                .principalAmount] = __terms[i].aprPremium;
            delete _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i]
                .maxSeniorRepayment][__terms[i].principalAmount];
        }
@>      pendingTermsSetTime = type(uint256).max;

        emit TermsSet(_termKeys, __terms);
    }

The problem here is that a malicious caller, when calling confirmTerms, can pass only one element in an array of parameters and ignore the rest. pendingTermsSetTimewill be set totype(uint256).max` when the for loop is over,

The setTerms operation will fail, only a _term is set correctly, requiring the administrator to call setTerms again and wait for the time lock to end. After the administrator is reset, the attacker can still attack again.

Tools Used

vscode, manual

Recommended Mitigation Steps

-    function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external {
+    function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external onlyOwner{

Assessed type

Access Control

An udpated liquidationAuctionDuration parameter might DOS placing bid in AuctionWithBuyoutLoanLiquidator.sol

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L128-L129

Vulnerability details

Impact

An udpated liquidationAuctionDuration parameter might DOS placing bid in AuctionWithBuyoutLoanLiquidator.sol

Proof of Concept

In AuctionWithBuyoutLoanLiquidator.sol, placing bid is only allowed after the settleWithBuyout window.

The vulnerability is that (1) AuctionWithBuyoutLoanLiquidator.sol is intended to work with multiple loan contracts with potentially different liquidationAuctionDuration parameter set-up; (2) There is no way to ensure any liquidationAuctionDuration will be strictly greater than the buyout window.

This means that if any loan contract's liquidationAuctionDuration is updated to be lower than _timeForMainLenderToBuy. No bids can be placed. After the buyout window, aution would have already expired.

//src/lib/AuctionWithBuyoutLoanLiquidator.sol
    function _placeBidChecks(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
        internal
        view
        override
    {
...
        //@audit placeBid check is against the buyout window limit, when the auction duration is smaller than the buyout window, user cannot place bid.
|>      uint256 timeLimit = _auction.startTime + _timeForMainLenderToBuy;
        if (timeLimit > block.timestamp) {
            revert OptionToBuyStilValidError(timeLimit);
        }

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L128C1-L128C74)

//
    function placeBid(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
...
//@audit _auction.duration is _liquidationAuctionDuration, auction would expired before bidding is allowed after the buyout window.
        uint96 expiration = _auction.startTime + _auction.duration;
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L235)

Tools Used

Manual

Recommended Mitigation Steps

In AuctionWithBuyoutLoanLiquidator.sol, consider adding liquidationAuctionDuration in addition to the buy-out window _timeForMainLenderToBuy, because during the buy-out no bids can be placed anyway.

Assessed type

Other

deployWithdrawalQueue() need clear _queueAccounting[lastQueueIndex]

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L378

Vulnerability details

Vulnerability details

in deployWithdrawalQueue() only clear _queueOutstandingValues[lastQueueIndex] and _outstandingValues
but not clear _queueAccounting[lastQueueIndex]

    function deployWithdrawalQueue() external nonReentrant {
...

        /// @dev We move outstaning values from the pool to the queue that was just deployed.
        _queueOutstandingValues[pendingQueueIndex] = _outstandingValues;
        /// @dev We clear values of the new pending queue.
        delete _queueOutstandingValues[lastQueueIndex];
        delete _outstandingValues;
@>      //@audit miss delete _queueAccounting[lastQueueIndex]

        _updateLoanLastIds();

@>      _pendingQueueIndex = lastQueueIndex;

        // Cannot underflow because the sum of all withdrawals is never larger than totalSupply.
        unchecked {
            totalSupply -= sharesPendingWithdrawal;
        }
    }

after this method , anyone call queueClaimAll() will use this stale data _queueAccounting[lastQueueIndex]

queueClaimAll() -> _queueClaimAll(_pendingQueueIndex)-> _updatePendingWithdrawalWithQueue(_pendingQueueIndex)

    function _updatePendingWithdrawalWithQueue(
        uint256 _idx,
        uint256 _cachedPendingQueueIndex,
        uint256[] memory _pendingWithdrawal
    ) private returns (uint256[] memory) {
        uint256 totalReceived = getTotalReceived[_idx];
        uint256 totalQueues = getMaxTotalWithdrawalQueues + 1;
        /// @dev Nothing to be returned
        if (totalReceived == 0) {
            return _pendingWithdrawal;
        }
        getTotalReceived[_idx] = 0;

        /// @dev We go from idx to newer queues. Each getTotalReceived is the total
        /// returned from loans for that queue. All future queues/pool also have a piece of it.
        /// X_i: Total received for queue `i`
        /// X_1  = Received * shares_1 / totalShares_1
        /// X_2 = (Received - (X_1)) * shares_2 / totalShares_2 ...
        /// Remainder goes to the pool.
        for (uint256 i; i < totalQueues;) {
            uint256 secondIdx = (_idx + i) % totalQueues;
@>          QueueAccounting memory queueAccounting = _queueAccounting[secondIdx];
            if (queueAccounting.thisQueueFraction == 0) {
                unchecked {
                    ++i;
                }
                continue;
            }
            /// @dev We looped around.
@>          if (secondIdx == _cachedPendingQueueIndex + 1) {
                break;
            }
            uint256 pendingForQueue = totalReceived.mulDivDown(queueAccounting.thisQueueFraction, PRINCIPAL_PRECISION);
            totalReceived -= pendingForQueue;

            _pendingWithdrawal[secondIdx] = pendingForQueue;
            unchecked {
                ++i;
            }
        }
        return _pendingWithdrawal;
    }

Impact

not clear _queueAccounting[lastQueueIndex] , when execute queueClaimAll() will use this stale data to distribute totalReceived

Recommended Mitigation

    function deployWithdrawalQueue() external nonReentrant {
...

        /// @dev We move outstaning values from the pool to the queue that was just deployed.
        _queueOutstandingValues[pendingQueueIndex] = _outstandingValues;
        /// @dev We clear values of the new pending queue.
        delete _queueOutstandingValues[lastQueueIndex];
+       delete _queueAccounting[lastQueueIndex]
        delete _outstandingValues;


        _updateLoanLastIds();

        _pendingQueueIndex = lastQueueIndex;

        // Cannot underflow because the sum of all withdrawals is never larger than totalSupply.
        unchecked {
            totalSupply -= sharesPendingWithdrawal;
        }
    }

Assessed type

Context

The attackers front-running `repayloans` so that the debt cannot be repaid

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L405

Vulnerability details

Impact

The attackers make it impossible for borrowers to repay their debts, and the collateral is liquidated when the debts mature.

Proof of Concept

repayLoan, need to check the loanId, if the id is inconsistent will revert.

    function repayLoan(LoanRepaymentData calldata _repaymentData) external override nonReentrant {
        uint256 loanId = _repaymentData.data.loanId;
        Loan calldata loan = _repaymentData.loan;
        .....
@>      _baseLoanChecks(loanId, loan);
        .....
    }
    
    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
        if (_loan.startTime + _loan.duration < block.timestamp) {
            revert LoanExpiredError();
        }
    }

The problem is that _loans[_loanId] can change, for example, when mergeTranches delete the old loanId and write the new one.

    _loans[loanId] = loanMergedTranches.hash();
    delete _loans[_loanId];

An attacker can use the front-running attack method,
when repayLoan is called, execute the mergeTranches function in advance, and make the id in _loans updated.
In this case, the repayLoan execution will fail due to inconsistent _loanId.

If the attacker keeps using this attack, the borrower's debt will not be repaid, eventually causing the collateral to be liquidated.

In addition to the mergeTranches function, the attacker can also call addNewTranche, and the borrower can also call the refinance-related function, again causing _loanId to be updated.

An attacker can also use the same method to attack refinance related functions, making refinance unable to execute.

An attacker can also use the same method to attack the liquidateLoan function, making it impossible for debts to be cleared.

Tools Used

vscode, manual

Recommended Mitigation Steps

Do not delete _loanId

Assessed type

DoS

mergeTranches() If the lender is a LoanManager it will break the Pool accounting

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L1133

Vulnerability details

Vulnerability details

Anyone can run mergeTranches() to merge similar tranches with the same lender and startTime into a new tranche.

    function mergeTranches(uint256 _loanId, Loan memory _loan, uint256 _minTranche, uint256 _maxTranche)
        external
        returns (uint256, Loan memory)
    {
        _baseLoanChecks(_loanId, _loan);
        uint256 loanId = _getAndSetNewLoanId();
        Loan memory loanMergedTranches = _mergeTranches(loanId, _loan, _minTranche, _maxTranche);
        _loans[loanId] = loanMergedTranches.hash();
        delete _loans[_loanId];

        emit TranchesMerged(loanMergedTranches, _minTranche, _maxTranche);

        return (loanId, loanMergedTranches);
    }

    function _mergeTranches(
        uint256 _newLoanId,
        IMultiSourceLoan.Loan memory _loan,
        uint256 _minTranche,
        uint256 _maxTranche
    ) private pure returns (IMultiSourceLoan.Loan memory) {
        /// @dev if the diff is also just 1, then we wouldn't be merging anything
        if (_minTranche >= _maxTranche - 1) {
            revert InvalidParametersError();
        }

        IMultiSourceLoan.Tranche[] memory tranche =
            new IMultiSourceLoan.Tranche[](_loan.tranche.length - (_maxTranche - _minTranche) + 1);

        uint256 originalIndex = 0;
        /// @dev Copy tranches before
        for (; originalIndex < _minTranche;) {
            tranche[originalIndex] = _loan.tranche[originalIndex];
            unchecked {
                ++originalIndex;
            }
        }

        /// @dev Merge tranches. Just picking one, they must all match.
        address lender = _loan.tranche[_minTranche].lender;
        uint256 startTime = _loan.tranche[_minTranche].startTime;

        uint256 principalAmount;
        uint256 cumAprBps;
        uint256 accruedInterest;
        /// @dev Use to validate _totalTranches
        for (; originalIndex < _maxTranche;) {
            IMultiSourceLoan.Tranche memory thisTranche = _loan.tranche[originalIndex];
            if (lender != thisTranche.lender || startTime != thisTranche.startTime) {
                revert MismatchError();
            }
            principalAmount += thisTranche.principalAmount;
            cumAprBps += thisTranche.aprBps * thisTranche.principalAmount;
            accruedInterest += thisTranche.accruedInterest;
            unchecked {
                ++originalIndex;
            }
        }
        /// @dev Output of merged tranches.
        tranche[_minTranche] = IMultiSourceLoan.Tranche(
@>          _newLoanId,
            _loan.tranche[_minTranche].floor,
            principalAmount,
            lender,
            accruedInterest,
            startTime,
            cumAprBps / principalAmount
        );

        /// @dev Copy remaining ones
        uint256 remainingIndex = _minTranche + 1;
        for (; originalIndex < _loan.tranche.length;) {
            tranche[remainingIndex] = _loan.tranche[originalIndex];
            unchecked {
                ++originalIndex;
                ++remainingIndex;
            }
        }
        _loan.tranche = tranche;

        return _loan;
    }

The newly merged tranche will have a new loanId.

But there is a problem if the lender is a LoanManager, the new loanId breaks the Pool's accounting system.

Because when Loan repays, Pool will locate the corresponding _queueAccounting[], _queueOutstandingValues, getTotalReceived[] based on the tranche's loanId.

Pool.sol

    function _loanTermination(
        address _loanContract,
        uint256 _loanId,
        uint256 _principalAmount,
        uint256 _apr,
        uint256 _interestEarned,
        uint256 _received
    ) private {
        uint256 pendingIndex = _pendingQueueIndex;
        uint256 totalQueues = getMaxTotalWithdrawalQueues + 1;
        uint256 idx;
        /// @dev oldest queue is the one after pendingIndex
        uint256 i;
        for (i = 1; i < totalQueues;) {
            idx = (pendingIndex + i) % totalQueues;
@>          if (getLastLoanId[idx][_loanContract] >= _loanId) {
                break;
            }
            unchecked {
                ++i;
            }
        }
        /// @dev We iterated through all queues and never broke, meaning it was issued after the newest one.
        if (i == totalQueues) {
            _outstandingValues =
                _updateOutstandingValuesOnTermination(_outstandingValues, _principalAmount, _apr, _interestEarned);
            return;
        } else {
            uint256 pendingToQueue =
@>              _received.mulDivDown(PRINCIPAL_PRECISION - _queueAccounting[idx].netPoolFraction, PRINCIPAL_PRECISION);
@>          getTotalReceived[idx] += _received;
            getAvailableToWithdraw += pendingToQueue;
@>          _queueOutstandingValues[idx] = _updateOutstandingValuesOnTermination(
                _queueOutstandingValues[idx], _principalAmount, _apr, _interestEarned
            );
        }
    }

The new loanId will result in incorrect localization, assigning the wrong asset to the wrong queue

Impact.

mergeTranches() merged loanId will break Pool's accounting system, leading to incorrect asset assignment

Recommended Mitigation

Recommendation: mergeTranches() when lender can't be a LoanManager

    function _mergeTranches(
        uint256 _newLoanId,
        IMultiSourceLoan.Loan memory _loan,
        uint256 _minTranche,
        uint256 _maxTranche
    ) private pure returns (IMultiSourceLoan.Loan memory) {
...
        for (; originalIndex < _maxTranche;) {
            IMultiSourceLoan.Tranche memory thisTranche = _loan.tranche[originalIndex];
-           if (lender != thisTranche.lender || startTime != thisTranche.startTime) {
+           if (lender != thisTranche.lender || getLoanManagerRegistry.isLoanManager(thisTranche.lender) || startTime != thisTranche.startTime) {
                revert MismatchError();
            }
            principalAmount += thisTranche.principalAmount;
            cumAprBps += thisTranche.aprBps * thisTranche.principalAmount;
            accruedInterest += thisTranche.accruedInterest;
            unchecked {
                ++originalIndex;
            }
        }
        /// @dev Output of merged tranches.
        tranche[_minTranche] = IMultiSourceLoan.Tranche(
            _newLoanId,
            _loan.tranche[_minTranche].floor,
            principalAmount,
            lender,
            accruedInterest,
            startTime,
            cumAprBps / principalAmount
        );

Assessed type

Context

Target idle amount is incorrect in validateOffer flow, which can result in insufficient liquid asset in pool

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L411

Vulnerability details

Impact

Target idle amount is incorrect in validateOffer() flow, which can result in insufficient liquid asset in pool

Proof of Concept

In Pool:validateOffer, reallocating undeployed assets from BaseInterestAllocator to the pool is required if the pool doesn't have enough asset balance.

However, the target idle amount input for the reallocate call is incorrect. The reallocate will transfer the delta of current idle and target idle, which means target idle should be input as the desired amount of liquid assets in the pool. Current implementation input principalAmount - currentBalance, but it should be principalAmount.

//src/lib/pools/Pool.sol
    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
...
        } else if (principalAmount > currentBalance) {
//@audit principalAmount - currentBalance -> principalAmount
            IBaseInterestAllocator(getBaseInterestAllocator).reallocate(
|>                currentBalance, principalAmount - currentBalance, true
            );
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L411)

When the pool needs more liquid asset, this may transfer liquid asset out of the pool, resulting in insufficient pool balance.

Tools Used

Manual

Recommended Mitigation Steps

Change to correct target idle amount

Assessed type

Error

Pool.getMinTimeBetweenWithdrawalQueues current calculations may not be sufficient

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L163

Vulnerability details

Vulnerability details

getMinTimeBetweenWithdrawalQueues is very important for Pool.

If getMinTimeBetweenWithdrawalQueues is too small, pendingQueues will be overwritten too early, and when Loan pays off, it won't be able to find the corresponding queues.

So we will calculate getMinTimeBetweenWithdrawalQueues by MaxDuration + _LOAN_BUFFER_TIME to make sure it won't be overwritten too early.

Currently `_LOAN_BUFFER_TIME = 7 days'
Similarly: LiquidationHandler.MAX_AUCTION_DURATION = 7 days

So that getMinTimeBetweenWithdrawalQueues is sufficient if the bidding is done within the time period

However, less consideration is given to the presence of AuctionLoanLiquidator._MIN_NO_ACTION_MARGIN and the bidding can be delayed.

    function placeBid(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
        external
        nonReentrant
        returns (Auction memory)
    {
...
        uint256 currentTime = block.timestamp;
        uint96 expiration = _auction.startTime + _auction.duration;
        uint96 withMargin = _auction.lastBidTime + _MIN_NO_ACTION_MARGIN;
@>      uint96 max = withMargin > expiration ? withMargin : expiration;
        if (max < currentTime && currentHighestBid > 0) {
            revert AuctionOverError(max);
        }

If the bidding is intense, it may be delayed > _MIN_NO_ACTION_MARGIN =10 minutes, or even longer.

So getMinTimeBetweenWithdrawalQueues may not be enough.

Suggest adding an extra day: MaxDuration + _LOAN_BUFFER_TIME + 3 day.

Impact

getMinTimeBetweenWithdrawalQueues is not large enough causing pendingQueues to be overwritten prematurely.

Recommended Mitigation

contract Pool is ERC4626, InputChecker, IPool, IPoolWithWithdrawalQueues, LoanManager, ReentrancyGuard {
...

    /// @dev Used in case loans might have a liquidation, then the extension is upper bounded by maxDuration + liq time.
-   uint256 private constant _LOAN_BUFFER_TIME = 7 days;
+   uint256 private constant _LOAN_BUFFER_TIME = 10 days;

Assessed type

Context

In Pool.sol, disabled slippage protection in reallocate flow putting undeployed funds at risk

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L411
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L600

Vulnerability details

Impact

In Pool.sol, disabled slippage protection in reallocate flow putting undeployed funds at risk

Proof of Concept

In Pool.sol, when LidoEthBaseInterestAllocator.sol is set as getBaseInterestAllocator, slippage protection is disabled in multiple reallocate flows.

When the pool doesn't have enough capital, reallocation is needed to move funds from getBaseInterestAllocator to the pool through IBaseInterestAllocator(getBaseInterestAllocator).reallocate(), which takes in three parameters including bool _force, a toggle for slippage protection in LidoEthBaseInterestAllocator.sol.

When bool _force is hard-coded to be true, slippage is turned off and allows any weth amount to be transferred to the pool without reverting. This might cause undeployed funds in allocator to be exploited in curve pool swap and resulting a loss.

See below instances where _force is set to true:
(1)

//src/lib/pools/Pool.sol
    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
...
        } else if (principalAmount > currentBalance) {
            IBaseInterestAllocator(getBaseInterestAllocator).reallocate(
|>                currentBalance, principalAmount - currentBalance, true
            );
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L411)

(2)

//src/lib/pools/Pool.sol
    function _reallocateOnWithdrawal(uint256 _withdrawn) private {
...
|>        IBaseInterestAllocator(getBaseInterestAllocator).reallocate(currentBalance, _withdrawn + targetIdle, true);
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L600)

Tools Used

Manual

Recommended Mitigation Steps

Enable slippage protection.

Assessed type

Other

_baseLoanChecks() check errors for expire

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L649

Vulnerability details

Vulnerability details

_baseLoanChecks() is used to check whether Loan has expired.

    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
@>      if (_loan.startTime + _loan.duration < block.timestamp) {
            revert LoanExpiredError();
        }
    }

The expiration checks in liquidation are as follows:

    function _liquidateLoan(uint256 _loanId, IMultiSourceLoan.Loan calldata _loan, bool _canClaim)
        internal
        returns (bool liquidated, bytes memory liquidation)
    {
...

        uint256 expirationTime = _loan.startTime + _loan.duration;
@>      if (expirationTime > block.timestamp) {
            revert LoanNotDueError(expirationTime);
        }

This way, both checks pass when block.timestamp == _loan.startTime + _loan.duration

This leads to the problem that a malicious attacker can perform the following step
when block.timestamp == _loan.startTime + _loan.duration

  1. Alice call liquidateLoan(loandId =1) -> success
    • LoanLiquidator generates an auction
    • _loans[loandId = 1] is still valid , and will only be cleared when the auction is over.
  2. Alice call addNewTranche(loandId = 1) -> success
    • _baseLoanChecks(loandId = 1) will pass
    • delete _loans[1];
    • _loans[2] = newLoan.hash()
  3. bidding ends, call loanLiquidated(loandId = 1) will fail , because _loans[1] has been cleared

Impact

Maliciously disrupting the end of the bidding, causing the NFT/funds to be locked

Recommended Mitigation

    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
        if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
-       if (_loan.startTime + _loan.duration < block.timestamp) {
+      if (_loan.startTime + _loan.duration <= block.timestamp) {
            revert LoanExpiredError();
        }
    }

Assessed type

Context

Function `settleWithBuyout()` does not call `LoanManager.loanLiquidation()` during a buyout

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L83

Vulnerability details

Impact

Lenders in the Gondi protocol could be EOA and Gondi Pool. Gondi Pool, an ERC4626, allows anyone to deposit funds and earn yield from lending on Gondi. Gondi Pool implemented the LoanManager interfaces, which include the validateOffer(), loanRepayment(), and loanLiquidation() functions. The functions loanRepayment() and loanLiquidation() are called when a borrower repays the loan or the loan is liquidated, i.e., when the Pool receives funds back from MultiSourceLoan. Both functions is used to update the queue accounting and the outstanding values of the Pool.

ERC20 asset = ERC20(_auction.asset); 
uint256 totalOwed;
// @audit Repay lender but not call LoanManager.loanLiquidation()
for (uint256 i; i < _loan.tranche.length;) {
    if (i != largestTrancheIdx) { 
        IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
        uint256 owed = thisTranche.principalAmount + thisTranche.accruedInterest
            + thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
        totalOwed += owed; 
        asset.safeTransferFrom(msg.sender, thisTranche.lender, owed);
    }
    unchecked {
        ++i;
    }
}
IMultiSourceLoan(_auction.loanAddress).loanLiquidated(_auction.loanId, _loan);

In the settleWithBuyout() function, the main lender buys out the loan by repaying all other lenders directly. However, loanLiquidation() is not called, leading to incorrect accounting in the Pool.

Proof of Concept

The loanLiquidation() function handles accounting in the pool.
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L449-L463

function loanLiquidation(
    ...
) external override onlyAcceptedCallers {
    uint256 netApr = _netApr(_apr, _protocolFee);
    uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
    uint256 fees = IFeeManager(getFeeManager).processFees(_received, 0);
    getCollectedFees += fees;
    _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, _received - fees);
}

Tools Used

Manual Review

Recommended Mitigation Steps

Consider checking and calling loanLiquidation() in settleWithBuyout() to ensure accurate accounting in the pool.

Assessed type

Other

loanLiquidation() calculation of interest is not accurate

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L460

Vulnerability details

Vulnerability details

loanLiquidation() The calculated interest codes are as follows:

    function loanLiquidation(
        uint256 _loanId,
        uint256 _principalAmount,
        uint256 _apr,
        uint256,
        uint256 _protocolFee,
        uint256 _received,
        uint256 _startTime
    ) external override onlyAcceptedCallers {
        uint256 netApr = _netApr(_apr, _protocolFee);
        uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
@>      uint256 fees = IFeeManager(getFeeManager).processFees(_received, 0);
        getCollectedFees += fees;
        _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, _received - fees);
    }
...


contract FeeManager is IFeeManager, TwoStepOwned {
...
    function processFees(uint256 _principal, uint256 _interest) external view returns (uint256) {
        /// @dev cached
        Fees memory __fees = _fees;
        return _principal.mulDivDown(__fees.managementFee, PRECISION)
            + _interest.mulDivDown(__fees.performanceFee, PRECISION);
    }

The above code takes all of _received as the principal and gives it to IFeeManager to calculate.

But the amount received from the bidding is not always less than the principal, it may be more than the principal, and this part should be calculated as interest.

Impact

When received is greater than the principal, fees is not correct.

Recommended Mitigation

    function loanLiquidation(
        uint256 _loanId,
        uint256 _principalAmount,
        uint256 _apr,
        uint256,
        uint256 _protocolFee,
        uint256 _received,
        uint256 _startTime
    ) external override onlyAcceptedCallers {
        uint256 netApr = _netApr(_apr, _protocolFee);
        uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
-       uint256 fees = IFeeManager(getFeeManager).processFees(_received, 0);
+       uint256 fees;
+       if (_received > _principalAmount) {
+           fees = IFeeManager(getFeeManager).processFees(_principalAmount, _received - _principalAmount);
+       }else {
+           fees = IFeeManager(getFeeManager).processFees(_received, 0);
+       }
        getCollectedFees += fees;
        _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, _received - fees);
    }


## Assessed type

Context

QA Report

See the markdown file with the details of this report here.

confirmTerms() DOS

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/PoolOfferHandler.sol#L100

Vulnerability details

Vulnerability details

in PoolOfferHandler
If new _terms[] need to be set up
First, the administrator needs to submit _pendingTerms[]

    function setTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external onlyOwner {
        if (_termKeys.length != __terms.length) {
            revert InvalidInputError();
        }
        for (uint256 i = 0; i < __terms.length; i++) {
            if (_termKeys[i].duration > getMaxDuration) {
                revert InvalidDurationError();
            }
            _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i].maxSeniorRepayment][__terms[i]
                .principalAmount] = __terms[i].aprPremium;
        }
        uint256 ts = block.timestamp;
        pendingTermsSetTime = ts;

        emit PendingTermsSet(_termKeys, __terms, ts);
    }

Then wait for NEW_TERMS_WAITING_TIME.
Anyone can then execute confirmTerms() to make _pendingTerms[] effective

    function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external {
        if (block.timestamp - pendingTermsSetTime < NEW_TERMS_WAITING_TIME) {
            revert TooSoonError();
        }
@>      for (uint256 i = 0; i < __terms.length; i++) {
            if (_termKeys[i].duration > getMaxDuration) {
                revert InvalidDurationError();
            }
            uint256 pendingAprPremium = _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i]
                .maxSeniorRepayment][__terms[i].principalAmount];
            if (pendingAprPremium != __terms[i].aprPremium) {
                revert InvalidTermsError();
            }
            _terms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i].maxSeniorRepayment][__terms[i]
                .principalAmount] = __terms[i].aprPremium;
            delete _pendingTerms[_termKeys[i].collection][_termKeys[i].duration][_termKeys[i]
                .maxSeniorRepayment][__terms[i].principalAmount];
        }
@>      pendingTermsSetTime = type(uint256).max;

        emit TermsSet(_termKeys, __terms);
    }

The above method does not have permission control and does not limit the length of __terms.
So if a malicious user front-run execute confirmTerms(), submits __terms.length==0, pendingTermsSetTime will be invalidated, and _pendingTerms[] will be invalidated as well.
The administrator needs to resubmit _pendingTerms[], which is also possible by DOS.

Impact

confirmTerms() can be DOS , and _terms[] can never be added.

Recommended Mitigation

Only administrators can execute confirmTerms().

-   function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external {
+   function confirmTerms(TermsKey[] calldata _termKeys, Terms[] calldata __terms) external onlyOwner {
        if (block.timestamp - pendingTermsSetTime < NEW_TERMS_WAITING_TIME) {
            revert TooSoonError();
        }

Assessed type

DoS

refinanceFull/addNewTranche reusing a lender's signature leads to unintended behavior

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L358
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L194

Vulnerability details

Vulnerability details

in MultiSourceLoan
refinanceFull() and addNewTranche() use the same signature

    function refinanceFull(
        RenegotiationOffer calldata _renegotiationOffer,
        Loan memory _loan,
        bytes calldata _renegotiationOfferSignature
    ) external nonReentrant returns (uint256, Loan memory) {
...
        if (lenderInitiated) {
            if (_isLoanLocked(_loan.startTime, _loan.startTime + _loan.duration)) {
                revert LoanLockedError();
            }
            _checkStrictlyBetter(
                _renegotiationOffer.principalAmount,
                _loan.principalAmount,
                _renegotiationOffer.duration + block.timestamp,
                _loan.duration + _loan.startTime,
                _renegotiationOffer.aprBps,
                totalAnnualInterest / _loan.principalAmount,
                _renegotiationOffer.fee
            );
        } else if (msg.sender != _loan.borrower) {
            revert InvalidCallerError();
        } else {
            /// @notice Borrowers clears interest
@>          _checkSignature(_renegotiationOffer.lender, _renegotiationOffer.hash(), _renegotiationOfferSignature);
            netNewLender -= totalAccruedInterest;
            totalAccruedInterest = 0;
        }
    function addNewTranche(
        RenegotiationOffer calldata _renegotiationOffer,
        Loan memory _loan,
        bytes calldata _renegotiationOfferSignature
    ) external nonReentrant returns (uint256, Loan memory) {
...
        uint256 loanId = _renegotiationOffer.loanId;

        _baseLoanChecks(loanId, _loan);
        _baseRenegotiationChecks(_renegotiationOffer, _loan);
@>      _checkSignature(_renegotiationOffer.lender, _renegotiationOffer.hash(), _renegotiationOfferSignature);
        if (_loan.tranche.length == getMaxTranches) {
            revert TooManyTranchesError();
        }

So when lender signs RenegotiationOffer, it is meant to replace tranche, i.e. execute refinanceFull().

But a malicious user can use this sign and front-run execute addNewTranche().
addNewTranche() doesn't limit the RenegotiationOffer too much.
The newly generated Loan will be approximately twice the total amount borrowed, and the risk of borrowing against the lender will increase dramatically.

Impact

Maliciously using the signature of refinanceFull() to execute addNewTranche() will result in approximately double the borrowed amount, and the risk of borrowing will increase dramatically.

Recommended Mitigation

RenegotiationOffer Add a type field to differentiate between signatures.

Assessed type

Context

AuctionLoanLiquidator#placeBid can be DoS

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L222

Vulnerability details

Impact

The attacker performs a DoS attack on the Bid function, causing other users to be unable to participate and eventually obtaining the NFT at a low price.

Proof of Concept

The placeBid function requires each bid to increase by 5% from the original, locking in for a period of time after each bid.

function placeBid(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
        external
        nonReentrant
        returns (Auction memory)
    {
        _placeBidChecks(_nftAddress, _tokenId, _auction, _bid);

        uint256 currentHighestBid = _auction.highestBid;
        // MIN_INCREMENT_BPS = 10000, _BPS = 500 , add 5%
        if (_bid == 0 || (currentHighestBid.mulDivDown(_BPS + MIN_INCREMENT_BPS, _BPS) >= _bid)) {
            revert MinBidError(_bid);
        }

        uint256 currentTime = block.timestamp;
        uint96 expiration = _auction.startTime + _auction.duration;
@>      uint96 withMargin = _auction.lastBidTime + _MIN_NO_ACTION_MARGIN;
        uint96 max = withMargin > expiration ? withMargin : expiration;
        if (max < currentTime && currentHighestBid > 0) {
            revert AuctionOverError(max);
        }
        .....
    }

The problem here is that if the initial price increases from a very small value, the second increase in percentage only needs to be a very small amount.
For example: 100 wei -> 105 wei -> 110 wei

So an attacker can start with a small bid and keep growing slowly,
Because of the time lock, other users cannot participate for a period of time,
Normal users must wait until the time lock is over and the transaction needs to be executed before the attacker.

If normal users are unable to participate in the bidding, the attacker can obtain the auction item(NFT) at a very low price.

Tools Used

vscode, manual

Recommended Mitigation Steps

function placeBid(.....){
+       require (_bid > MIN_BID);
}

Assessed type

DoS

`triggerFee` is stolen from other auctions during `settleWithBuyout()`

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L97

Vulnerability details

Impact

The function settleWithBuyout() is used to settle an auction with a buyout from the main lender. This lender needs to repay all other lenders and will receive the NFT collateral. Near the end of the function, the triggerFee is also paid to the auction originator. However, the funds used to pay this fee are taken directly from the contract balance, even though the main lender doesn't transfer these funds into the contract.

function settleWithBuyout(
    ...
) external nonReentrant {
    ...
    // @note Repay other lenders
    ERC20 asset = ERC20(_auction.asset); 
    uint256 totalOwed;
    for (uint256 i; i < _loan.tranche.length;) {
        ...
    }
    IMultiSourceLoan(_auction.loanAddress).loanLiquidated(_auction.loanId, _loan);
    
    // @audit There is no fund in this contract to pay triggerFee
    asset.safeTransfer(_auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS)); 
    ...
}

As a result, if the auction contract balance is insufficient to cover the fee, the function will simply revert and prevent the main lender from buying out. In other cases where multiple auctions are running in parallel, the triggerFee will be deducted from the other auctions. This could lead to the last auctions being unable to settle due to insufficient balance.

Proof of Concept

The function settleWithBuyout() is called before any placeBid() so the funds is only from main lender. In the settleWithBuyout(), there are 2 transfers asset. One is to pay other lenders and one is to pay the triggerFee. As you can see in the code snippet, there is no triggerFee transfer from sender to originator.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider using safeTransferFrom() to pay the triggerFee from the sender's address, rather than using safeTransfer() to pay the triggerFee from the contract balance.

Assessed type

Other

addNewTranche() no authorization from borrower

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L358

Vulnerability details

Vulnerability details

addNewTranche() The code implementation is as follows:

    function addNewTranche(
        RenegotiationOffer calldata _renegotiationOffer,
        Loan memory _loan,
        bytes calldata _renegotiationOfferSignature
    ) external nonReentrant returns (uint256, Loan memory) {
        uint256 loanId = _renegotiationOffer.loanId;

        _baseLoanChecks(loanId, _loan);
        _baseRenegotiationChecks(_renegotiationOffer, _loan);
@>      _checkSignature(_renegotiationOffer.lender, _renegotiationOffer.hash(), _renegotiationOfferSignature);
        if (_loan.tranche.length == getMaxTranches) {
            revert TooManyTranchesError();
        }

        uint256 newLoanId = _getAndSetNewLoanId();
        Loan memory loanWithTranche = _addNewTranche(newLoanId, _loan, _renegotiationOffer);
        _loans[newLoanId] = loanWithTranche.hash();
        delete _loans[loanId];

        ERC20(_loan.principalAddress).safeTransferFrom(
            _renegotiationOffer.lender, _loan.borrower, _renegotiationOffer.principalAmount - _renegotiationOffer.fee
        );
        if (_renegotiationOffer.fee > 0) {
            /// @dev Cached
            ProtocolFee memory protocolFee = _protocolFee;
            ERC20(_loan.principalAddress).safeTransferFrom(
                _renegotiationOffer.lender,
                protocolFee.recipient,
                _renegotiationOffer.fee.mulDivUp(protocolFee.fraction, _PRECISION)
            );
        }

        emit LoanRefinanced(
            _renegotiationOffer.renegotiationId, loanId, newLoanId, loanWithTranche, _renegotiationOffer.fee
        );

        return (newLoanId, loanWithTranche);
    }

Currently only the signature of the lender is checked, not the authorization of the borrower.
Then any lender can add tranche to any loan by

  1. specify a very high apr
  2. specify any _renegotiationOffer.fee,example : set _renegotiationOffer.fee==_renegotiationOffer.principalAmount.

This doesn't make sense for borrower.

It is recommended that only the borrower performs this method.

Impact

lender can be specified to generate a malicious tranche to compromise borrower.

Recommended Mitigation

    function addNewTranche(
        RenegotiationOffer calldata _renegotiationOffer,
        Loan memory _loan,
        bytes calldata _renegotiationOfferSignature
    ) external nonReentrant returns (uint256, Loan memory) {
        uint256 loanId = _renegotiationOffer.loanId;
+       if (msg.sender != _loan.borrower) {
+           revert InvalidCallerError();
+       } 
        _baseLoanChecks(loanId, _loan);
        _baseRenegotiationChecks(_renegotiationOffer, _loan);
        _checkSignature(_renegotiationOffer.lender, _renegotiationOffer.hash(), _renegotiationOfferSignature);
        if (_loan.tranche.length == getMaxTranches) {
            revert TooManyTranchesError();
        }

Assessed type

Context

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

In WithdrawalQueue, the user may not be able to withdraw token

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/WithdrawalQueue.sol#L137

Vulnerability details

Impact

In some cases, users are unable to withdraw token, resulting in the loss of user funds.

Proof of Concept

WithdrawalQueue uses _getAvailable to calculate the token that a certain _tokenId can withdraw.

    function _getAvailable(uint256 _tokenId) private view returns (uint256) {
        return getShares[_tokenId] * _getWithdrawablePerShare() - getWithdrawn[_tokenId];
    }
    
    function _getWithdrawablePerShare() private view returns (uint256) {
        return (_totalWithdrawn + _asset.balanceOf(address(this))) / getTotalShares;
    }

This function will get a negative number in some cases, but since it is uint256, it will revert.

_getWithdrawablePerShare gets a ratio based on balanceOf(this) and getTotalShares.

withdrawablePerShare is a variable value,
Suppose the value is 2 when the user first withdraw, and becomes 1 when the user again withdraw.
Since getWithdrawn is also calculated based on userShare * withdrawablePerShare,
getWithdrawn[_tokenId] is the number of tokens that have been withdraw.

    uint256 available = _getAvailable(_tokenId);
    getWithdrawn[_tokenId] += available;

For the first withdraw, getWithdrawn[_tokenId] may be a larger value,
When withdraw again, withdrawablePerShare is reduced, so userShare * withdrawablePerShare is likely to be smaller than getWithdrawn[_tokenId].

It will be revert, as a result, users can't get rewards.

Tools Used

vscode, manual

Recommended Mitigation Steps

Record the difference in share when two withdrawals are made,
Instead of recording the number of tokens that have been extracted

Assessed type

Other

placeBid() malicious low bidding

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L230

Vulnerability details

Vulnerability details

Users who need to bid can do so with the method: AuctionLoanLiquidator.placeBid()

    function placeBid(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
        external
        nonReentrant
        returns (Auction memory)
    {
@>      _placeBidChecks(_nftAddress, _tokenId, _auction, _bid);

        uint256 currentHighestBid = _auction.highestBid;
        if (_bid == 0 || (currentHighestBid.mulDivDown(_BPS + MIN_INCREMENT_BPS, _BPS) >= _bid)) {
            revert MinBidError(_bid);
        }
...

@>      _auctions[_nftAddress][_tokenId] = _auction.hash();

        emit BidPlaced(_nftAddress, _tokenId, newBidder, _bid, _auction.loanAddress, _auction.loanId);
        return _auction;
    }

There are two important checks

  1. auctions[_nftAddress][_tokenId] == _auction.hash() ( whether the param _auction is legal or not)
  2. this bid amount bid is > 5% over highestBid ( bid > highestBid * 10500 / 10000)

These two conditions can be easily underbid by a user
Users just need to monitor mempool, front-run placeBid() starting from bid=1.
Example:

  1. block = 1 , front-run placeBid(_auction, bid=1) --> pass ( 1 > (0 * 10500 / 10000))

  2. block = 2 , front-run placeBid(_auction, bid=2) --> pass ( 2 > (1 * 10500 / 10000))

  3. block = 3 , front-run placeBid(_auction, bid=3) --> pass ( 3 > (2 * 10500 / 10000))
    ...

Each front-run, causes subsequent bids on the same block to fail, regardless of the user's bid amount, because the first one changes the hash : _auctions[_nftAddress][_tokenId] = _auction.hash();, resulting in the other users not being able to pass the hash check

Impact

Malicious front-run low bidding

Recommended Mitigation

Suggestions:

  1. Auction is saved in storage
  2. placeBid() pass in auction id

Assessed type

DoS

Bidders might lose funds due to possible racing condition between settleWithBuyout and placeBid

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L129
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L64

Vulnerability details

Impact

Bidder might lose funds due to possible racing condition between settleWithBuyout and placeBid.

Proof of Concept

In AuctionWithBuyoutLoanLiquidator.sol, settleWithBuyout and placeBid are allowed at an overlapping timestamp (_auction.startTime + _timeForMainLenderToBuy). This allows settleWithBuyout and placeBid to be settled at the same block.

When placeBid tx settles at _auction.startTime + _timeForMainLenderToBuy before settleWithBuyout tx, the bidder will lose their funds. Because settleWithBuyout will always assume no bids are placed, it will directly transfer out the collateral NFT token and delete the auction data from storage.

    function settleWithBuyout(
        address _nftAddress,
        uint256 _tokenId,
        Auction calldata _auction,
        IMultiSourceLoan.Loan calldata _loan
    ) external nonReentrant {
...
        uint256 timeLimit = _auction.startTime + _timeForMainLenderToBuy;
 |>       if (timeLimit < block.timestamp) {
            revert OptionToBuyExpiredError(timeLimit);
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L63C1-L66C10)

    function _placeBidChecks(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
        internal
        view
        override
    {
...
        uint256 timeLimit = _auction.startTime + _timeForMainLenderToBuy;
|>        if (timeLimit > block.timestamp) {
            revert OptionToBuyStilValidError(timeLimit);
        }

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L129)

Tools Used

Manual

Recommended Mitigation Steps

Consider only allow buyout strictly before the timeLimit if (timeLimit <= block.timestamp) {//revert.

Assessed type

Other

Borrower signature could be reused in `emitLoan()`

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L135

Vulnerability details

Impact

The function emitLoan() is used to issue a new loan. This function could be called directly by the borrower or by a random address if the borrower has signed the LoanExecutionData.

function emitLoan(LoanExecutionData calldata _loanExecutionData)
    external
    nonReentrant
    returns (uint256, Loan memory)
{
    address borrower = _loanExecutionData.borrower;
    ExecutionData calldata executionData = _loanExecutionData.executionData;
    (address principalAddress, address nftCollateralAddress) = _getAddressesFromExecutionData(executionData);

    OfferExecution[] calldata offerExecution = executionData.offerExecution;

    // @audit Check borrower signature or borrower is caller
    _validateExecutionData(_loanExecutionData, borrower); 
    ...
}


function _validateExecutionData(LoanExecutionData calldata _executionData, address _borrower) private view {
    if (msg.sender != _borrower) {
        _checkSignature(
            _executionData.borrower, _executionData.executionData.hash(), _executionData.borrowerOfferSignature
        );
    }
    if (block.timestamp > _executionData.executionData.expirationTime) {
        revert ExpiredOfferError(_executionData.executionData.expirationTime);
    }
}

However, there isn't a check to ensure the signature for the same LoanExecutionData can't be used to execute emitLoan() more than once. As a result, if the borrower repays the loan, an attacker could call emitLoan() again to initiate a new loan.

Proof of Concept

Consider this scenario:

  1. Lender Alice has an offer with a capacity of 50 ETH.
  2. Borrower Bob signs a signature to take a 10 ETH loan with his NFT.
  3. After Bob repays the loan, anyone can call emitLoan() using the previous signature to force Bob to take the 10 ETH loan again. Since the capacity of Alice's offer is 50 ETH, the signature can be reused up to 5 times.

Tools Used

Manual Review

Recommended Mitigation Steps

Add a nonce to ensure a signature cannot be reused.

Assessed type

Other

Any liquidators can pretend to be a loan contract to validate offers, due to insufficient validation

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L392

Vulnerability details

Impact

Any liquidators can pretend to be a loan contract to validate offers, due to insufficient validation

Proof of Concept

Accepted callers in a loan manager (e.g. Pool.sol) can be either liquidators or loan contracts.

And only loan contracts should validate offers during a loan creation. However, the current access control check is insufficient in pool::validateOffer, which allows liquidators to pretend to be a loan contract, and directly modify storage (__outstandingValues) bypassing additional checks and accounting in a loan contract.

//src/lib/pools/Pool.sol
    //@audit onlyAcceptedCallers only doesn't ensure caller is a loan contract
|>    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
        if (!isActive) {
            revert PoolStatusError();
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L392)

Current access control check(onlyAcceptedCallers) only ensures caller is accepted caller but doesn't verify the caller is a loan contract (_isLoanContract(caller)==true).

//src/lib/loans/LoanManager.sol
    modifier onlyAcceptedCallers() {
        if (!_acceptedCallers.contains(msg.sender)) {
            revert CallerNotAccepted();
        }
        _;
    }

When a liquidator calls validateOffer, they can provide a fabricated bytes calldata _offer and uint256 _protocolFee bypassing additional checks and state accounting in a loan contract. For example, in MultiSourceLoan.sol- emitLoan, extra checks are implemented on LoanExecutionData to verify borrower and lender signatures and offer expiration timestamp as well as transfer collateral NFT tokens and recoding loan to storage. All of the above can be skipped if a liquidator directly call validateOffer and modify __outstandingValues without token transfer.

Tools Used

Manual

Recommended Mitigation Steps

In Pool::validateOffer, consider adding a check to ensure _isLoanContract(msg.sender)==true

Assessed type

Invalid Validation

Racing condition between settleAuction and placeBid might allow previous highest bidder to prevent others to bid

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L238
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L273

Vulnerability details

Impact

Racing condition between settleAuction and placeBid might allow previous highest bidder to prevent others to bid

Proof of Concept

In AuctionLoanLiquidator.sol, placeBid and settleAuction windows overlap. Both are allowed when currentTime == max (withMargin or expiration).

    function placeBid(address _nftAddress, uint256 _tokenId, Auction memory _auction, uint256 _bid)
...
        uint96 max = withMargin > expiration ? withMargin : expiration;
|>        if (max < currentTime && currentHighestBid > 0) {
            revert AuctionOverError(max);
        }
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionLoanLiquidator.sol#L237-L239)

    function settleAuction(Auction calldata _auction, IMultiSourceLoan.Loan calldata _loan) external nonReentrant {
...
        uint96 expiration = _auction.startTime + _auction.duration;
        uint96 withMargin = _auction.lastBidTime + _MIN_NO_ACTION_MARGIN;
|>        if ((withMargin > currentTime) || (currentTime < expiration)) {
            uint96 max = withMargin > expiration ? withMargin : expiration;
            revert AuctionNotOverError(max);

This is problematic because a user whose placeBid tx settles at max time might be reverted when another user front-runs with settleAuction at the same max time. This encourages a racing between settleAuction and placeBid, which allows previous highest bidder to prevent others to bid by front-running.

Tools Used

Manual

Recommended Mitigation Steps

In settleAuction, change the if control flow into if ((withMargin >= currentTime) || (currentTime <= expiration)) {//revert}

Assessed type

Other

validateOffer() give the wrong _targetIdle to reallocate()

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L411

Vulnerability details

Vulnerability details

when validateOffer(), if principalAmount > currentBalance , we need to get back enough from getBaseInterestAllocator to avoid not having enough balance to pay the offer.

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
        if (!isActive) {
            revert PoolStatusError();
        }
        uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
        uint256 undeployedAssets = currentBalance + baseRateBalance;
        (uint256 principalAmount, uint256 apr) = IPoolOfferHandler(getUnderwriter).validateOffer(
            IBaseInterestAllocator(getBaseInterestAllocator).getBaseAprWithUpdate(), _offer
        );

        /// @dev Since the balance of the pool includes capital that is waiting to be claimed by the queues,
        ///      we need to check if the pool has enough capital to fund the loan.
        ///      If that's not the case, and the principal is larger than the currentBalance, the we need to reallocate
        ///      part of it.
        if (principalAmount > undeployedAssets) {
            revert InsufficientAssetsError();
        } else if (principalAmount > currentBalance) {
            IBaseInterestAllocator(getBaseInterestAllocator).reallocate(
@>              currentBalance, principalAmount - currentBalance, true
            );
        }
        /// @dev If the txn doesn't revert, we can assume the loan was executed.
        _outstandingValues = _getNewLoanAccounting(principalAmount, _netApr(apr, _protocolFee));
    }

The above call to IBaseInterestAllocator(getBaseInterestAllocator).reallocate(_currentIdle, _targetIdle) with the second argument _targetIdle is wrong.
Instead of passing _targetIdle = principalAmount - currentBalance, we should pass _targetIdle = principalAmount directly

The reallocate() method calculates the delta itself internally.

Take AaveUsdcBaseInterestAllocator as an example:

contract AaveUsdcBaseInterestAllocator is IBaseInterestAllocator, Owned {
...
    function reallocate(uint256 _currentIdle, uint256 _targetIdle, bool) external {
        address pool = _onlyPool();
        if (_currentIdle > _targetIdle) {
            uint256 delta = _currentIdle - _targetIdle;
            ERC20(_usdc).transferFrom(pool, address(this), delta);
            IAaveLendingPool(_aavePool).deposit(_usdc, delta, address(this), 0);
        } else {
@>          uint256 delta = _targetIdle - _currentIdle;
            IAaveLendingPool(_aavePool).withdraw(_usdc, delta, address(this));
            ERC20(_usdc).transfer(pool, delta);
        }

        emit Reallocated(_currentIdle, _targetIdle);
    }

Impact

reallocate() incorrectly passes delta, which may cause withdraw to become deposit, resulting in insufficient balance and failed offer.

Recommended Mitigation

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
        if (!isActive) {
            revert PoolStatusError();
        }
        uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
        uint256 undeployedAssets = currentBalance + baseRateBalance;
        (uint256 principalAmount, uint256 apr) = IPoolOfferHandler(getUnderwriter).validateOffer(
            IBaseInterestAllocator(getBaseInterestAllocator).getBaseAprWithUpdate(), _offer
        );

        /// @dev Since the balance of the pool includes capital that is waiting to be claimed by the queues,
        ///      we need to check if the pool has enough capital to fund the loan.
        ///      If that's not the case, and the principal is larger than the currentBalance, the we need to reallocate
        ///      part of it.
        if (principalAmount > undeployedAssets) {
            revert InsufficientAssetsError();
        } else if (principalAmount > currentBalance) {
            IBaseInterestAllocator(getBaseInterestAllocator).reallocate(
-               currentBalance, principalAmount - currentBalance, true
+               currentBalance, principalAmount, true
            );
        }
        /// @dev If the txn doesn't revert, we can assume the loan was executed.
        _outstandingValues = _getNewLoanAccounting(principalAmount, _netApr(apr, _protocolFee));
    }

Assessed type

Context

refinanceFromLoanExecutionData() lack of check if tokenId is equal

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L322

Vulnerability details

Vulnerability details

refinanceFromLoanExecutionData() is used to pay off the old Loan and generate a new Loan with LoanExecutionData.

    function refinanceFromLoanExecutionData(
        uint256 _loanId,
        Loan calldata _loan,
        LoanExecutionData calldata _loanExecutionData
    ) external nonReentrant returns (uint256, Loan memory) {
        _baseLoanChecks(_loanId, _loan);

        ExecutionData calldata executionData = _loanExecutionData.executionData;
        address borrower = _loanExecutionData.borrower;
        (address principalAddress, address nftCollateralAddress) = _getAddressesFromExecutionData(executionData);

        OfferExecution[] calldata offerExecution = executionData.offerExecution;

        _validateExecutionData(_loanExecutionData, _loan.borrower);
        _checkWhitelists(principalAddress, nftCollateralAddress);

@>      //@audit miss check if tokenId is equal
        if (_loan.principalAddress != principalAddress || _loan.nftCollateralAddress != nftCollateralAddress) {
            revert InvalidAddressesError();
        }

        /// @dev We first process the incoming offers so borrower gets the capital. After that, we process repayments.
        ///      NFT doesn't need to be transfered (it was already in escrow)
        (uint256 newLoanId, uint256[] memory offerIds, Loan memory loan, uint256 totalFee) =
        _processOffersFromExecutionData(
            borrower,
            executionData.principalReceiver,
            principalAddress,
            nftCollateralAddress,
            executionData.tokenId,
            executionData.duration,
            offerExecution
        );
        _processRepayments(_loan);

        emit LoanRefinancedFromNewOffers(_loanId, newLoanId, loan, offerIds, totalFee);

        _loans[newLoanId] = loan.hash();
        delete _loans[_loanId];

        return (newLoanId, loan);
    }

The above code checks that principalAddress and nftCollateralAddress must be equal

but does not restrict the tokenId to be equal.

This way the user can use the old Loan with the cheap NFT, but generate a new Loan with the expensive NFT (this new NFT could belong to another Loan, and the lender could be his own account, no loss of repay).

It is not necessary to transfer the new NFT during the execution of this method.

The NFT is then stolen through repayLoan().

Impact

lack of checking whether tokenId is equal , can be used to steal other people's NFTs.

Recommended Mitigation

    function refinanceFromLoanExecutionData(
        uint256 _loanId,
        Loan calldata _loan,
        LoanExecutionData calldata _loanExecutionData
    ) external nonReentrant returns (uint256, Loan memory) {
        _baseLoanChecks(_loanId, _loan);
...
-       if (_loan.principalAddress != principalAddress || _loan.nftCollateralAddress != nftCollateralAddress) {
+       if (_loan.principalAddress != principalAddress || _loan.nftCollateralAddress != nftCollateralAddress || _loan.nftCollateralTokenId!=executionData.tokenId) {
            revert InvalidAddressesError();
        }

Assessed type

Context

distribute() Use the wrong end time to break maxSeniorRepayment's expectations

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/LiquidationDistributor.sol#L39

Vulnerability details

Vulnerability details

When the bid amount is not enough, the lender will be repaid in order of tranche[].
In order to minimize the risk, the user can specify maxSeniorRepayment to avoid the risk to some extent, and put himself in a position of higher repayment priority.
At the same time emitLoan() checks maxSeniorRepayment for the emitLoan()

emitLoan() -> _processOffersFromExecutionData() -> _checkOffer()

    function _processOffersFromExecutionData(
        address _borrower,
        address _principalReceiver,
        address _principalAddress,
        address _nftCollateralAddress,
        uint256 _tokenId,
        uint256 _duration,
        OfferExecution[] calldata _offerExecution
    ) private returns (uint256, uint256[] memory, Loan memory, uint256) {
...
            uint256 amount = thisOfferExecution.amount;
            address lender = offer.lender;
            /// @dev Please note that we can now have many tranches with same `loanId`.
            tranche[i] = Tranche(loanId, totalAmount, amount, lender, 0, block.timestamp, offer.aprBps);
            totalAmount += amount;
@>          totalAmountWithMaxInterest += amount + amount.getInterest(offer.aprBps, _duration);
...

    function _checkOffer(
        LoanOffer calldata _offer,
        address _principalAddress,
        address _nftCollateralAddress,
        uint256 _amountWithInterestAhead
    ) private pure {
        if (_offer.principalAddress != _principalAddress || _offer.nftCollateralAddress != _nftCollateralAddress) {
            revert InvalidAddressesError();
        }
@>      if (_amountWithInterestAhead > _offer.maxSeniorRepayment) {
            revert InvalidTrancheError();
        }
    }

Note: totalAmountWithMaxInterest is computed using loan._duration

But when the bidding ends and the distribution is done LiquidationDistributor.distribute()
the current time is used to calculate Interest.

    function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
        uint256[] memory owedPerTranche = new uint256[](_loan.tranche.length);
        uint256 totalPrincipalAndPaidInterestOwed = _loan.principalAmount;
        uint256 totalPendingInterestOwed = 0;
        for (uint256 i = 0; i < _loan.tranche.length;) {
            IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
            uint256 pendingInterest =
@>              thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
            totalPrincipalAndPaidInterestOwed += thisTranche.accruedInterest;
            totalPendingInterestOwed += pendingInterest;
            owedPerTranche[i] += thisTranche.principalAmount + thisTranche.accruedInterest + pendingInterest;
            unchecked {
                ++i;
            }
        }

Because bidding takes a certain amount of time (3~7days), using block.timestamp - thisTranche.startTime will be larger than expected!
Correctly should use: (loan.startTime + loan.duration - thisTranche.startTime) to calculate the interest.

This leads to the problem that if there is not enough funds, the front lender will get a larger repayment than expected, breaking the back lender's initial expectation of `maxSeniorRepayment

Impact

If there are not enough funds, the initial expectation of maxSeniorRepayment may be broken

Recommended Mitigation

    function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
        uint256[] memory owedPerTranche = new uint256[](_loan.tranche.length);
        uint256 totalPrincipalAndPaidInterestOwed = _loan.principalAmount;
        uint256 totalPendingInterestOwed = 0;
+      uint256 loanExpireTime = _loan.startTime + _loan.duration;
        for (uint256 i = 0; i < _loan.tranche.length;) {
            IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
            uint256 pendingInterest =
-               thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
+               thisTranche.principalAmount.getInterest(thisTranche.aprBps, loanExpireTime - thisTranche.startTime);
            totalPrincipalAndPaidInterestOwed += thisTranche.accruedInterest;
            totalPendingInterestOwed += pendingInterest;
            owedPerTranche[i] += thisTranche.principalAmount + thisTranche.accruedInterest + pendingInterest;
            unchecked {
                ++i;
            }
        }

Assessed type

Context

_processOffersFromExecutionData() lack of check executionData.duration<=offer.duration

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L124

Vulnerability details

Vulnerability details

when emitLoan(), only limitoffer.duration != 0,There's no limit executionData.duration<=offer.duration

emitLoan() -> _processOffersFromExecutionData() -> _validateOfferExecution()

    function _validateOfferExecution(
        OfferExecution calldata _offerExecution,
        uint256 _tokenId,
        address _lender,
        address _offerer,
        bytes calldata _lenderOfferSignature,
        uint256 _feeFraction,
        uint256 _totalAmount
    ) private {
...

@>      if (offer.duration == 0) {
            revert ZeroDurationError();
        }
        if (offer.aprBps == 0) {
            revert ZeroInterestError();
        }
        if ((offer.capacity > 0) && (_used[_offerer][offer.offerId] + _offerExecution.amount > offer.capacity)) { 
            revert MaxCapacityExceededError();
        }

        _checkValidators(_offerExecution.offer, _tokenId);
    }

If the executionData.duration time is not limited, it can lead to far exceeding the borrowing time offer.duration.
If the lender is a LoanManager, when repayLoan() it can also exceed the maximum pendingQueues, leading to accounting issues

Impact

far exceeding the borrowing time than offer.duration.
If lender is LoanManager also exceeds max pendingQueues, causing bookkeeping issues

Recommended Mitigation

check executionData.duration<=offer[n].duration

Assessed type

Context

DOS all Pool's offer through capacity=0

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L124

Vulnerability details

Vulnerability details

If offer.capacity=0, then this offer.offerId becomes one-time.

emitLoan()->_processOffersFromExecutionData()

    function _processOffersFromExecutionData(
        address _borrower,
        address _principalReceiver,
        address _principalAddress,
        address _nftCollateralAddress,
        uint256 _tokenId,
        uint256 _duration,
        OfferExecution[] calldata _offerExecution
    ) private returns (uint256, uint256[] memory, Loan memory, uint256) {
...

            _handleProtocolFeeForFee(
                offer.principalAddress, lender, fee.mulDivUp(protocolFee.fraction, _PRECISION), protocolFee.recipient
            );

            ERC20(offer.principalAddress).safeTransferFrom(lender, _principalReceiver, amount - fee);
            if (offer.capacity > 0) {
                _used[lender][offer.offerId] += amount;
            } else {
@>              isOfferCancelled[lender][offer.offerId] = true;
            }

            offerIds[i] = offer.offerId;
            unchecked {
                ++i;
            }
        }
        Loan memory loan = Loan(
            _borrower,
            _tokenId,
            _nftCollateralAddress,
            _principalAddress,
            totalAmount,
            block.timestamp,
            _duration,
            tranche,
            protocolFee.fraction
        );

        return (loanId, offerIds, loan, totalFee);
    }

This gives a malicious attacker an opportunity to maliciously attack all offers with lender == Pool.
Example
Bob call emitLoan(lender == Pool, offerId = 123)

  1. Alice front-run call emitLoan(lender == Pool, offerId = 123,capacity=0, duration=0)
    • after execute , isOfferCancelled[Pool][123] = true
  2. Bob's tranaction will fail, because isOfferCancelled[Pool][123] = true;
  3. Alice call repayLoan() get back nft

Impact

DOS all Pool's offer

Recommended Mitigation

If lender is LoanManager, then offer.capacity must not be 0.

    function _validateOfferExecution(
        OfferExecution calldata _offerExecution,
        uint256 _tokenId,
        address _lender,
        address _offerer,
        bytes calldata _lenderOfferSignature,
        uint256 _feeFraction,
        uint256 _totalAmount
    ) private {
...

+      if (getLoanManagerRegistry.isLoanManager(_lender) && offer.capacity==0) {
+          revert InvalidTrancheError();
+      }

       if ((offer.capacity > 0) && (_used[_offerer][offer.offerId] + _offerExecution.amount > offer.capacity)) { 
            revert MaxCapacityExceededError();
        }

        _checkValidators(_offerExecution.offer, _tokenId);
    }

Assessed type

DoS

Incorrect circular array check in _updatePendingWithdrawalWithQueue flow , causing received funds added to the wrong queues

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L643

Vulnerability details

Impact

Incorrect circular array check in _updatePendingWithdrawalWithQueue flow , causing received funds to be added to the wrong queues

Proof of Concept

In Pool.sol, queueClaimAll flow will transfer received funds(returned funds from loans) for each queue to newer queues.

Received funds for a given queue are intended to be distributed to newer queues:

   /// @dev We go from idx to newer queues. Each getTotalReceived is the total        
   /// returned from loans for that queue. All future queues/pool also have a piece of it.
   /// X_i: Total received for queue `i`
   /// X_1  = Received * shares_1 / totalShares_1
   /// X_2 = (Received - (X_1)) * shares_2 / totalShares_2 ...
   /// Remainder goes to the pool.

This logic is implemented in _updatePendingWithdrawalWithQueue(). Due to queue arrays are circular, the array index never exceeds getMaxTotalWithdrawalQueues and will restart from 0. % totalQueues should be used when checking array indexes in most cases.

However, in the queue index for-loop, if (secondIdx == _cachedPendingQueueIndex + 1) {break;} is used to break the loop instead of secondIdx == (_cachedPendingQueueIndex + 1)%totalQueues.

This is problematic in some cases:

(1) When _cachedPendingQueueIndex < getMaxTotalWithdrawalQueues.
_updatePendingWithdrawalWithQueue() will always skip the oldest queue when distributing getTotalReceived[_idx] funds.
In _queueClaimAll(), the first for-loop start with the oldestQueueIndex ((_cachedPendingQueueIndex + 1) % totalQueues + 0)%totalQueues). When (_cachedPendingQueueIndex + 1) % totalQueues== _cachedPendingQueueIndex + 1, this first iteration will always result in a break in the second for-loop, where secondIdx == oldestQueueIndex == _cachedPendingQueueIndex + 1.

As a result, any received funds from the oldesQueueIndex ( getTotalReceived[oldestQueueIndex]) will not be distributed and directly deleted ( getTotalReceived[_idx] = 0;).

(2) When _cachedPendingQueueIndex == getMaxTotalWithdrawalQueues
The second for-loop will never break, because secondIdx < _cachedPendingQueueIndex + 1.

for (uint256 i; i < totalQueues;) will always run getMaxTotalWithdrawalQueues+1 times.

This will result in received funds from any queues being distributed to both newer queues and older queues.

Tools Used

Manual

Recommended Mitigation Steps

Based on my understanding, this should be if (i≠0 && secondIdx == (_cachedPendingQueueIndex + 1)%totalQueues) { break;}

Assessed type

Error

MultiSourceLoan#addNewTranche can be DoS

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L359

Vulnerability details

Impact

The addNewTranche function is unavailable due to a DoS attack.

Proof of Concept

The addNewTranche function checks whether the number of _loan.tranches has exceeded MaxTranches.

    function addNewTranche(....) external nonReentrant returns (uint256, Loan memory) {
        ....
        if (_loan.tranche.length == getMaxTranches) {
            revert TooManyTranchesError();
        }
    }

getMaxTranches sets 20 in the test code

abstract contract MultiSourceCommons is TestLoanSetup {
    uint256 internal _maxTranches = 20;
}

An attacker can pass in a small amount of principalAmount, allowing _loan.tranche to increase.

When the length reaches the maximum, the addNewTranche function cannot be called.

An attacker can front-running the addNewTranche function and call it several times before the user, maximizing the array length and causing the user's call to fail.

If getMaxTranches is set to a large value, an attacker can also attack, because executing this function consumes excessive gas when the array length is too large,
The caller will fail the call due to insufficient gas.

Tools Used

vscode, manual

Recommended Mitigation Steps

Set the minimum principalAmount.
Or use maps to hold Tranche data instead of arrays.

Assessed type

DoS

LiquidationDistributor#distribute function lacks permission control

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/LiquidationDistributor.sol#L32

Vulnerability details

Impact

An attacker can use the distribute function to call Pool#loanLiquidation to add any number of interest/fee to the Pool.

Proof of Concept

The loanLiquidation function in Pool requires permission to access:

    function loanLiquidation(
       ....
@>  ) external override onlyAcceptedCallers {
        uint256 netApr = _netApr(_apr, _protocolFee);
        uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
        uint256 fees = IFeeManager(getFeeManager).processFees(_received, 0);
        getCollectedFees += fees;
        _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, _received - fees);
    }

    modifier onlyAcceptedCallers() {
        if (!_acceptedCallers.contains(msg.sender)) {
            revert CallerNotAccepted();
        }
        _;
    }

modifier onlyAcceptedCallers indicates that msg.sender needs to be added to the trust list before it can be called.

The distribute function in the LiquidationDistributor contract is a public function and calls Pool#loanLiquidation:

distribute->_handleTrancheExcess->_handleLoanManagerCall:

  function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
        ......
        if (_proceeds > totalPrincipalAndPaidInterestOwed + totalPendingInterestOwed) {
            for (uint256 i = 0; i < _loan.tranche.length;) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
@>                _handleTrancheExcess(
                    _loan.principalAddress,
                    thisTranche,
                    msg.sender,
                    _proceeds,
                    totalPrincipalAndPaidInterestOwed + totalPendingInterestOwed
                );
                unchecked {
                    ++i;
                }
            }
        } else {
            for (uint256 i = 0; i < _loan.tranche.length && _proceeds > 0;) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
@>              _proceeds = _handleTrancheInsufficient(
                    _loan.principalAddress, thisTranche, msg.sender, _proceeds, owedPerTranche[i]
                );
                unchecked {
                    ++i;
                }
            }
        }
    }

    function _handleTrancheExcess(....) private {
        uint256 excess = _proceeds - _totalOwed;
        /// Total = principal + accruedInterest +  pendingInterest + pro-rata remainder
        uint256 owed = _tranche.principalAmount + _tranche.accruedInterest
            + _tranche.principalAmount.getInterest(_tranche.aprBps, block.timestamp - _tranche.startTime);
        uint256 total = owed + excess.mulDivDown(owed, _totalOwed);
@>      _handleLoanManagerCall(_tranche, total);
        ERC20(_tokenAddress).safeTransferFrom(_liquidator, _tranche.lender, total);
    }

    function _handleLoanManagerCall(IMultiSourceLoan.Tranche calldata _tranche, uint256 _sent) private {
        if (getLoanManagerRegistry.isLoanManager(_tranche.lender)) {
@>          LoanManager(_tranche.lender).loanLiquidation(
                _tranche.loanId,
                _tranche.principalAmount,
                _tranche.aprBps,
                _tranche.accruedInterest,
                0,
                _sent,
                _tranche.startTime
            );
        }
    }

The distribute function has no permission control, and an attacker can construct appropriate parameters to call Pool#loanLiquidation and pass in arbitrary parameters.
In this way, the interest/fee in the Pool can be arbitrarily set.

Tools Used

vscode, manual

Recommended Mitigation Steps

AuctionLoanLiquidator#settleAuction calls the distribute function:

    function settleAuction(Auction calldata _auction, IMultiSourceLoan.Loan calldata _loan) external nonReentrant {
        .....
        _liquidationDistributor.distribute(proceeds, _loan);
        .....
    }

We should restrict the distribute function to only being called by Liquidator:

-  function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external {
+  function distribute(uint256 _proceeds, IMultiSourceLoan.Loan calldata _loan) external onlyLiquidator{

Assessed type

Access Control

validateOffer() reentry to manipulate exchangeRate

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L415

Vulnerability details

Vulnerability details

The current mechanism of validateOffer() is to first book _outstandingValues to increase, but assets.balanceOf(address(this)) doesn't decrease immediately

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
..

        /// @dev Since the balance of the pool includes capital that is waiting to be claimed by the queues,
        ///      we need to check if the pool has enough capital to fund the loan.
        ///      If that's not the case, and the principal is larger than the currentBalance, the we need to reallocate
        ///      part of it.
        if (principalAmount > undeployedAssets) {
            revert InsufficientAssetsError();
        } else if (principalAmount > currentBalance) {
            IBaseInterestAllocator(getBaseInterestAllocator).reallocate(
                currentBalance, principalAmount - currentBalance, true
            );
        }
@>      /// @dev If the txn doesn't revert, we can assume the loan was executed.
@>      _outstandingValues = _getNewLoanAccounting(principalAmount, _netApr(apr, _protocolFee));
    }

I.e.: After this method is called, _getUndeployedAssets() is unchanged, but _getTotalOutstandingValue() is increased, so totalAssets() is increased, but totalSupply is unchanged, so exchangeRate is get bigger.

Originally, it was expected that after that, the Pool balance would be transferred at MultiSourceLoan, so _getUndeployedAssets() becomes smaller and exchangeRate returns to normal.

But if it's possible to do callback malicious logic before MultiSourceLoan transfers away the Pool balance, it's possible to take advantage of this exchangeRate that becomes larger

Example:
Suppose: _getUndeployedAssets() = 1000 _getTotalOutstandingValue() = 1000 totalSupply = 2000
so:
totalAssets() = 2000
exchangeRate = 1:1

  1. alice call MultiSourceLoan.emitLoan()
    • offer.lender = pool
    • offer.principalAmount = 500
    • offer.validators = CustomContract -> for callback
  2. emitLoan() -> Pool.validateOffer()
    • _getUndeployedAssets() = 1000 (no change)
    • _getTotalOutstandingValue() = 1000 + 500 = 1500 (more 500)
    • totalAssets() = 2500
    • exchangeRate = 1.25 : 1
  3. emitLoan() -> _checkValidators() -> CustomContract.validateOffer()
    • in CustomContract.validateOffer() call pool.redeem(shares) use exchangeRate = 1.25 : 1 to get more assets
  4. emitLoan() -> asset.safeTransferFrom(pool,receiver,500)
    • _getUndeployedAssets() -= 500
    • exchangeRate Expect to return to normal

Impact

Manipulating the exchangeRate to redeem additional assets

Recommended Mitigation

In validateOffer(), restrict offer.validators to be an empty array to avoid callbacks.

Assessed type

Context

emitLoan() lack of checks <=getMaxTranches

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L124-L128

Vulnerability details

Vulnerability details

Currently emitLoan() doesn't limit _loan.tranche.length <= getMaxTranches

And in addNewTranche() it's determining that _loan.tranche.length == getMaxTranches will revert TooManyTranchesError().

    function addNewTranche(
        RenegotiationOffer calldata _renegotiationOffer,
        Loan memory _loan,
        bytes calldata _renegotiationOfferSignature
    ) external nonReentrant returns (uint256, Loan memory) {
...
        if (_loan.tranche.length == getMaxTranches) {
            revert TooManyTranchesError();
        }

This way, as long as emitLoan() is executed with tranche.length == getMaxTranches + 1

and then addNewTranche() to skip the limit and add unlimited tranches.

Impact

Adding too many tranches causes GAS_OUT, which can lead to failure of liquidation, and so on.

Recommended Mitigation

    function _processOffersFromExecutionData(
        address _borrower,
        address _principalReceiver,
        address _principalAddress,
        address _nftCollateralAddress,
        uint256 _tokenId,
        uint256 _duration,
        OfferExecution[] calldata _offerExecution
    ) private returns (uint256, uint256[] memory, Loan memory, uint256) {
...

+       if (tranche.length > getMaxTranches) {
+           revert TooManyTranchesError();
+       }
      
        Loan memory loan = Loan(
            _borrower,
            _tokenId,
            _nftCollateralAddress,
            _principalAddress,
            totalAmount,
            block.timestamp,
            _duration,
            tranche,
            protocolFee.fraction
        );

        return (loanId, offerIds, loan, totalFee);
    }

Assessed type

Context

loan.hash() does not contain protocolFee

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/utils/Hash.sol#L117

Vulnerability details

Vulnerability details

The current IMultiSourceLoan.loop.hash() does not contain protocolFee

    function emitLoan(LoanExecutionData calldata _loanExecutionData)
        external
        nonReentrant
        returns (uint256, Loan memory)
    {
...
@>      _loans[loanId] = loan.hash();
        emit LoanEmitted(loanId, offerIds, loan, totalFee);

        return (loanId, loan);
    }

    function hash(IMultiSourceLoan.Loan memory _loan) internal pure returns (bytes32) {
        bytes memory trancheHashes;
        for (uint256 i; i < _loan.tranche.length;) {
            trancheHashes = abi.encodePacked(trancheHashes, _hashTranche(_loan.tranche[i]));
            unchecked {
                ++i;
            }
        }
        return keccak256(
            abi.encode(
                _MULTI_SOURCE_LOAN_HASH,
                _loan.borrower,
                _loan.nftCollateralTokenId,
                _loan.nftCollateralAddress,
                _loan.principalAddress,
                _loan.principalAmount,
                _loan.startTime,
                _loan.duration,
@>              //@audit miss protocolFee
                keccak256(trancheHashes)
            )
        );
    }

   struct Loan {
        address borrower;
        uint256 nftCollateralTokenId;
        address nftCollateralAddress;
        address principalAddress;
        uint256 principalAmount;
        uint256 startTime;
        uint256 duration;
        Tranche[] tranche;
@>      uint256 protocolFee;
    }

Then you can specify protocolFee arbitrarily in many methods, but the _baseLoanChecks() security check doesn't revert.

    function _baseLoanChecks(uint256 _loanId, Loan memory _loan) private view {
@>      if (_loan.hash() != _loans[_loanId]) {
            revert InvalidLoanError(_loanId);
        }
        if (_loan.startTime + _loan.duration < block.timestamp) {
            revert LoanExpiredError();
        }
    }

Example:repayLoan(loadn.protocolFee=0) to escape fees and cause a LoanManager accounting error
refinancePartial()/refinanceFull() can also specify the wrong fees to skip the fees

Impact

The loan hash does not contain a protocolFee, leading to an arbitrary protocolFee that can be specified to escape fees or cause a accounting error

Recommended Mitigation

    function hash(IMultiSourceLoan.Loan memory _loan) internal pure returns (bytes32) {
        bytes memory trancheHashes;
        for (uint256 i; i < _loan.tranche.length;) {
            trancheHashes = abi.encodePacked(trancheHashes, _hashTranche(_loan.tranche[i]));
            unchecked {
                ++i;
            }
        }
        return keccak256(
            abi.encode(
                _MULTI_SOURCE_LOAN_HASH,
                _loan.borrower,
                _loan.nftCollateralTokenId,
                _loan.nftCollateralAddress,
                _loan.principalAddress,
                _loan.principalAmount,
                _loan.startTime,
                _loan.duration, 
                keccak256(trancheHashes),
+               _loan.protocolFee
            )
        );
    }

Assessed type

Context

Inconsistent accounting of undeployedAssets might result in undesired optimal range in the pool

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L398
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L564-L565
https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L572

Vulnerability details

Impact

Inconsistent accounting of undeployedAssets might result in undesired optimal range in the pool

Proof of Concept

undeployedAssets is calculated inconsistently. Currently in _getUndeployedAssets() the protocol collected fees are subtracted, however, in validateOffer, the protocol collected fees are not subtracted.

(1)_getUndeployedAssets():This is called in deployWithdrawalQueue() to calculate proRata liquid assets to the queue.contractAddress.

    function _getUndeployedAssets() private view returns (uint256) {
        return asset.balanceOf(address(this)) + IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated()
|>            - getAvailableToWithdraw - getCollectedFees;
    }

(2)uint256 undeployedAssets: this is manually calculated in validateOffer flow, which is used check whether the pool has enough undeployed Assets to cover loan.principalAmount.

    function validateOffer(bytes calldata _offer, uint256 _protocolFee) external override onlyAcceptedCallers {
...
        uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
         //@audit getCollectedFees is not subtracted
|>        uint256 undeployedAssets = currentBalance + baseRateBalance;
        (uint256 principalAmount, uint256 apr) = IPoolOfferHandler(getUnderwriter).validateOffer(
            IBaseInterestAllocator(getBaseInterestAllocator).getBaseAprWithUpdate(), _offer
        );
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L398)
Note that in (2), undeployedAssets are inflated because getCollectedFees are fees protocol collected from liquidation/repayment flows and shouldn't be considered as liquid assets to cover the loan principal amount.

(3)_reallocate(): This also manually calculate total undeployedassets amount, but again didn't account for getCollectedFees. _reaalocate() balances optimal target idle assets ratio by checking currentBalance / total ratio. Here currentBalance should be additionally subtracted by getCollectedFees because fees are set aside and shouldn't be considered idle. This affects optimal range check.

    function _reallocate() private returns (uint256, uint256) {
        /// @dev Balance that is idle and belongs to the pool (not waiting to be claimed)
        uint256 currentBalance = asset.balanceOf(address(this)) - getAvailableToWithdraw;
        if (currentBalance == 0) {
            revert AllocationAlreadyOptimalError();
        }
        uint256 baseRateBalance = IBaseInterestAllocator(getBaseInterestAllocator).getAssetsAllocated();
        uint256 total = currentBalance + baseRateBalance;
        uint256 fraction = currentBalance.mulDivDown(PRINCIPAL_PRECISION, total);
...

(https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L572)

Inconsistent accounting in various flows may result in incorrect checks or undesirable optimal ranges.

Tools Used

Manual

Recommended Mitigation Steps

Account for getCollectedFees in (2)&(3).

Assessed type

Other

confirmUnderwriter() need to recalculate getMinTimeBetweenWithdrawalQueues

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/LoanManager.sol#L127

Vulnerability details

Vulnerability details

getMinTimeBetweenWithdrawalQueues is very important for Pool.

If getMinTimeBetweenWithdrawalQueues is too small, pendingQueues will be overwritten too early, and when Loan pays off, it won't be able to find the corresponding queues.

So we will calculate getMinTimeBetweenWithdrawalQueues by MaxDuration + _LOAN_BUFFER_TIME to make sure it won't be overwritten too early.

    constructor(
        address _feeManager,
        address _offerHandler,
        uint256 _waitingTimeBetweenUpdates,
        OptimalIdleRange memory _optimalIdleRange,
        uint256 _maxTotalWithdrawalQueues,
        uint256 _reallocationBonus,
        ERC20 _asset,
        string memory _name,
        string memory _symbol
    ) ERC4626(_asset, _name, _symbol) LoanManager(tx.origin, _offerHandler, _waitingTimeBetweenUpdates) {

....

@>      getMinTimeBetweenWithdrawalQueues = (IPoolOfferHandler(_offerHandler).getMaxDuration() + _LOAN_BUFFER_TIME)
            .mulDivUp(1, _maxTotalWithdrawalQueues);

But switching the new getUnderwriter/_offerHandler doesn't recalculate the getMinTimeBetweenWithdrawalQueues.

    function confirmUnderwriter(address __underwriter) external onlyOwner {
        if (getPendingUnderwriterSetTime + UPDATE_WAITING_TIME > block.timestamp) {
            revert TooSoonError();
        }
        if (getPendingUnderwriter != __underwriter) {
            revert InvalidInputError();
        }

@>      getUnderwriter = __underwriter;
        getPendingUnderwriter = address(0);
        getPendingUnderwriterSetTime = type(uint256).max;

        emit UnderwriterSet(__underwriter);
    }

This may break the expectation of getMinTimeBetweenWithdrawalQueues, and the new getUnderwriter.getMaxDuration is larger than the old one, which may cause pendingQueues to be overwritten prematurely

Impact

The new getUnderwriter.getMaxDuration is larger than the old one, which may cause pendingQueues to be overwritten prematurely.

Recommended Mitigation

Pool overrides confirmUnderwriter() with an additional recalculation of getMinTimeBetweenWithdrawalQueues and must not be smaller than the old one, to avoid premature overwriting of the previous one.

contract Pool is ERC4626, InputChecker, IPool, IPoolWithWithdrawalQueues, LoanManager, ReentrancyGuard {
-   uint256 public immutable getMinTimeBetweenWithdrawalQueues;
+   uint256 public getMinTimeBetweenWithdrawalQueues;
...
+   function confirmUnderwriter(address __underwriter) external override onlyOwner {
+           super.confirmUnderwriter(__underwriter);
+           uint256 newMinTime = (IPoolOfferHandler(__underwriter).getMaxDuration() + _LOAN_BUFFER_TIME)
+            .mulDivUp(1, _maxTotalWithdrawalQueues);
+           require(newMinTime >= getMinTimeBetweenWithdrawalQueues,"invalid");
+           getMinTimeBetweenWithdrawalQueues = newMinTime;
+    }

Assessed type

Context

mergeTranches()/refinancePartial() lack of nonReentrant

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L389

Vulnerability details

Vulnerability details

mergeTranches() the method's code implementation is as follows::

 function mergeTranches(uint256 _loanId, Loan memory _loan, uint256 _minTranche, uint256 _maxTranche)
        external
        returns (uint256, Loan memory)
    {
        _baseLoanChecks(_loanId, _loan);
        uint256 loanId = _getAndSetNewLoanId();
        Loan memory loanMergedTranches = _mergeTranches(loanId, _loan, _minTranche, _maxTranche);
        _loans[loanId] = loanMergedTranches.hash();
        delete _loans[_loanId];

        emit TranchesMerged(loanMergedTranches, _minTranche, _maxTranche);

        return (loanId, loanMergedTranches);
    }

As shown above, this method lacks reentrancy protection, which could allow reentrancy attacks to manipulate the _loans[].

Example:
Suppose _loans[1] = {NFT = 1}

  1. Alice calls refinanceFromLoanExecutionData(_loans[1],LoanExecutionData)
    • LoanExecutionData.ExecutionData.OfferExecution.LoanOffer.OfferValidator[0].validator = CustomContract => for callback
  2. refinanceFromLoanExecutionData() -> _processOffersFromExecutionData()-> _validateOfferExecution()->_checkValidators()->IOfferValidator(CustomContract).validateOffer()
  3. in IOfferValidator(CustomContract).validateOffer() call MultiSourceLoan.mergeTranches(_loans[1]) -->pass without nonReentrant
    • _loans[3] = newLoan.hash()
  4. return to refinanceFromLoanExecutionData(), will execute:
    • _loans[2] = newOtherLoan.hash()

There will be _loans[2] and _loans[3] , both containing NFT=1.
Note: Both Loans 's lender are all himself

  1. the user can repayLoan(_loans[2]) and get the NFT back.
  2. use the NFT to borrow other people's funds, e.g. to generate _loans[100].
  3. repayLoan(_loans[3]), get NFT back

Impact

Stealing funds

Proof of Concept

Recommended Mitigation

add nonReentrant

 function mergeTranches(uint256 _loanId, Loan memory _loan, uint256 _minTranche, uint256 _maxTranche)
        external
+       nonReentrant
        returns (uint256, Loan memory)
    {
        _baseLoanChecks(_loanId, _loan);
        uint256 loanId = _getAndSetNewLoanId();

    function refinancePartial(RenegotiationOffer calldata _renegotiationOffer, Loan memory _loan)
        external
+       nonReentrant
        returns (uint256, Loan memory)
    {

Assessed type

Context

Pool.getCollectedFees Lack of method for claim

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L53

Vulnerability details

Vulnerability details

when loanRepayment() / loanLiquidation()

Fees accumulated to getCollectedFees

    function loanRepayment(
        uint256 _loanId,
        uint256 _principalAmount,
        uint256 _apr,
        uint256,
        uint256 _protocolFee,
        uint256 _startTime
    ) external override onlyAcceptedCallers {
        uint256 netApr = _netApr(_apr, _protocolFee);
        uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
        uint256 received = _principalAmount + interestEarned;
        uint256 fees = IFeeManager(getFeeManager).processFees(_principalAmount, interestEarned);
@>      getCollectedFees += fees;
        _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, received - fees);
    }

    /// @inheritdoc LoanManager
    function loanLiquidation(
        uint256 _loanId,
        uint256 _principalAmount,
        uint256 _apr,
        uint256,
        uint256 _protocolFee,
        uint256 _received,
        uint256 _startTime
    ) external override onlyAcceptedCallers {
        uint256 netApr = _netApr(_apr, _protocolFee);
        uint256 interestEarned = _principalAmount.getInterest(netApr, block.timestamp - _startTime);
        uint256 fees = IFeeManager(getFeeManager).processFees(_received, 0);
@>      getCollectedFees += fees;
        _loanTermination(msg.sender, _loanId, _principalAmount, netApr, interestEarned, _received - fees);
    }

But currently Pool.sol does not provide a method to claim and reduce getCollectedFees

Impact

Pool.getCollectedFees can't be claimed.

Recommended Mitigation

Add a method so that administrators can claim getCollectedFees.

Assessed type

Context

settleWithBuyout() cannot pay triggerFee

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L97

Vulnerability details

Vulnerability details

in settleWithBuyout use safeTransfer() to pay triggerFee

    function settleWithBuyout(
        address _nftAddress,
        uint256 _tokenId,
        Auction calldata _auction,
        IMultiSourceLoan.Loan calldata _loan
    ) external nonReentrant {
        /// TODO: Originator fee
        _checkAuction(_nftAddress, _tokenId, _auction);
        uint256 timeLimit = _auction.startTime + _timeForMainLenderToBuy;
        if (timeLimit < block.timestamp) {
            revert OptionToBuyExpiredError(timeLimit);
        }
        uint256 largestTrancheIdx;
        uint256 largestPrincipal;
        for (uint256 i = 0; i < _loan.tranche.length;) {
            if (_loan.tranche[i].principalAmount > largestPrincipal) {
                largestPrincipal = _loan.tranche[i].principalAmount;
                largestTrancheIdx = i;
            }
            unchecked {
                ++i;
            }
        }
        if (msg.sender != _loan.tranche[largestTrancheIdx].lender) {
            revert NotMainLenderError();
        }
        ERC20 asset = ERC20(_auction.asset); 
        uint256 totalOwed;
        for (uint256 i; i < _loan.tranche.length;) {
            if (i != largestTrancheIdx) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                uint256 owed = thisTranche.principalAmount + thisTranche.accruedInterest
                    + thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
                totalOwed += owed; 
                asset.safeTransferFrom(msg.sender, thisTranche.lender, owed);
            }
            unchecked {
                ++i;
            }
        }
        IMultiSourceLoan(_auction.loanAddress).loanLiquidated(_auction.loanId, _loan);

@>      asset.safeTransfer(_auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS));

        ERC721(_loan.nftCollateralAddress).transferFrom(address(this), msg.sender, _tokenId);

        delete _auctions[_nftAddress][_tokenId];

        emit AuctionSettledWithBuyout(_auction.loanAddress, _auction.loanId, _nftAddress, _tokenId, largestTrancheIdx);
    }

Unlike settleAuction() the funds are not in the contract and transfer() won't work.

Correct should be: asset.safeTransferFrom(msg.sender, _auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS));

Impact

cannot pay triggerFee

Recommended Mitigation

    function settleWithBuyout(
        address _nftAddress,
        uint256 _tokenId,
        Auction calldata _auction,
        IMultiSourceLoan.Loan calldata _loan
    ) external nonReentrant {
...
-       asset.safeTransfer(_auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS));
+       asset.safeTransferFrom(msg.sender, _auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS));`

        ERC721(_loan.nftCollateralAddress).transferFrom(address(this), msg.sender, _tokenId);

        delete _auctions[_nftAddress][_tokenId];

        emit AuctionSettledWithBuyout(_auction.loanAddress, _auction.loanId, _nftAddress, _tokenId, largestTrancheIdx);
    }

Assessed type

Context

settleWithBuyout() lack of call LoanManager.loanRepayment()

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/AuctionWithBuyoutLoanLiquidator.sol#L83-L94

Vulnerability details

Vulnerability details

in AuctionWithBuyoutLoanLiquidator
The lender who has lent out the most can purchase the NFT through settleWithBuyout()

    function settleWithBuyout(
        address _nftAddress,
        uint256 _tokenId,
        Auction calldata _auction,
        IMultiSourceLoan.Loan calldata _loan
    ) external nonReentrant {
        /// TODO: Originator fee
        _checkAuction(_nftAddress, _tokenId, _auction);
        uint256 timeLimit = _auction.startTime + _timeForMainLenderToBuy;
        if (timeLimit < block.timestamp) {
            revert OptionToBuyExpiredError(timeLimit);
        }
        uint256 largestTrancheIdx;
        uint256 largestPrincipal;
        for (uint256 i = 0; i < _loan.tranche.length;) {
            if (_loan.tranche[i].principalAmount > largestPrincipal) {
                largestPrincipal = _loan.tranche[i].principalAmount;
                largestTrancheIdx = i;
            }
            unchecked {
                ++i;
            }
        }
        if (msg.sender != _loan.tranche[largestTrancheIdx].lender) {
            revert NotMainLenderError();
        }
        ERC20 asset = ERC20(_auction.asset); 
        uint256 totalOwed;
        for (uint256 i; i < _loan.tranche.length;) {
            if (i != largestTrancheIdx) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                uint256 owed = thisTranche.principalAmount + thisTranche.accruedInterest
                    + thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
                totalOwed += owed; 
                asset.safeTransferFrom(msg.sender, thisTranche.lender, owed);
@>              //@audit miss notice LoanManager if lender is LoanManager
            }
            unchecked {
                ++i;
            }
        }
        IMultiSourceLoan(_auction.loanAddress).loanLiquidated(_auction.loanId, _loan);

        asset.safeTransfer(_auction.originator, totalOwed.mulDivDown(_auction.triggerFee, _BPS));

        ERC721(_loan.nftCollateralAddress).transferFrom(address(this), msg.sender, _tokenId);

        delete _auctions[_nftAddress][_tokenId];

        emit AuctionSettledWithBuyout(_auction.loanAddress, _auction.loanId, _nftAddress, _tokenId, largestTrancheIdx);
    }

The above code repays other lenders, but it lacks notice the lender If the lender is a LoanManager, notify it to accounting using ILoanManager(tranche.lender).loanRepayment() like in MultiSourceLoan or LiquidationDistributor.

It's crucial to notify the LoanManager for accounting.
Failure to do so will result in an incorrect allocation of repaid assets to the correct WithdrawalQueue.

Impact

The lack of notification to the LoanManager for accounting will result in incorrect asset allocation.

Recommended Mitigation

suggest:

  1. add getLoanManagerRegistry to contract
  2. call loanRepayment() in settleWithBuyout()
    function settleWithBuyout(
        address _nftAddress,
        uint256 _tokenId,
        Auction calldata _auction,
        IMultiSourceLoan.Loan calldata _loan
    ) external nonReentrant {
...
        for (uint256 i; i < _loan.tranche.length;) {
            if (i != largestTrancheIdx) {
                IMultiSourceLoan.Tranche calldata thisTranche = _loan.tranche[i];
                uint256 owed = thisTranche.principalAmount + thisTranche.accruedInterest
                    + thisTranche.principalAmount.getInterest(thisTranche.aprBps, block.timestamp - thisTranche.startTime);
                totalOwed += owed; 
                asset.safeTransferFrom(msg.sender, thisTranche.lender, owed);

+               if (getLoanManagerRegistry.isLoanManager(tranche.lender)) {
+                   ILoanManager(thisTranche.lender).loanRepayment(
+                       thisTranche.loanId,
+                       thisTranche.principalAmount,
+                       thisTranche.aprBps,
+                       thisTranche.accruedInterest,
+                       _loan.protocolFee,
+                       thisTranche.startTime
+                   );
+               }

            }
            unchecked {
                ++i;
            }
        }
....
    }

Assessed type

Context

_updatePendingWithdrawalWithQueue() should accumulate pendingForQueue

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/pools/Pool.sol#L678

Vulnerability details

Vulnerability details

_queueClaimAll() is used to allocate getTotalReceived[]

The main calculation is in the _updatePendingWithdrawalWithQueue() method

This method has an important mechanism to calculate All future queues/pools also have a piece of it.

    function _queueClaimAll(uint256 _totalToBeWithdrawn, uint256 _cachedPendingQueueIndex) private {
        _reallocateOnWithdrawal(_totalToBeWithdrawn);
        uint256 totalQueues = (getMaxTotalWithdrawalQueues + 1);
        uint256 oldestQueueIdx = (_cachedPendingQueueIndex + 1) % totalQueues;
        uint256[] memory pendingWithdrawal = new uint256[](totalQueues);
        for (uint256 i; i < pendingWithdrawal.length;) {
            uint256 idx = (oldestQueueIdx + i) % totalQueues;
@>          _updatePendingWithdrawalWithQueue(idx, _cachedPendingQueueIndex, pendingWithdrawal);
            unchecked {
                ++i;
            }
        }
        getAvailableToWithdraw = 0;

        for (uint256 i; i < pendingWithdrawal.length;) {
            if (pendingWithdrawal[i] == 0) {
                unchecked {
                    ++i;
                }
                continue;
            }
            address queueAddr = _deployedQueues[i].contractAddress;
            uint256 amount = pendingWithdrawal[i];

            asset.safeTransfer(queueAddr, amount);
            emit QueueClaimed(queueAddr, amount);
            unchecked {
                ++i;
            }
        }
    }
....

    function _updatePendingWithdrawalWithQueue(
        uint256 _idx,
        uint256 _cachedPendingQueueIndex,
        uint256[] memory _pendingWithdrawal
    ) private returns (uint256[] memory) {
        uint256 totalReceived = getTotalReceived[_idx];
        uint256 totalQueues = getMaxTotalWithdrawalQueues + 1;
        /// @dev Nothing to be returned
        if (totalReceived == 0) {
            return _pendingWithdrawal;
        }
        getTotalReceived[_idx] = 0;

        /// @dev We go from idx to newer queues. Each getTotalReceived is the total
        /// returned from loans for that queue. All future queues/pool also have a piece of it.
        /// X_i: Total received for queue `i`
        /// X_1  = Received * shares_1 / totalShares_1
        /// X_2 = (Received - (X_1)) * shares_2 / totalShares_2 ...
        /// Remainder goes to the pool.
        for (uint256 i; i < totalQueues;) {
            uint256 secondIdx = (_idx + i) % totalQueues;
            QueueAccounting memory queueAccounting = _queueAccounting[secondIdx];
            if (queueAccounting.thisQueueFraction == 0) {
                unchecked {
                    ++i;
                }
                continue;
            }
            /// @dev We looped around.
            if (secondIdx == _cachedPendingQueueIndex + 1) {
                break;
            }
            uint256 pendingForQueue = totalReceived.mulDivDown(queueAccounting.thisQueueFraction, PRINCIPAL_PRECISION);
            totalReceived -= pendingForQueue;

@>          _pendingWithdrawal[secondIdx] = pendingForQueue;
            unchecked {
                ++i;
            }
        }
        return _pendingWithdrawal;
    }

Currently _updatePendingWithdrawalWithQueue() uses _pendingWithdrawal[secondIdx] = pendingForQueue;
The correct one would be to accumulate: _pendingWithdrawal[secondIdx] += pendingForQueue;

because it can be accumulated multiple times when loop calculating all queues for All future queues/pools also have a piece of it

Impact.

Without using accumulation, the actual amount allocated is partially lost

Recommended Mitigation

    function _updatePendingWithdrawalWithQueue(
        uint256 _idx,
        uint256 _cachedPendingQueueIndex,
        uint256[] memory _pendingWithdrawal
    ) private returns (uint256[] memory) {

            /// @dev We looped around.
            if (secondIdx == _cachedPendingQueueIndex + 1) {
                break;
            }
            uint256 pendingForQueue = totalReceived.mulDivDown(queueAccounting.thisQueueFraction, PRINCIPAL_PRECISION);
            totalReceived -= pendingForQueue;

-           _pendingWithdrawal[secondIdx] = pendingForQueue;
+           _pendingWithdrawal[secondIdx] += pendingForQueue;
            unchecked {
                ++i;
            }
        }
        return _pendingWithdrawal;
    }

Assessed type

Context

The borrower can use `refinanceFromLoanExecutionData` delay on loan

Lines of code

https://github.com/code-423n4/2024-04-gondi/blob/b9863d73c08fcdd2337dc80a8b5e0917e18b036c/src/lib/loans/MultiSourceLoan.sol#L306

Vulnerability details

Impact

The borrower uses refinance to extend the loan and avoid liquidation.

Proof of Concept

In MultiSourceLoan, the borrower can use refinanceFromLoanExecutionData function, refinance the loan.

This function needs to use lender's offer.

The key issue is that offer can be reused if offer.capacity > 0, the offer can be used again.

    function _processOffersFromExecutionData(
        .....
        if (offer.capacity > 0) {
            _used[lender][offer.offerId] += amount;
        } else {
            isOfferCancelled[lender][offer.offerId] = true;
        }
        .....
    }

    function _validateOfferExecution(){
        .....
        if (isOfferCancelled[_lender][offerId] || (offerId <= minOfferId[_lender])) {
            revert CancelledOrExecutedOfferError(_lender, offerId);
        }
        .....
        if ((offer.capacity > 0) && (_used[_offerer][offer.offerId] + _offerExecution.amount > offer.capacity)) {
            revert MaxCapacityExceededError();
        }
        .....
    }

The borrower can use ths offer(offer.capacity > 0) call refinanceFromLoanExecutionData repeatedly.

refinanceFromLoanExecutionData will generate a new loan, the new loan due time will be reset.
Therefore, the borrower can always call this function so that his loan will never be liquidated.

borrower can then act as lender and provide an offer with a capacity greater than 0 to call.

Here is the test code:

    function testRefinanceFromLoanExecutionData2() public {
        (uint256 loanId, IMultiSourceLoan.Loan memory loan) = _getInitialLoan();

        vm.warp(1 days);

        IMultiSourceLoan.LoanOffer memory loanOffer = _getSampleOffer(
            address(collateralCollection),
            collateralTokenId,
            _INITIAL_PRINCIPAL
        );
        loanOffer.duration = 90 days;
        loanOffer.principalAmount *= 2;

        loanOffer.capacity = loanOffer.principalAmount * 10;

        IMultiSourceLoan.LoanExecutionData memory led = IMultiSourceLoan
            .LoanExecutionData(
                _sampleExecutionData(loanOffer, loan.borrower),
                loan.borrower,
                ""
            );
        led.executionData.offerExecution[0].amount = loanOffer.principalAmount;

        testToken.mint(loanOffer.lender, loanOffer.principalAmount * 20);
        vm.prank(loanOffer.lender);
        testToken.approve(address(_msLoan), loanOffer.principalAmount * 20);

        vm.startPrank(_borrower);
        testToken.mint(_borrower, loanOffer.principalAmount * 10);
        testToken.approve(address(_msLoan), loanOffer.principalAmount * 10);

        (uint256 newLoanId, IMultiSourceLoan.Loan memory newLoan) = _msLoan
            .refinanceFromLoanExecutionData(loanId, loan, led);

        _msLoan.refinanceFromLoanExecutionData(newLoanId, newLoan, led);

        vm.stopPrank();
    }

Put the test code in MultiSourceLoan.t.sol.

Test code demonstrates the borrower USES the same offer call refinanceFromLoanExecutionData 2 times.

If not set loanOffer.capacity, failed to perform the test code will, display CancelledOrExecutedOfferError

Tools Used

vscode, manual

Recommended Mitigation Steps

When borrower refinance regenerates the loan, it does not change the maturity of the loan.

Assessed type

Other

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.