deposit() charges a flash-loan fee to the exchange rate even though the depositor never pays it.
updateExchangeRate is the mechanism that distributes flash-loan fees to liquidity providers: it raises the underlying-per-share rate by fee / totalSupply. The fee is supposed to enter the vault as tokens when a borrower repays - that is why flashloan() raises the rate only together with the repayment check.
deposit() calls it with a fee that nobody pays. The only tokens that enter the vault are amount; the fee is computed from getCalculatedFee() and written into the rate without any transfer. Each deposit therefore increases the value of every share without increasing the assets backing it.
The vault becomes insolvent by construction:
Share value rises on every deposit, while the vault balance rises only by the deposited amount.
The last depositor to redeem cannot withdraw the full value of their shares, because the contract holds fewer tokens than the shares claim.
The shortfall grows with the number of deposits and with the size of the flash-loan fee, and there is no mechanism to inject the missing tokens.
Likelihood: High. Every call to deposit() triggers it. No attacker, no special ordering and no privileged role are required - two ordinary deposits are enough.
Impact: High. The vault ends up owing more than it holds. The final depositor to exit is unable to withdraw and their funds stay locked in the contract, so the loss is a direct loss of user deposits.
Two liquidity providers deposit 100 tokens each. The rate rises twice, the vault holds 200, but the shares are worth 200.6. The first provider exits with their full claim and the second one can no longer withdraw.
Logged values: rate 1.003e18 -> 1.004506733699945082e18, vault 200e18, share value 200.6009e18, vault after the first exit 99.549e18 against 100.45e18 of remaining shares.
A deposit must not accrue a fee. Remove the exchange-rate update from deposit() and let only flashloan() raise the rate, together with the repayment that actually funds it.
# 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.