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

Loans can be DoS'ed and funds stolen from other lenders by buying loans with pools which have non-matching `loanToken` and `collateralToken` addresses

Summary

The buyLoan function in the Lender contract does not validate the loanToken and collateralToken token addresses when buying a loan to ensure the loan is moved to the correct pool with the same configured token addresses. As a result, an attacker can exploit this vulnerability to both steal funds from other lenders and cripple a loan, effectively locking the loan's collateral forever and causing a loss of funds for (previous) lender.

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.

Please note that the caller, msg.sender, is not validated to be the owner of the pool with the given poolId. This issue is covered by another high-severity submission - "Non-pool owner can buy a loan causing the loan to be crippled and funds to be stuck". This submission assumes that the msg.sender is the owner of the pool with the given poolId.

If a pool owner buys a loan that has a different loanToken or collateralToken than the new pool, the loan will be correlated to the wrong pool id later on. The loan's new lender is assigned to msg.sender in line 518, i.e., the pool owner, and both the old and the new pool will have their pool balance and outstanding loan accounting variables updated accordingly.

The destination pool with the given poolId id does not necessarily have the same loanToken and collateralToken as the loan. This results in adjusting pool balances of different tokens with different values, which can be exploited to lock the loan's collateral forever and cause accounting issues.

Additionally, 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 (due to the different loanToken or collateralToken). This determined pool id may point to a non-existent pool with zero balance and zero outstandingLoans, thus decrementing leads to an underflow error. This griefs the following actions and causes the collateral to be locked forever:

  • repay will revert in line 314 due to subtracting the loan debt from outstandingLoans, which is 0

  • giveLoan reverts in line 400

  • buyLoan reverts in line 502

  • seizeLoan reverts in line 575

  • refinance reverts in line 633

Re-visiting the above mentioned accounting issue, an attacker can steal funds from other pool owners (lenders).

This is accomplished by creating two pools - one regular pool with a high-value loan token and a second pool with a low-value loan token. The attacker borrows funds, i.e., creates a loan, via the first pool. Then the attacker buys the loan (which has a higher-value loan token) with the second pool id. Both pools, will have their pool balances and outstanding loans accounting variables updated.

However, by having different tokens, token amounts don't reflect the same monetary values. The loan's debt is thus not covered (collateralized) anymore with the correct token by this second pool. Now the attacker and owner of the first pool withdraws the pool balance via the removeFromPool function and steals funds from other pool owners who deposited funds for the same loan token in the Lender contract.

Consider the following example (for simplicity and demonstration purposes, interest was omitted):

Pools:

Pool Lender Collateral Token Loan Token Pool Balance Outstanding Loans
1 Bob USDC WETH 10e18 WETH 0
2 Alice (Attacker) USDC WETH 0 WETH (initially 10e18 WETH, lent out) 10e18 WETH
3 Alice (Attacker) USDC DAI 100e18 DAI 0

Total WETH in the Lender contract: 10e18 WETH

Loans:

Loan Collateral Token Loan Token Collateral Debt Correlated Pool
1 USDC WETH 1 USDC 10e18 WETH 2

Alice, the attacker, starts the auction for loan 1 and buys it by calling buyLoan(..) with the pool id of pool 3.

Please note that the loan ratio can be purposefully set very high, i.e., having little collateral for a large debt, to make the loan unattractive for other buyers. It is only important to have the same maxLoanRatio for both of attacker's pools.

This leads to the following state:

Pools:

Pool Lender Collateral Token Loan Token Pool Balance Outstanding Loans
1 Bob USDC WETH 10e18 WETH 0
2 Alice USDC WETH 0 + 10e18 = 10e18 WETH 10e18 - 10e18 = 0 WETH
3 Alice USDC DAI 100e18 - 10e18 = 90e18 DAI 0 + 10e18 = 10e18 WETH

Alice's pool (pool 2) now has its pool balance back to 10e18 WETH and is able to withdraw the funds via the removeFromPool function, effectively stealing from Bob. The Lender contract will now have 0 WETH.

If Bob attempts to withdraw his pool balance, he will not be able to do so as the Lender contract does not have sufficient WETH funds to cover the withdrawal and faces a loss.

Please note that validation is also missing for maxLoanRatio as well as auctionLength.

Lender.sol#L483

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: @> // @audit-info missing validation to ensure the `loanToken` and `collateralToken` of the loan and the new pool match
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;
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

  • Loan actions can be griefed (DoS'd) resulting in stuck collateral funds

  • Funds from other pool owners can be stolen from the Lender contract thus resulting in a loss of funds for them

Additionally, accounting issues may arise if the loanToken or collateralToken have different decimals than the tokens intended for the pool.

Tools Used

Manual Review

Recommendations

Consider adding validation for pool.loanToken, pool.collateralToken, maxLoanRatio, and auctionLength in the buyLoan function to ensure the pool matches the loan.

Support

FAQs

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