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.
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
Similarly, re-entrancy is also possible in seizeLoan() as well because the loan
is deleted after the token transfer.
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.
Financial loss
Manual review
Use a ReentrancyGuard for each vulnerable function
OR
make sure token transfers are made after all state changes in each function.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.