LP claims on an AssetToken are totalSupply * exchangeRate, and the exchange rate is written by adding the
fee as if fee whole asset-supply units appeared in the vault, instead of as real backing.
getCalcPriceInWeth reads the pool's live reserve ratio:
After any fee, claims move by rate * fee / 1e18 but backing only grows by fee. The two match only while
rate == 1e18; every fee nudges the rate above 1e18, so the divergence compounds on subsequent fees.
The v2 flashloan path (below) has the same rate step on the collected fee:
updateExchangeRate(fee) credits the fee to LP claims using (supply + fee)/supply — i.e. fee behaves
like newly minted shares — while the underlying vault only ever receives fee tokens. When rate != 1e18
(after the very first fee), this credits rate*fee/1e18 of claims against fee of backing, minting unbacked
(phantom) value. Three amplifying defects compound it:
Unit mismatch in getCalculatedFee: the fee is 0.3% of the token's weth value but is applied as if
it were token units (never divided back by the price). On a pool where 1 tokenA == 1000 weth, a
nominal 0.3% fee becomes a 300% fee.
Uncollected fee on deposit (v1): deposit mints shares at the old rate, then inflates the rate by a fee
nobody pays (the deposit only transfers principal), so the depositor redeems at the inflated rate.
Manipulable spot oracle: getPriceInWeth is a raw pool reserve ratio with no TWAP; a same-tx swap pumps
the fee arbitrarily, scaling the phantom claims without bound.
Likelihood: High. The v1 deposit path needs no oracle game at all on pools where the fee's weth-to-token
units diverge (a 1 token = 1000 weth pool turns an honest first deposit into a 4x rate jump). Oracle
manipulation is one same-tx swap away and requires no privileged actor. The v2 drift requires only repeated
honest, fully-repaid flash loans.
Impact:
Critical (v1): attacker pumps the pool price ~1370x, deposits the just-bought tokens, then redeems at
the exploded rate -> the entire AssetToken vault is drained (LP principal stolen, account left with dust).
High (v2): repeated honest flash-loan fees produce claims >> backing (in the PoC, 7.89e42 claims vs
7.6e24 backing after 50 loans) -> unavoidable insolvency; late redeemers revert / get nothing.
Independent confirmation: stateful invariant fuzzing (ThunderLoanInvariants) breaks the
lpClaimsNeverExceedBacking invariant within 128k calls.
test/poc/PocHonestDepositInsolvency.t.sol — no manipulation needed: on a 1 token = 1000 weth pool, an
honest first deposit already produces a 4x rate jump and an instantly-insolvent vault (redeem of the resulting
40k claims reverts against a 10k vault):
test/poc/PocV1DepositSteal.t.sol (v1 -> full vault drain):
Run:
forge test --match-contract PocHonestDepositInsolvency -vv (PASS — no-manipulation variant)
forge test --match-contract PocV1DepositSteal -vv (PASS; logs: attackerDeposited 194,594, attackerRedeemedOut 294,594, backingAfter 7,324 wei)
forge test --match-contract PocFeeAccountingDrift -vv (PASS; logs: claimsAfter50Loans 7.89e42, backingAfter50Loans 7.6e24)
In updateExchangeRate, convert the fee to vault-share terms so claims growth equals backing growth at any
rate: newExchangeRate = s_exchangeRate * (totalSupply() + fee * 1e18 / s_exchangeRate) / totalSupply(); or
track accrued fees in a separate reserve and add them to the vault before computing the rate.
In getCalculatedFee, convert the weth-value fee back into underlying-token units (divide by the token price)
so a 0.3% fee is actually 0.3% of the amount, regardless of pool price.
In v1 deposit, remove the uncollected updateExchangeRate(calculatedFee) call (v2 already did).
For the oracle: use a TWAP/EMA or bounded price feed so the spread of getCalculatedFee is capped.
# 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.