A reentrancy vulnerability in deposit
enables a flash loaner to fool the protocol that the loan has been repaid. The attacker can steal all the protocol funds this way.
Three critical functions and their implementations make this vulnerability possible.
First, deposit()
allows anyone to deposit tokens and receive AssetTokens
in return.
Second, redeem()
allows for redeeming these AssetTokens
to get the original tokens back, plus any interest accrued (this interest comes from flash loans)
Third, flashloan()
allows users to take out flash loans as long as they return
the loan plus a fee in the same transaction. A flash loaner can loan any underlying amount of tokens from the protocol, as long
as the loan is returned along with a fee of 0.3% by default.
One problem with the implementation of this is the way that the protocol
makes sure that the user has paid back the loaned ERC20 tokens, and the additional fee.
Simplified, the logic of the flashloan
function goes like (let's assume WETH is loaned):
Check the protocol WETH token balance (using token.balanceOf()
)
Send WETH tokens to flash loan receiver
Call the executeOperation
function of the flash loan receiver
Check the protocol WETH token balance, require that it is not less than
the initial from step 1, plus a fee.
The assumption is that the loaner will have to return the tokens using the
repay()
function or simply doing an ERC20 token transfer()
back to the protocol.
However, since there are no reentrancy checks the flash loaner
can make a deposit
with the flash loaned tokens instead (and also supply an extra amount for the required fee percentage).
This will have the effects that:
a) the flashloan balance-after check in step 4 will succed, and
b) the flash loaner will have received AssetTokens
that can now be redeemed for the deposited tokens - tokens that the flash loaner just temporarily owned via the flash loan.
Outlining a basic such attack with some example numbers may look like the following:
Attacker starts with 1 WETH.
Assuming a flashloan fee of 0.3% the attacker decides to
loan around 333 WETH from the protocol. This way the attacker will be
able to cover the resulting fee
of 0.003 * 333 WETH = 0.999 WETH.
Attacker takes out the flash loan and now has 1 + 333 WETH = 334 WETH.
Attacker deposits the 334 WETH and receives AssetTokens in return.
The protocol checks that its balance is at least the original 333 WETH
plus the fee of 1 WETH, which it is thanks to the 334 WETH deposit.
The flashloan transaction has now succeeded, leaving the attacker with
AssetTokens
.
Attacker redeems his asset tokens and gets the underlying 334 WETH.
The below test shows a POC of this.
The test uses the following IFlashLoanReceiver based contract:
The test itself below:
In this case, the attacker roughly 333X's his money with one flashloan.
Importantly, an attacker can make a series of flashloans using this approach until
the protocol is completedly drained, 333-folding his capital for every step.
Although not shown above in the test for simplicity, it is even possible
to make a flashloan within a flashloan in which case no starting capital
would be needed at all (except for gas costs).
Attacker takes out a flash loan of 10 WETH. He will need to repay
that and the fee, a total of 10.03 WETH
During the flash loan, the attacker takes out a second loan of 10 ETH. He will
need to repay 10.03 for that one too. This is possible because there are no
reentrancy checks, and the variable that tracks whether a flash loan
is ongoing, s_currentlyFlashLoaning
, is only set - never verified.
Attacker now controls 20 WETH.
Attacker uses the deposit hack to pay back the first loan of 10.03 owed.
The second flash loan has been repaid and completed. Program flow is now
back inside the executeOperation
of the first flash loan.
Attacker now has 9.97 WETH left, but also some asset tokens that he can redeem.
Attacker redeems his asset tokens and gets 10.03 WETH back.
Now the attacker once again controls 20 WETH, with a remaining debt
of 10.03 that needs to be paid back for the first flash loan in order
for the transaction to complete successfully.
Attacker makes a deposit
of 10.03 WETH to fool ThunderLoan's check for the first flash loan
The transaction containing a flashloan-within-a-flashloan is completed successfully.
Attacker redeems his final 10.03 WETH.
End result: an attacker starting with only gas money now has 20 WETH.
The amounts used are just for illustration, the approach could any amount
including one that drains all the underlying assets of protocol.
High impact - all protocol funds extremely likely to be lost.
Manual review
There are a number of checks that in themselves could completely or partially help against this attack.
Consider implementing all of the below:
Add nonReentrant modifier on all external functions in ThunderLoan.sol
.
Use ReentrancyGuardUpgradeable from OpenZeppelin.
Check the s_currentlyFlashLoaning
mapping at the top of flashloan()
, require it to not be set.
Also note that in a double flash loan like described above, the flag would be unset by the
end of the inner flashloan, while the outer is still executing. This could potentially
affect third party code and enable some read-only reentrancy issues even if reentrancy or
repayment mechanisms were improved.
Improve mechanism of loan repayment. A better design would be to have
ThunderLoan actively pull the payment from the flash loaner using ERC20 transfer() after
the flashloan receiver has finished executing.
The flash loaner would be responsible for setting allowance for the token.
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.