Thunder Loan

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

Nested same-token flashloan wipes the shared s_currentlyFlashLoaning flag, causing the outer loan's honest repay() to revert

Root + Impact

Description

  • s_currentlyFlashLoaning[token] is a single per-token bool, not a nesting-aware counter. flashloan() sets it to true at the start and unconditionally sets it back to false right before it finishes - regardless of whether some other flashloan on the same token is still open.

  • If a receiver's executeOperation() callback takes out a second (inner) flashloan on the same token - a legitimate composition pattern (e.g. temporarily re-borrowing the same asset to complete an intermediate step) - the inner flashloan() call finishes and correctly repays itself, but on its way out it sets s_currentlyFlashLoaning[token] = false. This silently wipes out the still-open outer loan's bookkeeping.

  • When the outer executeOperation() then calls repay() for its own, still-legitimately-open loan (with sufficient funds and a correct amount), repay() reverts with ThunderLoan__NotCurrentlyFlashLoaning() because the flag was already cleared by the inner call - even though the outer loan was never actually settled.

  • Because the entire outer transaction reverts, all state changes roll back and no funds are ever at risk - this is a function-correctness / composability defect, not a fund-safety one, matching CodeHawks' own Low-impact definition of "funds are not at risk, but function/state behaves incorrectly."

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
...
@> s_currentlyFlashLoaning[token] = true;
...
receiverAddress.functionCall(...); // receiver may re-enter with an inner flashloan() on the SAME token here
...
@> s_currentlyFlashLoaning[token] = false; // inner call's own exit wipes the OUTER loan's flag too
}
function repay(IERC20 token, uint256 amount) public {
@> if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
...
}

Risk

Likelihood:

  • Reason 1 // Requires a receiver contract to specifically implement the "take a nested flashloan of the same token from within its own callback" composition pattern - a real but specific integration pattern, not something any arbitrary direct call triggers.

  • Reason 2 // No attacker or malicious intent is needed - a perfectly honest receiver composing two legitimate same-token flashloan operations in one transaction hits this deterministically every time.

Impact:

  • Impact 1 // The entire outer transaction reverts, so no funds are ever lost or placed at risk - this is exactly CodeHawks' Low-impact category ("funds not at risk, but behavior/state incorrect").

  • Impact 2 // Breaks composability: integrators who reasonably assume same-token flashloans can be nested within a single transaction will have their transactions unexpectedly and unavoidably revert.

Proof of Concept

Ran with forge test --match-path "test/PoC_4.t.sol" -vv: [PASS] testNestedFlashLoanOnSameTokenBreaksOuterRepay() (gas: 311744). A NestedFlashLoanReceiver receives an outer flashloan(amountToBorrow=100e18, params=true). Inside its callback it takes an inner flashloan(50e18, params=false) on the same token, which completes and repays itself honestly. The receiver then tries to repay its own outer loan with sufficient approved funds - and this call reverts with the exact custom error ThunderLoan.ThunderLoan__NotCurrentlyFlashLoaning.selector (asserted precisely via vm.expectRevert(...), not a generic revert), proving the failure is specifically the shared-flag collision and not, e.g., insufficient balance. Full regression suite: 18/18 passing, no regressions - the bug only manifests in this specific nested-same-token composition.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { BaseTest, ThunderLoan } from "./unit/BaseTest.t.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract NestedFlashLoanReceiver {
using SafeERC20 for IERC20;
address public immutable s_thunderLoan;
address public immutable s_owner;
bool public innerLoanAttempted;
constructor(address thunderLoan) {
s_thunderLoan = thunderLoan;
s_owner = msg.sender;
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata params
)
external
returns (bool)
{
bool isOuterCall = abi.decode(params, (bool));
if (isOuterCall) {
innerLoanAttempted = true;
ThunderLoan(s_thunderLoan).flashloan(address(this), IERC20(token), amount / 2, abi.encode(false));
IERC20(token).approve(s_thunderLoan, amount + fee);
ThunderLoan(s_thunderLoan).repay(IERC20(token), amount + fee);
} else {
IERC20(token).approve(s_thunderLoan, amount + fee);
ThunderLoan(s_thunderLoan).repay(IERC20(token), amount + fee);
}
return true;
}
}
contract PoC_4_NestedFlashLoanFlagCollision is BaseTest {
uint256 constant AMOUNT = 10e18;
uint256 constant DEPOSIT_AMOUNT = AMOUNT * 100;
address liquidityProvider = address(123);
address user = address(456);
NestedFlashLoanReceiver receiver;
function setUp() public override {
super.setUp();
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
vm.prank(user);
receiver = new NestedFlashLoanReceiver(address(thunderLoan));
}
function testNestedFlashLoanOnSameTokenBreaksOuterRepay() public {
uint256 amountToBorrow = AMOUNT * 10;
tokenA.mint(address(receiver), AMOUNT * 2);
vm.prank(user);
vm.expectRevert(ThunderLoan.ThunderLoan__NotCurrentlyFlashLoaning.selector);
thunderLoan.flashloan(address(receiver), tokenA, amountToBorrow, abi.encode(true));
assertFalse(thunderLoan.isCurrentlyFlashLoaning(tokenA));
}
}

Recommended Mitigation

- mapping(IERC20 => bool) private s_currentlyFlashLoaning;
+ mapping(IERC20 => uint256) private s_flashLoanDepth;
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
...
- s_currentlyFlashLoaning[token] = true;
+ s_flashLoanDepth[token]++;
...
- s_currentlyFlashLoaning[token] = false;
+ s_flashLoanDepth[token]--;
}
function repay(IERC20 token, uint256 amount) public {
- if (!s_currentlyFlashLoaning[token]) {
+ if (s_flashLoanDepth[token] == 0) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
...
}

Replace the shared per-token bool with a nesting-aware depth counter: increment on entry, decrement on exit, and only revert repay() when the depth is zero. This way an inner loan finishing never wipes out an outer loan's still-open state. A more thorough fix would track each flashloan's expected repayment amount in its own call-scoped context (e.g. a one-time loan id) rather than relying on any shared per-token global state at all. Add a regression test covering "receiver takes a nested same-token flashloan inside its callback, then repays the outer loan" to prevent regressions.

Updates

Lead Judging Commences

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