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

An attacker can steal funds from the protocol when repaying/seizing/refinancing a loan using reentrancy

Summary

Presence of ERC-777 tokens within the protocol enables potential attackers to drain the entire token balance from the protocol due to reentrancy.

This is because when a contract implementing the IERC777Recipient interface receives ERC-777 tokens, the tokensReceived() function in the receiving contract is called. A malicous user can use this callback to reenter a vulnerable contract.

Vulnerability Details

1. repay()

Pool A (USDC-Token A):

  • Loan Token: USDC

  • Collateral Token: Token A (ERC-777)

  • min loan amount: 100 USDC

Token A balance of the protocol: 50,000

  • The attacker uses a contract that implements the IERC777Recipient interface to borrow 100 USDC and provides 25,000 collateral of Token A.

  • Attacker calls repay()

    • 100 USDC of loan token is transferred to the pool IERC20(loan.loanToken).transferFrom

    • Collateral is transferred from the protocol to the borrower IERC20(loan.collateralToken).transfer

    • Note: The loan info hasn't been updated yet so reentrancy is possible

    • The attacker(borrower) can reenter when it receives tokens using tokensReceived(). Here the attacker calls repay() again.

    • Again 100 USDC is transferred from the attacker to the pool, and 25,000 collateral tokens are transferred to the attacker

  • So the protocol is drained of token A

This reentrancy is possible because the loan is deleted after the token transfer.

https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L343

function repay(uint256[] calldata loanIds) public {
.
.
.
IERC20(loan.collateralToken).transfer(
loan.borrower,
loan.collateral
);
.
.
.
delete loans[loanId];
}

2. seizeLoan()

Similarly, re-entrancy is also possible in seizeLoan() as well because the loan is deleted after the token transfer.

function seizeLoan(uint256[] calldata loanIds) public {
for (uint256 i = 0; i < loanIds.length; i++) {
.
.
IERC20(loan.collateralToken).transfer(
loan.lender,
loan.collateral - govFee
);
.
.
delete loans[loanId]; // state changes should be before token transfer
}
}

3. refinance()

Pool (USDC-Token A):

  • Loan Token: Token A (ERC-777)

  • Collateral Token: USDC

  • min loan amount: 100

Token A balance of the protocol: 50,000

  • The attacker uses a contract that implements the IERC777Recipient interface to borrow 100 loan tokens and provides 2000 USDC as collateral.

  • Attacker calls refinance() for the loan with debt=1000 and the same collateral. (LTV is within limits)

  • Because the attacker requested more debt tokens, additional debt tokens are transferred to the attacker

    • Attacker receives debt tokens

    • Callback function tokensReceived() in the attacker contract is called and attacker calls refinance() again with the same parameters.

    • Since the state of loan wasn't updated, additional debt tokens will be transferred to the attacker

    • The attacker keeps on reentering until pool_balance = 0

  • So the protocol is drained of the pool's debt tokens

This reentrancy is possible because an external call (token transfer) is made before any state changes.

function refinance(Refinance[] calldata refinances) public {
.
.
.
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
);
}
.
loans[loanId].debt = debt;
.
.
.
loans[loanId].collateral = collateral;
// update loan interest rate
loans[loanId].interestRate = pool.interestRate;
// update loan start timestamp
loans[loanId].startTimestamp = block.timestamp;
// update loan auction start timestamp
loans[loanId].auctionStartTimestamp = type(uint256).max;
// update loan auction length
loans[loanId].auctionLength = pool.auctionLength;
// update loan lender
loans[loanId].lender = pool.lender;
.
.
}

Impact

Financial loss

Tools Used

Manual review

Recommendations

Use a ReentrancyGuard for each vulnerable function
OR
make sure token transfers are made after all state changes in each function.

Support

FAQs

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