DEV Community

ohmygod
ohmygod

Posted on

The Share Inflation Kill Chain: How Three Lines of Missing Code Keep Draining DeFi Lending Protocols

On March 17, 2026, an attacker deposited 772 USDC into dTRINITY's dLEND pool on Ethereum. The protocol's accounting system interpreted this as $4.8 million in collateral. The attacker borrowed $257,000 in dUSD, looped 127 deposit-withdraw cycles to drain the remaining liquidity, and walked away before anyone noticed.

This wasn't novel. It wasn't clever. It was the exact same bug class that hit Cream Finance ($130M), Hundred Finance ($7.4M), and a dozen other protocols. Share inflation attacks keep happening because teams keep shipping the same three missing lines of code.

This article breaks down the kill chain, explains why the standard fixes fail, and provides a definitive prevention pattern you can deploy today.


The Kill Chain: How Share Inflation Attacks Work

Every modern lending protocol uses share-based accounting. When you deposit 1,000 USDC, you receive shares proportional to the pool's exchange rate:

shares = depositAmount × totalShares / totalAssets
Enter fullscreen mode Exit fullscreen mode

The vulnerability appears at pool initialization, when totalShares and totalAssets are both zero (or near-zero).

Step 1: Seed the Pool

The attacker deposits a tiny amount — say 1 wei of USDC — receiving 1 share.

Step 2: Donate to Inflate

The attacker transfers a large amount of USDC directly to the pool contract (bypassing the deposit() function). Now:

