Thunder Loan

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

Division by zero in `updateExchangeRate()` when AssetToken `totalSupply` is 0

Description

AssetToken.updateExchangeRate() computes the new exchange rate by dividing by totalSupply():

// src/protocol/AssetToken.sol:89
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
// ^^^^^^^^^^^^
// Division by zero when no LPs

When totalSupply() == 0 (no LPs have deposited, or all LPs have redeemed), this triggers Panic(0x12) — an EVM arithmetic division-by-zero panic.

flashloan() calls updateExchangeRate(fee) at line 194 before checking anything about LP supply:

// src/protocol/ThunderLoan.sol:192-194
uint256 fee = getCalculatedFee(token, amount);
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
assetToken.updateExchangeRate(fee); // @> panics if totalSupply == 0

This is distinct from the existing L-04 finding (which covers the fee == 0 case causing newRate == oldRate and reverting on the strict <= monotonicity check). This finding covers a separate code path — a raw EVM arithmetic panic from division by zero, triggered by an empty LP pool regardless of the fee amount.

There are two realistic scenarios where totalSupply() is zero:

  1. Fresh token setup: Owner calls setAllowedToken(token, true) creating a new AssetToken. Before any LP deposits, someone attempts a flash loan (the AssetToken may hold tokens from a direct transfer or leftover from re-allowing a previously disallowed token).

  2. LP exodus: All LPs call redeem(), burning all shares. totalSupply() returns to 0. The AssetToken may still hold residual dust from rounding. Any flash loan attempt now panics.

Risk

Likelihood: Low — requires a token to be allowed but have zero outstanding AssetToken supply. Uncommon in normal operation but reachable during protocol initialization or LP migration events.

Impact: High — any flashloan() call reverts with an unhelpful Panic(0x12) instead of a descriptive error. The token's entire flash loan functionality is bricked until someone deposits. If tokens are stuck in the AssetToken (from rounding dust or direct transfers), they become inaccessible.

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 MockTokenM3 is ERC20 {
constructor() ERC20("MockToken", "MTK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockPoolFactoryM3 {
mapping(address => address) private s_pools;
function createPool(address token) external returns (address) {
MockTSwapPoolM3 pool = new MockTSwapPoolM3();
s_pools[token] = address(pool);
return address(pool);
}
function getPool(address token) external view returns (address) { return s_pools[token]; }
}
contract MockTSwapPoolM3 {
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) { return 1e18; }
}
contract SimpleReceiverM3 {
ThunderLoan public tl;
constructor(address _tl) { tl = ThunderLoan(_tl); }
function executeOperation(
address token, uint256 amount, uint256 fee, address, bytes calldata
) external returns (bool) {
IERC20(token).approve(address(tl), amount + fee);
tl.repay(IERC20(token), amount + fee);
return true;
}
}
contract Exploit_M03 is Test {
ThunderLoan thunderLoan;
MockTokenM3 tokenA;
AssetToken assetToken;
function setUp() public {
tokenA = new MockTokenM3();
MockPoolFactoryM3 pf = new MockPoolFactoryM3();
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)));
}
function testExploit_DivisionByZero() public {
// Directly fund the AssetToken (simulating leftover from previous cycle)
tokenA.mint(address(assetToken), 100e18);
// AssetToken has 100e18 underlying but totalSupply == 0 (no LPs)
assertEq(assetToken.totalSupply(), 0);
assertEq(tokenA.balanceOf(address(assetToken)), 100e18);
SimpleReceiverM3 receiver = new SimpleReceiverM3(address(thunderLoan));
tokenA.mint(address(receiver), 10e18); // extra for fee
// Flash loan attempt panics with Panic(0x12) — division by zero
vm.expectRevert(); // Panic(0x12)
thunderLoan.flashloan(address(receiver), IERC20(address(tokenA)), 50e18, "");
console.log("PROVEN: flashloan reverts with Panic when totalSupply == 0");
}
}

Output: flashloan() reverts with Panic(0x12) (division by zero) when assetToken.totalSupply() == 0. The 100e18 tokens in the AssetToken are inaccessible via flash loan.

Recommended Mitigation

Guard updateExchangeRate() against zero supply. When no LPs exist, the fee has no one to distribute to:

function updateExchangeRate(uint256 fee) external onlyThunderLoan {
+ // No LPs to distribute fee to — skip rate update
+ if (totalSupply() == 0) {
+ return;
+ }
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
emit ExchangeRateUpdated(s_exchangeRate);
}
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!