ThunderLoan::deposit raises the exchange rate by a fee that was never earned, so claims exceed assets before a single flash loan is takenThe exchange rate is the protocol's promise to liquidity providers: amountUnderlying = shares * exchangeRate / 1e18. It exists to distribute flash-loan fees — the README says AssetTokens "gain interest over time depending on how often people take out flash loans", and AssetToken::updateExchangeRate is documented as "it should always go up, never down". It may only rise when new underlying actually arrives as revenue.
deposit calls it with the fee that would be charged if the deposited amount were borrowed. No such fee is ever collected. The depositor hands over exactly amount, but the per-share claim of every holder is raised as though amount + fee had arrived.
The ordering matters as much as the call itself: mint happens before the rate is raised, so the depositor buys shares at the old rate and is immediately credited with the increase. Every deposit therefore both dilutes the backing and hands the depositor an unearned gain — which is exactly the mechanism by which one liquidity provider ends up taking another's money.
redeem pays out the inflated book value with no solvency check whatsoever:
so the shortfall is not shared proportionally. It lands entirely on whoever withdraws last.
On the fact that ThunderLoanUpgraded::deposit does not contain these two lines (src/upgradedProtocol/ThunderLoanUpgraded.sol:146-154): that is evidence the lines are unintended, not evidence the issue is already handled. ThunderLoan is the implementation that is live and holding funds, both files are in scope, and the upgrade that would remove these lines is itself broken by the storage-layout collision I am reporting separately — so "just upgrade" is not an available remediation.
Likelihood:
Every deposit triggers it. No attacker, no race, no configuration — the protocol becomes insolvent through its intended, documented happy path, starting with the first liquidity provider.
Nothing surfaces it. deposit succeeds, the share balance looks correct, and the problem only appears on the way out.
The project's own suite passes 9/9 and contains no test for redeem at all. test/fuzz/Invariant.t.sol names the exact property this breaks — "2. The protocol should never lose liquidity provider deposits, it should always go up" — but the file is an empty contract Invariant { } with the invariants left as comments, so it was never enforced.
Impact:
The protocol is insolvent from the first deposit. One LP deposits 1,000 with zero flash loans ever taken: the AssetToken holds exactly 1,000 and the protocol believes it owes 1,003.
Liquidity providers steal from each other, decided purely by exit order. With two 1,000-token depositors, the one who exits first collects 1,004.51 — 4.51 more than they put in — and the second is left with 995.49 against their 1,000 principal. The 4.51 comes directly out of the second LP's deposit.
The second LP cannot execute a full withdrawal at all. Their book claim is 1,001.50 against 995.49 actually present, so redeem(theirEntireShareBalance) reverts; they can only recover by computing a reduced amount, and the remainder of their shares is permanently unbacked.
redeem(type(uint256).max) — the only "withdraw everything" path the contract offers — always reverts, for every LP, in every configuration, because it resolves to the caller's full share balance.
The shortfall grows by getCalculatedFee(token, amount) on every deposit, so it scales with cumulative deposit volume rather than being a one-off rounding artifact.
Scope note, stated precisely so it is not overclaimed: a sole liquidity provider is not locked out of their principal. Their full-balance redeem reverts, but a reduced redeem returns 999.999999999999999999 of their 1,000 and strands 2.99 shares' worth of unbacked claim. The real loss requires a second liquidity provider — which is the normal case for a pool.
Two scenarios: what a sole LP actually experiences, and where the money really goes once there is more than one.
Save as test/PoC_H02.t.sol and run forge test --mt test_H02 -vv:
Output:
No flash loan is taken in either scenario. The 3-token gap in the first is exactly getCalculatedFee(token, 1000e18) — a fee on a loan that never happened — and in the second it is 4.51 tokens moved from one honest liquidity provider to another.
A deposit is not revenue. Remove both lines; the exchange rate should move only where a fee is actually collected.
This is exactly the shape of ThunderLoanUpgraded::deposit, so it is a known-good form for this codebase.
Because the rate is already inflated on any deployed instance, and AssetToken is created with new (:234) rather than behind a proxy, s_exchangeRate cannot be corrected after the fact by any existing code path — an onlyThunderLoan corrective setter, or deploying AssetToken upgradeably, is needed for remediation on a live pool.
Independently of the fix, redeem should refuse to pay out more than the AssetToken holds, so any future accounting error degrades into a clear failure instead of a first-come-first-served race:
And implement the invariant the repository already names: totalSupply() * getExchangeRate() / 1e18 <= underlying.balanceOf(assetToken) fails on the very first deposit.
# 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.