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

Borrower can prevent his/her loan from being liquidated

Summary

During the Dutch Auction, the borrower can keep calling buyLoan() with the loanId is his/her loan and poolId is the same pool which the loan was borrowed from so that the loan.auctionStartTimestamp will be set to the default value of type(uint256).max. This makes the loan always immature to be seized as seizeLoan() will revert if loan.auctionStartTimestamp == type(uint256).max.

Vulnerability Details

The borrower first needs to calculate the timeElapsed which is responsible for rate validation of the pool:
https://github.com/Cyfrin/2023-07-beedle/blob/main/src/Lender.sol#L474-L478

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();

Then he/she can decide when to buy the loan after the original lender have started the auction for this loan so that it can satisfy the rate validation. After being successfully bought with the same poolId, the loan.auctionStartTimestamp's value will be set to default as you can see here: https://github.com/Cyfrin/2023-07-beedle/blob/main/src/Lender.sol#L521

521 loans[loanId].auctionStartTimestamp = type(uint256).max;

POC

Copy this test case into test/Lender.t.sol

function test_buyLoanWithTheSamePool() public {
test_borrow();
// 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);
bytes32[] memory poolIds = new bytes32[](1);
poolIds[0] = keccak256(
abi.encode(
address(lender1),
address(loanToken),
address(collateralToken)
)
);
// warp to middle of auction
vm.warp(block.timestamp + 23 hours + 59 minutes);
vm.startPrank(borrower);
lender.buyLoan(0, poolIds[0]);
(, , , , , , , , uint256 auctionStartTimestamp, ) = lender.loans(0);
assertEq(auctionStartTimestamp, type(uint256).max);
vm.startPrank(lender1);
// Lender1 can't seize the loan anymore because it has been rebought to the same pool before auction ended
lender.seizeLoan(loanIds);
}

Use forge test --mt test_buyLoanWithTheSamePool to run this test case.

In this POC the borrower waits until there is one minutes left before the auction ending (23 hours 59 minutes, the loan.auctionLength is 1 days) and calls buyLoan(), after the action the lender1 cannot seize the loan anymore.

Impact

If the borrower observes that there is no pool that has the same config of the old pool and has enough balance to buy his/her loan when it come to an end of the auction, he/she can use this workaround to avoid liquidation of the loan. The lender also loses his time waiting for the auction to end but can't seize the loan to get the collateral assets although no one actually buying the loan, the loan is just re-bought itself from the same pool, which is not the lender's original intention.

Tools Used

Manual

Recommendations

  1. Implement a validation that the new pool buying the loan is not the same pool which the loan is from.

Put the code below this line: https://github.com/Cyfrin/2023-07-beedle/blob/main/src/Lender.sol#L493

+ // validate if the same pool is used to buy
+ if (oldPoolId == poolId) revert DeclareANewErrorForThis();

Support

FAQs

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