Thunder Loan

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

s_currentlyFlashLoaning is a per-token bool, not a depth counter, so a nested same-token flash loan clears it on exit and the outer loan's repay() reverts with ThunderLoan__NotCurrentlyFlashLoaning

Root + Impact

Description

  • flashloan() should mark the token as flash-loaned for as long as a loan is open, so the receiver can repay with approve plus repay(token, amount + fee). The repo's own MockFlashLoanReceiver (L43-L44) repays this way. isCurrentlyFlashLoaning(token) should report whether a loan of that token is open.

  • The flag is a single bool per token. flashloan() sets it to true on entry (L198) and to false unconditionally on exit (L216), and it has no reentrancy guard. When a receiver starts a second flash loan of the same token inside executeOperation, the inner loan's exit clears the flag while the outer loan is still open, so the outer receiver's repay() reverts with ThunderLoan__NotCurrentlyFlashLoaning and the whole transaction reverts; ThunderLoanUpgraded has the same code (L99, L196, L214, L218-L220).

// src/protocol/ThunderLoan.sol L99, L180-L225 (L181-L197 elided)
// Same code in src/upgradedProtocol/ThunderLoanUpgraded.sol L99, L178-L223 (flag set L196, cleared L214, checked L218)
mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning; // @>
​
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
// ...
s_currentlyFlashLoaning[token] = true; // @>
assetToken.transferUnderlyingTo(receiverAddress, amount);
// slither-disable-next-line unused-return reentrancy-vulnerabilities-2
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
​
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false; // @>
}
​
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) { // @>
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • It requires a receiver that opens a second flash loan of the same token from the same ThunderLoan pool inside its own executeOperation, either directly or through a protocol it composes with. This is an unusual pattern, because one larger loan does the same job.

  • Only the borrower's own nested transaction is affected. No third party can push a nested loan into another user's loan.

  • It occurs only when the outer receiver repays through repay(). A receiver that repays with a direct token transfer to the AssetToken is not affected.

Impact:

  • The outer borrower's transaction reverts in full. The PoC shows that the pool, R1 and R2 balances are unchanged and the flag is reset afterwards, so no funds are lost or stuck.

  • The borrower can still close the outer loan with a raw token.transfer to the AssetToken, because flashloan() checks repayment only by balance (L212-L215). In the PoC this repays both loans and the pool grows by both fees (0.45e18).

  • While the outer loan is still open, isCurrentlyFlashLoaning(token) returns false for the rest of that call. Any integration that reads it gets the wrong state.

Proof of Concept

test/PoC_TL-11.t.sol deploys both ThunderLoan and ThunderLoanUpgraded behind an ERC1967Proxy with MockPoolFactory (price 1e18) and an LP deposit of 1000e18. Receiver R1 borrows 100e18 and, inside executeOperation, takes a 50e18 loan of the same token for receiver R2, which repays through approve + repay(). For each contract the tests show three things. A non-nested loan repaid through repay() succeeds and the pool grows by 0.3e18. In the nested flow, R1's repay() reverts with the exact ThunderLoan__NotCurrentlyFlashLoaning selector and all balances stay unchanged. Also in the nested flow, isCurrentlyFlashLoaning reads true before the inner loan and false after it, and R1 can still close its loan with a raw transfer to the AssetToken (pool grows by 0.45e18). Run it with forge test --match-path test/PoC_TL-11.t.sol -vv.

File test/PoC_TL-11.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-11.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
​
/// Surface shared by ThunderLoan and ThunderLoanUpgraded.
interface ITL11 {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function deposit(IERC20 token, uint256 amount) external;
function repay(IERC20 token, uint256 amount) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function isCurrentlyFlashLoaning(IERC20 token) external view returns (bool);
}
​
/// R2: an ordinary, honest receiver that repays through the official repay() API
/// (same pattern as test/mocks/MockFlashLoanReceiver.sol L43-44).
contract InnerReceiver {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
IERC20(token).approve(msg.sender, amount + fee);
ITL11(msg.sender).repay(IERC20(token), amount + fee);
return true;
}
}
​
/// R1: an honest receiver that, during its own loan, composes with another
/// same-token flash loan (taken for R2), then repays its own loan.
contract OuterReceiver {
enum Mode {
SINGLE_REPAY, // no nesting, repay via repay() (baseline)
NESTED_REPAY, // nested inner loan, repay via repay() (the bug)
NESTED_TRANSFER // nested inner loan, raw transfer (workaround)
}
​
Mode public mode;
address public inner;
uint256 public innerAmount;
​
bool public flagBeforeInner;
bool public flagAfterInner;
bool public flagAtOwnRepay;
​
function configure(Mode m, address _inner, uint256 _innerAmount) external {
mode = m;
inner = _inner;
innerAmount = _innerAmount;
}
​
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
ITL11 tl = ITL11(msg.sender);
IERC20 t = IERC20(token);
​
if (mode != Mode.SINGLE_REPAY) {
flagBeforeInner = tl.isCurrentlyFlashLoaning(t);
// Same-token nested flash loan (e.g. a composed protocol borrowing for R2).
tl.flashloan(inner, t, innerAmount, "");
flagAfterInner = tl.isCurrentlyFlashLoaning(t);
}
​
flagAtOwnRepay = tl.isCurrentlyFlashLoaning(t);
​
if (mode == Mode.NESTED_TRANSFER) {
// Workaround: bypass repay() and pay the AssetToken directly.
t.transfer(address(tl.getAssetFromToken(t)), amount + fee);
} else {
// Documented path: approve + IThunderLoan.repay
t.approve(msg.sender, amount + fee);
tl.repay(t, amount + fee);
}
return true;
}
}
​
contract PoC_TL11 is Test {
MockPoolFactory factory;
ERC20Mock tokenA;
address lp = makeAddr("lp");
​
uint256 constant LP_DEPOSIT = 1000e18;
uint256 constant OUTER_AMOUNT = 100e18;
uint256 constant INNER_AMOUNT = 50e18;
​
function setUp() public {
factory = new MockPoolFactory();
tokenA = new ERC20Mock();
factory.createPool(address(tokenA)); // MockTSwapPool price = 1e18
}
​
function _deployV1() internal returns (ITL11) {
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoan tl = ThunderLoan(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
_lpDeposit(ITL11(address(tl)));
return ITL11(address(tl));
}
​
function _deployUpgraded() internal returns (ITL11) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoanUpgraded tl = ThunderLoanUpgraded(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
_lpDeposit(ITL11(address(tl)));
return ITL11(address(tl));
}
​
function _lpDeposit(ITL11 tl) internal {
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(tl), LP_DEPOSIT);
tl.deposit(IERC20(address(tokenA)), LP_DEPOSIT);
vm.stopPrank();
}
​
function _fee(uint256 amount) internal pure returns (uint256) {
// price 1e18, fee 3e15 / 1e18 => 0.3%
return (amount * 3e15) / 1e18;
}
​
function _setup(OuterReceiver.Mode m) internal returns (OuterReceiver r1, InnerReceiver r2) {
r1 = new OuterReceiver();
r2 = new InnerReceiver();
r1.configure(m, address(r2), INNER_AMOUNT);
tokenA.mint(address(r1), _fee(OUTER_AMOUNT)); // R1 holds only its own fee
tokenA.mint(address(r2), _fee(INNER_AMOUNT)); // R2 holds only its own fee
}
​
// ---------------------------------------------------------------------
// Baseline: a single (non-nested) loan repaid through repay() works.
// ---------------------------------------------------------------------
function _baseline(ITL11 tl, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1,) = _setup(OuterReceiver.Mode.SINGLE_REPAY);
​
uint256 poolBefore = tokenA.balanceOf(address(at));
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
uint256 poolAfter = tokenA.balanceOf(address(at));
​
assertTrue(r1.flagAtOwnRepay(), "flag set while the single loan is open");
assertEq(poolAfter, poolBefore + _fee(OUTER_AMOUNT), "single loan repaid via repay()");
assertFalse(tl.isCurrentlyFlashLoaning(t), "flag cleared after loan");
console.log(tag, "baseline single loan via repay(): pool grew by", poolAfter - poolBefore);
}
​
// ---------------------------------------------------------------------
// Bug: nested same-token loan clears the flag; the outer repay() reverts
// with ThunderLoan__NotCurrentlyFlashLoaning and the whole tx reverts.
// ---------------------------------------------------------------------
function _nestedRepayReverts(ITL11 tl, bytes4 sel, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1, InnerReceiver r2) = _setup(OuterReceiver.Mode.NESTED_REPAY);
​
uint256 poolBefore = tokenA.balanceOf(address(at));
uint256 r1Before = tokenA.balanceOf(address(r1));
uint256 r2Before = tokenA.balanceOf(address(r2));
​
vm.expectRevert(sel);
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
​
// Whole transaction rolled back: no funds lost, nothing moved.
assertEq(tokenA.balanceOf(address(at)), poolBefore, "pool unchanged");
assertEq(tokenA.balanceOf(address(r1)), r1Before, "R1 unchanged");
assertEq(tokenA.balanceOf(address(r2)), r2Before, "R2 unchanged");
assertFalse(tl.isCurrentlyFlashLoaning(t), "flag reset by revert");
console.log(tag, "nested loan + outer repay(): reverted with ThunderLoan__NotCurrentlyFlashLoaning");
}
​
// ---------------------------------------------------------------------
// Observation + workaround: in the same nested scenario the flag reads
// false while the outer loan is still open; only a raw transfer to the
// AssetToken can close the outer loan.
// ---------------------------------------------------------------------
function _nestedTransferWorks(ITL11 tl, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1,) = _setup(OuterReceiver.Mode.NESTED_TRANSFER);
​
uint256 poolBefore = tokenA.balanceOf(address(at));
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
uint256 poolAfter = tokenA.balanceOf(address(at));
​
assertTrue(r1.flagBeforeInner(), "flag true at start of outer loan");
assertFalse(r1.flagAfterInner(), "BUG: inner loan exit cleared flag while outer loan still open");
assertFalse(r1.flagAtOwnRepay(), "BUG: flag false at the moment R1 must repay");
assertEq(poolAfter, poolBefore + _fee(OUTER_AMOUNT) + _fee(INNER_AMOUNT), "both loans paid with fees");
console.log(tag, "flag before inner loan:", r1.flagBeforeInner());
console.log(tag, "flag after inner loan (outer still open):", r1.flagAfterInner());
console.log(tag, "raw-transfer workaround: pool grew by", poolAfter - poolBefore);
}
​
// ----------------------------- ThunderLoan -----------------------------
function test_V1_Baseline_SingleLoanRepayWorks() public {
_baseline(_deployV1(), "[V1]");
}
​
function test_V1_NestedSameToken_OuterRepayReverts() public {
_nestedRepayReverts(_deployV1(), ThunderLoan.ThunderLoan__NotCurrentlyFlashLoaning.selector, "[V1]");
}
​
function test_V1_NestedSameToken_FlagClearedEarly_OnlyRawTransferWorks() public {
_nestedTransferWorks(_deployV1(), "[V1]");
}
​
// ------------------------- ThunderLoanUpgraded -------------------------
function test_Upgraded_Baseline_SingleLoanRepayWorks() public {
_baseline(_deployUpgraded(), "[Upg]");
}
​
function test_Upgraded_NestedSameToken_OuterRepayReverts() public {
_nestedRepayReverts(
_deployUpgraded(), ThunderLoanUpgraded.ThunderLoan__NotCurrentlyFlashLoaning.selector, "[Upg]"
);
}
​
function test_Upgraded_NestedSameToken_FlagClearedEarly_OnlyRawTransferWorks() public {
_nestedTransferWorks(_deployUpgraded(), "[Upg]");
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -99,1 +99,1 @@
- mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning;
+ mapping(IERC20 token => uint256 flashLoanDepth) private s_currentlyFlashLoaning;
@@ -196,3 +196,3 @@
emit FlashLoan(receiverAddress, token, amount, fee, params);
- s_currentlyFlashLoaning[token] = true;
+ s_currentlyFlashLoaning[token] += 1;
assetToken.transferUnderlyingTo(receiverAddress, amount);
@@ -213,9 +213,9 @@
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
- s_currentlyFlashLoaning[token] = false;
+ s_currentlyFlashLoaning[token] -= 1;
}
function repay(IERC20 token, uint256 amount) public {
- if (!s_currentlyFlashLoaning[token]) {
+ if (s_currentlyFlashLoaning[token] == 0) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
@@ -268,3 +268,3 @@
function isCurrentlyFlashLoaning(IERC20 token) public view returns (bool) {
- return s_currentlyFlashLoaning[token];
+ return s_currentlyFlashLoaning[token] > 0;
}
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -99,1 +99,1 @@
- mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning;
+ mapping(IERC20 token => uint256 flashLoanDepth) private s_currentlyFlashLoaning;
@@ -194,3 +194,3 @@
emit FlashLoan(receiverAddress, token, amount, fee, params);
- s_currentlyFlashLoaning[token] = true;
+ s_currentlyFlashLoaning[token] += 1;
assetToken.transferUnderlyingTo(receiverAddress, amount);
@@ -211,9 +211,9 @@
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
- s_currentlyFlashLoaning[token] = false;
+ s_currentlyFlashLoaning[token] -= 1;
}
function repay(IERC20 token, uint256 amount) public {
- if (!s_currentlyFlashLoaning[token]) {
+ if (s_currentlyFlashLoaning[token] == 0) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
@@ -266,3 +266,3 @@
function isCurrentlyFlashLoaning(IERC20 token) public view returns (bool) {
- return s_currentlyFlashLoaning[token];
+ return s_currentlyFlashLoaning[token] > 0;
}
Updates

Lead Judging Commences

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