totalAssets = 1_000_000e6  (donated USDC)
totalShares = 1            (attacker's single share)
exchangeRate = 1_000_000e6 per share
Enter fullscreen mode Exit fullscreen mode

Step 3: Front-Run the Victim

When the next depositor puts in 500,000 USDC:

shares = 500_000e6 × 1 / 1_000_000e6 = 0  (rounds down!)
Enter fullscreen mode Exit fullscreen mode

The victim receives zero shares for half a million dollars. Their deposit is absorbed into the attacker's single share.

Step 4: Withdraw

The attacker redeems their 1 share for the entire pool balance: their original donation + the victim's deposit.

The dTRINITY Variant

dTRINITY's exploit was a twist on this pattern. Instead of direct donation, the attacker exploited a stale liquidity index — the protocol didn't update its interest accumulator before processing deposits. This created a temporal mismatch where deposits were valued at a stale (lower) index while borrows used an updated (higher) index, producing phantom collateral from thin air.

The 127-loop extraction amplified rounding errors across each cycle, turning micro-cent rounding dust into $257,000 of real USDC.


Why the "Standard" Fixes Keep Failing

Fix Attempt #1: Minimum Deposit Requirements

require(assets >= MIN_DEPOSIT, "Below minimum");
Enter fullscreen mode Exit fullscreen mode

Why it fails: Attackers can meet the minimum and still inflate shares through direct transfers. The minimum protects against dust attacks but not economic manipulation.

Fix Attempt #2: First Depositor Lockup

Some protocols lock the first depositor's shares permanently.

Why it fails: Only prevents the simplest variant. Doesn't protect against index manipulation (dTRINITY), cross-function accounting mismatches, or donation attacks after the first deposit.

Fix Attempt #3: "Just Update the Index"

function deposit(uint256 assets) external {
    _updateIndex(); // "Fixed!"
    // ... rest of deposit logic
}
Enter fullscreen mode Exit fullscreen mode

Why it still fails: If _updateIndex() is called in deposit() but not in borrow(), or vice versa, the temporal mismatch persists. The fix must be universal and atomic — every state-changing function must operate on the same index snapshot.


The Definitive Fix: Virtual Shares + Invariant Enforcement

The only pattern that comprehensively prevents share inflation attacks combines three mechanisms. Here are the three lines of code that keep getting omitted:

Line 1: Virtual Shares (ERC-4626 §4)

uint256 private constant VIRTUAL_SHARES = 1e6;
uint256 private constant VIRTUAL_ASSETS = 1;

function _convertToShares(uint256 assets) internal view returns (uint256) {
    return assets.mulDiv(
        totalSupply() + VIRTUAL_SHARES,
        totalAssets() + VIRTUAL_ASSETS,
        Math.Rounding.Floor
    );
}

function _convertToAssets(uint256 shares) internal view returns (uint256) {
    return shares.mulDiv(
        totalAssets() + VIRTUAL_ASSETS,
        totalSupply() + VIRTUAL_SHARES,
        Math.Rounding.Floor
    );
}
Enter fullscreen mode Exit fullscreen mode

Virtual shares create a permanent "phantom depositor" in the pool. Even when totalShares = 0 and totalAssets = 0, the exchange rate calculation uses VIRTUAL_SHARES and VIRTUAL_ASSETS as a floor, making inflation economically impractical.

The math: To inflate the exchange rate by 10x with VIRTUAL_SHARES = 1e6, an attacker would need to donate 10 × 1e6 = 10M units of the underlying asset — making the attack cost more than any possible profit.

Line 2: Atomic Index Updates

modifier syncIndex() {
    _updateLiquidityIndex();
    _;
    _checkInvariant();
}

function deposit(uint256 assets) external syncIndex { ... }
function withdraw(uint256 shares) external syncIndex { ... }
function borrow(uint256 assets) external syncIndex { ... }
function repay(uint256 assets) external syncIndex { ... }
Enter fullscreen mode Exit fullscreen mode

Every state-changing function must update the index before execution. No exceptions. The modifier pattern ensures consistency can't be accidentally broken when adding new functions.

Line 3: Post-Operation Invariant Check

function _checkInvariant() internal view {
    uint256 realAssets = underlying.balanceOf(address(this));
    uint256 accountedAssets = _convertToAssets(totalSupply());

    // Allow 0.01% tolerance for rounding
    uint256 tolerance = realAssets / 10_000;
    require(
        realAssets >= accountedAssets - tolerance &&
        realAssets <= accountedAssets + tolerance,
        "INVARIANT_BROKEN"
    );
}
Enter fullscreen mode Exit fullscreen mode

This is the circuit breaker. If real assets and accounted assets diverge beyond tolerance, the transaction reverts. This catches every variant of share inflation — donation, index manipulation, rounding amplification — because they all produce the same symptom: a mismatch between what the pool holds and what it thinks it holds.


Testing the Fix: Foundry Invariant Suite

Prevention code is useless if it's not tested against adversarial conditions. Here's a production-ready Foundry invariant test suite:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {LendingPool} from "../src/LendingPool.sol";
import {MockERC20} from "./mocks/MockERC20.sol";

contract LendingPoolHandler is Test {
    LendingPool public pool;
    MockERC20 public token;

    uint256 public ghost_totalDeposited;
    uint256 public ghost_totalWithdrawn;

    constructor(LendingPool _pool, MockERC20 _token) {
        pool = _pool;
        token = _token;
    }

    function deposit(uint256 amount) external {
        amount = bound(amount, 1, 1e24);
        token.mint(address(this), amount);
        token.approve(address(pool), amount);
        pool.deposit(amount);
        ghost_totalDeposited += amount;
    }

    function withdraw(uint256 shares) external {
        uint256 maxShares = pool.balanceOf(address(this));
        if (maxShares == 0) return;
        shares = bound(shares, 1, maxShares);
        uint256 assets = pool.withdraw(shares);
        ghost_totalWithdrawn += assets;
    }

    // Simulate donation attack
    function donate(uint256 amount) external {
        amount = bound(amount, 1, 1e24);
        token.mint(address(this), amount);
        token.transfer(address(pool), amount);
    }
}

contract LendingPoolInvariantTest is StdInvariant, Test {
    LendingPool pool;
    MockERC20 token;
    LendingPoolHandler handler;

    function setUp() public {
        token = new MockERC20("USDC", "USDC", 6);
        pool = new LendingPool(address(token));
        handler = new LendingPoolHandler(pool, token);

        targetContract(address(handler));
    }

    function invariant_realAssetsMatchAccounting() public view {
        uint256 realBalance = token.balanceOf(address(pool));
        uint256 totalShares = pool.totalSupply();

        if (totalShares == 0) return;

        uint256 accountedAssets = pool.convertToAssets(totalShares);
        uint256 tolerance = realBalance / 10_000 + 1;

        assertGe(realBalance + tolerance, accountedAssets,
            "Real assets below accounted assets");
    }

    function invariant_noShareInflation() public view {
        // Exchange rate should never exceed a reasonable bound
        uint256 totalShares = pool.totalSupply();
        if (totalShares == 0) return;

        uint256 rate = pool.convertToAssets(1e18);
        assertLe(rate, 1e24, "Exchange rate suspiciously high");
    }

    function invariant_withdrawNeverExceedsDeposit() public view {
        assertLe(
            handler.ghost_totalWithdrawn(),
            handler.ghost_totalDeposited() + 
                token.balanceOf(address(pool)),
            "Withdrawn more than deposited + donations"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Run with high depth to catch multi-step exploits:

forge test --match-contract LendingPoolInvariantTest \
  -vvv \
  --invariant-runs 512 \
  --invariant-depth 128
Enter fullscreen mode Exit fullscreen mode

The depth=128 setting mirrors dTRINITY's 127-loop attack. If your invariant tests pass at this depth, you've validated against the exact extraction pattern that drained $257K.


The Audit Checklist: 9 Questions Every Lending Protocol Must Answer

Before deploying any share-based lending protocol, verify:

Share Accounting

  1. Are virtual shares implemented? Without them, the first-depositor inflation vector is always open.
  2. Does convertToShares() round down for deposits? Rounding up gives depositors unearned shares.
  3. Does convertToAssets() round down for withdrawals? Rounding up lets withdrawers extract more than their share.

Index Synchronization

  1. Is the liquidity/interest index updated before EVERY state change? Check every function: deposit, withdraw, borrow, repay, liquidate, flashloan callbacks.
  2. Are all functions using the SAME index snapshot within a transaction? A stale index in one function + fresh index in another = dTRINITY.
  3. Are index updates atomic with the operations they support? No external calls between index update and state change.

Invariant Enforcement

  1. Is there a post-operation balance check? realAssets ≈ accountedAssets after every operation.
  2. Is there a circuit breaker for abnormal deviations? Auto-pause when the gap exceeds tolerance.
  3. Are donation attacks handled? Direct transfers to the pool contract must not break the exchange rate.

The Hall of Shame: Share Inflation's $150M+ Track Record

Protocol Date Loss Root Cause
Cream Finance Oct 2021 $130M Flash loan + share inflation
Hundred Finance Apr 2023 $7.4M Empty market manipulation
Euler Finance Mar 2023 $197M Accounting mismatch (partial)
Wise Lending Jan 2024 $460K Donation attack
Sonne Finance May 2024 $20M Fork market manipulation
dTRINITY Mar 2026 $257K Index sync + loop extraction

Every single one of these would have been prevented by virtual shares + invariant enforcement.


Conclusion: Three Lines, Zero Excuses

The share inflation attack class is completely solved. OpenZeppelin's ERC-4626 implementation includes virtual shares by default since v5.0. Foundry's invariant testing framework can validate accounting consistency in minutes. The dTRINITY team has pledged to cover losses from internal funds — but the next team might not be so well-capitalized.

If you're building or auditing a lending protocol in 2026:

  1. Use virtual shares. Copy OpenZeppelin's implementation verbatim.
  2. Sync your index atomically. Use a modifier, not manual calls.
  3. Check your invariants on-chain. If realAssets ≠ accountedAssets, revert.

Three lines of code. $150 million in preventable losses. The pattern is known. The fix is known. Ship it.


This article is part of the DeFi Security Research series. Follow @ohmygod for weekly deep-dives into blockchain security vulnerabilities and defense patterns.

Top comments (0)