20,000 USDC
View results
Submission Details
Severity: high
Valid

Non-pool owner can buy a loan causing the loan to be crippled and funds to be stuck

Summary

The buyLoan function does not validate if the caller (msg.sender) is the actual owner of the pool with the given pool id (poolId). This leads to the inability to repay/seize/auction/refinance the loan and the funds being stuck (for both the lender and the borrower).

Vulnerability Details

An auctioned loan can be bought with the buyLoan function, providing the loan id (loanId) and the pool id (poolId) for the pool to move the loan to. However, it is currently not checked if the caller, msg.sender is the actual pool owner. Consequently, anyone can call the buyLoan function for a loan that is currently being auctioned. This will assign the msg.sender as the new lender of the loan (see line 518). Therefore, anyone can be the new lender without having a pool but utilizing someone else's pool.

As a result, whenever the pool id for a loan is determined as the keccak256 hash of the loan.lender, loan.loanToken, and the loan.collateralToken (e.g., by using the getPoolId function), this pool id is potentially different than the actual pool id that was supplied to the buyLoan call.

Subsequently, when the borrower attempts to repay the loan, the determined pool id will use the new loan.lender, which is set to the incorrect lender address and points to a pool that does not yet exist. The repay function will then revert in line 314 due to subtracting the loan debt from outstandingLoans, which is 0.

Ultimately, the loan is stuck, and any further actions are not possible.

Lender.sol#L518

465: function buyLoan(uint256 loanId, bytes32 poolId) public {
466: // get the loan info
467: Loan memory loan = loans[loanId];
468: // validate the loan
469: if (loan.auctionStartTimestamp == type(uint256).max)
470: revert AuctionNotStarted();
471: if (block.timestamp > loan.auctionStartTimestamp + loan.auctionLength)
472: revert AuctionEnded();
473: // calculate the current interest rate
474: uint256 timeElapsed = block.timestamp - loan.auctionStartTimestamp;
475: uint256 currentAuctionRate = (MAX_INTEREST_RATE * timeElapsed) /
476: loan.auctionLength;
477: // validate the rate
478: if (pools[poolId].interestRate > currentAuctionRate) revert RateTooHigh();
479: // calculate the interest
480: (uint256 lenderInterest, uint256 protocolInterest) = _calculateInterest(
481: loan
482: );
483:
484: // reject if the pool is not big enough
485: uint256 totalDebt = loan.debt + lenderInterest + protocolInterest;
486: if (pools[poolId].poolBalance < totalDebt) revert PoolTooSmall();
487:
488: // if they do have a big enough pool then transfer from their pool
489: _updatePoolBalance(poolId, pools[poolId].poolBalance - totalDebt);
490: pools[poolId].outstandingLoans += totalDebt;
491:
492: // now update the pool balance of the old lender
493: bytes32 oldPoolId = getPoolId(
494: loan.lender,
495: loan.loanToken,
496: loan.collateralToken
497: );
498: _updatePoolBalance(
499: oldPoolId,
500: pools[oldPoolId].poolBalance + loan.debt + lenderInterest
501: );
502: pools[oldPoolId].outstandingLoans -= loan.debt;
503:
504: // transfer the protocol fee to the governance
505: IERC20(loan.loanToken).transfer(feeReceiver, protocolInterest);
506:
507: emit Repaid(
508: loan.borrower,
509: loan.lender,
510: loanId,
511: loan.debt + lenderInterest + protocolInterest,
512: loan.collateral,
513: loan.interestRate,
514: loan.startTimestamp
515: );
516:
517: // update the loan with the new info
518: @> loans[loanId].lender = msg.sender; // @audit-info `msg.sender` is not necessarily the pool owner
519: loans[loanId].interestRate = pools[poolId].interestRate;
520: loans[loanId].startTimestamp = block.timestamp;
521: loans[loanId].auctionStartTimestamp = type(uint256).max;
522: loans[loanId].debt = totalDebt;
523:
524: emit Borrowed(
525: loan.borrower,
526: msg.sender,
527: loanId,
528: loans[loanId].debt,
529: loans[loanId].collateral,
530: pools[poolId].interestRate,
531: block.timestamp
532: );
533: emit LoanBought(loanId);
534: }

Impact

Determining the pool id of such a bought loan will be incorrect and not the actual pool id that was supplied to the buyLoan function. This incorrect pool id is potentially pointing to a non-existent pool, causing the borrower to be unable to repay the loan due to reverting in line 314 of the repay function caused by insufficient outstandingLoans (i.e., zero).

Moreover, attempting to use buyLoan will fail as well due to the same reason as above.

Effectively, both the lender and the borrower have their funds stuck.

Tools Used

Manual Review

Recommendations

Consider verifying that the caller (msg.sender) is the actual lender of the given poolId by checking pools[poolId].lender == msg.sender in the buyLoan function.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.