DEV Community

ohmygod
ohmygod

Posted on

The Venus Protocol Donation Attack: How a Compound Fork's getCashPrior() Let an Attacker Bypass Supply Caps and Create $2.18M in Bad Debt

TL;DR

On March 15, 2026, an attacker exploited a donation flaw in Venus Protocol on BNB Chain — a vulnerability class endemic to Compound-forked lending protocols. By directly transferring THENA (THE) tokens to the vTHE contract instead of using the standard mint() function, the attacker bypassed supply cap checks, inflated the exchange rate by 3.81×, borrowed millions against phantom collateral value, and left Venus with $2.18M in bad debt. The irony? The attacker themselves lost ~$4.7M on-chain.

This article dissects the exact mechanics, shows the vulnerable code patterns, and provides concrete detection and prevention strategies.


The Setup: 9 Months of Patient Accumulation

This wasn't a smash-and-grab. The attacker:

  1. Funded operations via Tornado Cash — 7,447 ETH laundered through the mixer
  2. Deposited ETH into Aave to borrow stablecoins (clean leverage, no traces back)
  3. Accumulated THE tokens over 9 months — reaching ~84% of Venus's 14.5M supply cap
  4. Waited for the right market conditions

The patience is the tell. When someone spends 9 months accumulating 84% of a supply cap in a single lending market, it's not a trade — it's a siege.


The Vulnerability: getCashPrior() Reads Raw Balance

The root cause is deceptively simple. In Compound-forked protocols, the exchange rate between the underlying token and the cToken (or vToken) is calculated using the contract's actual token balance:

// Simplified from Compound's CToken.sol
function exchangeRateStoredInternal() internal view returns (uint) {
    uint _totalSupply = totalSupply;
    if (_totalSupply == 0) {
        return initialExchangeRateMantissa;
    }

    uint totalCash = getCashPrior();  // ← THE PROBLEM
    uint cashPlusBorrows = totalCash + totalBorrows - totalReserves;

    return cashPlusBorrows * expScale / _totalSupply;
}

function getCashPrior() internal view returns (uint) {
    // Reads the RAW token balance of the contract
    return IERC20(underlying).balanceOf(address(this));
}
Enter fullscreen mode Exit fullscreen mode

The critical insight: getCashPrior() reads balanceOf(address(this)) — the actual token balance held by the contract. But supply cap checks only trigger during mint() calls. If you transfer tokens directly to the contract (a "donation"), the balance increases without any supply cap validation.

The Bypass Mechanism

Normal deposit flow:

User → mint(amount) → supply cap check ✓ → transfer tokens → mint vTokens
Enter fullscreen mode Exit fullscreen mode

Attack flow:

User → transfer(vTHE, amount) → NO supply cap check → balance increases → exchange rate inflates
Enter fullscreen mode Exit fullscreen mode

The supply cap check lives in the mintInternal() function:

function mintInternal(uint mintAmount) internal {
    // Supply cap check happens HERE
    uint supplyCap = comptroller.supplyCaps(address(this));
    require(totalSupply + mintAmount <= supplyCap, "supply cap reached");

    // ... proceed with mint
}
Enter fullscreen mode Exit fullscreen mode

But a direct ERC-20 transfer() to the vTHE address never calls mintInternal(). The tokens just... arrive. And getCashPrior() counts them all the same.


The Attack: A Recursive Inflation Loop

With the vulnerability understood, here's how the attacker weaponized it:

Phase 1: Initial Position

  • Deposit THE via normal mint() → receive vTHE tokens
  • This establishes legitimate collateral position

Phase 2: Donation Loop

for each iteration:
    1. Transfer THE directly to vTHE contract (donation)
    2. Exchange rate increases (more cash, same totalSupply)
    3. Collateral value of existing vTHE increases
    4. Borrow more assets against inflated collateral
    5. Swap borrowed assets → buy more THE on open market
    6. Repeat
Enter fullscreen mode Exit fullscreen mode

Phase 3: Exchange Rate Explosion

Through recursive donations, the attacker:

  • Pushed THE supply in Venus to 53.23M (367% of the 14.5M cap)
  • Inflated the exchange rate by 3.81×
  • Drove THE spot price from $0.263 → $0.51 (organic market impact from buying pressure)

Phase 4: The Unwind

The attacker borrowed everything they could. But here's where it got interesting — their health factor approached 1.0, and selling pressure collapsed THE's price to $0.22. This triggered:

  • 8,048 liquidation transactions
  • 42M THE in collateral unwound
  • $2.18M in bad debt (liquidations couldn't cover everything at crashed prices)

The attacker actually lost ~$4.7M net. This was either a miscalculated attack or deliberately destructive.


The Deeper Problem: Donation Attacks Are a Known Class

This isn't new. The donation attack pattern has haunted Compound forks for years. The pattern is consistent: any protocol that derives value from raw balanceOf() reads is vulnerable to balance manipulation via direct transfers.


Detection: What Should Have Caught This

1. Supply Concentration Monitoring

An entity accumulating 84% of a supply cap over 9 months is a screaming red flag:

def check_concentration(market, threshold=0.5):
    supply_cap = market.supply_cap
    for holder in market.top_holders():
        if holder.balance / supply_cap > threshold:
            alert(f"CRITICAL: {holder.address} holds "
                  f"{holder.balance/supply_cap:.0%} of supply cap")
Enter fullscreen mode Exit fullscreen mode

2. Exchange Rate Deviation Alerts

A 3.81× exchange rate increase should trigger circuit breakers:

def monitor_exchange_rate(market, window_blocks=100, max_deviation=1.5):
    current_rate = market.exchange_rate()
    historical_rate = market.exchange_rate(block=-window_blocks)

    if current_rate / historical_rate > max_deviation:
        alert(f"Exchange rate deviation: {current_rate/historical_rate:.2f}x")
Enter fullscreen mode Exit fullscreen mode

3. Direct Transfer Detection

Monitor for large ERC-20 transfers to lending pool addresses that don't correspond to mint() calls:

def detect_donations(tx):
    transfers_to_pool = [e for e in tx.events 
                         if e.name == "Transfer" and e.to == VTOKEN_ADDRESS]
    mints = [e for e in tx.events if e.name == "Mint"]

    for transfer in transfers_to_pool:
        if not any(m.amount == transfer.amount for m in mints):
            alert(f"DONATION DETECTED: {transfer.amount} tokens "
                  f"transferred without mint()")
Enter fullscreen mode Exit fullscreen mode

Prevention: How to Fix This Systemically

Fix 1: Track Internal Accounting Separately

Never rely on balanceOf() for exchange rate calculations. Maintain an internal ledger:

uint256 internal _totalCashInternal;

function getCashPrior() internal view returns (uint) {
    return _totalCashInternal;
}

function mintInternal(uint mintAmount) internal {
    _totalCashInternal += mintAmount;
}

function redeemInternal(uint redeemAmount) internal {
    _totalCashInternal -= redeemAmount;
}
Enter fullscreen mode Exit fullscreen mode

Fix 2: Donation-Resistant Exchange Rate

function exchangeRateStoredInternal() internal view returns (uint) {
    uint rawBalance = IERC20(underlying).balanceOf(address(this));
    uint trackedBalance = _totalCashInternal;
    uint effectiveCash = min(rawBalance, trackedBalance);

    return (effectiveCash + totalBorrows - totalReserves) 
           * expScale / totalSupply;
}
Enter fullscreen mode Exit fullscreen mode

Fix 3: Supply Cap Invariant Check

modifier enforceSupplyCap() {
    _;
    uint actualSupply = IERC20(underlying).balanceOf(address(this)) 
                        + totalBorrows - totalReserves;
    require(actualSupply <= supplyCap, "supply cap violated");
}
Enter fullscreen mode Exit fullscreen mode

Fix 4: Circuit Breakers on Exchange Rate

uint256 constant MAX_EXCHANGE_RATE_CHANGE = 1.5e18;

function exchangeRateCurrent() public returns (uint) {
    uint newRate = exchangeRateStoredInternal();
    uint lastRate = _lastExchangeRate;

    if (lastRate > 0) {
        require(
            newRate <= lastRate * MAX_EXCHANGE_RATE_CHANGE / 1e18,
            "exchange rate change too large"
        );
    }

    _lastExchangeRate = newRate;
    return newRate;
}
Enter fullscreen mode Exit fullscreen mode

Audit Checklist: Are You Vulnerable?

If you're running or auditing a Compound-forked protocol, check these:

  • [ ] Does getCashPrior() use balanceOf()? → You're likely vulnerable
  • [ ] Are supply caps checked only during mint()? → Direct transfers bypass them
  • [ ] Do you have exchange rate deviation monitoring? → Critical for early detection
  • [ ] Are there circuit breakers on exchange rate changes? → Prevents runaway inflation
  • [ ] Do you track internal balances separately from raw balanceOf()? → The real fix
  • [ ] Do you monitor for large direct transfers to pool contracts? → Detection layer
  • [ ] Are there concentration limits per depositor? → Prevents 84% accumulation scenarios

Lessons for the Ecosystem

  1. balanceOf() is not your accounting system. Raw token balances can be manipulated by anyone with tokens. Internal state should be the source of truth.

  2. Supply caps are access control, not invariants. If your supply cap only triggers on one code path, it's a suggestion, not a limit.

  3. Time-in-position is a risk signal. The 9-month accumulation period was a gift to defenders who weren't watching.

  4. Compound forks inherit Compound's assumptions. The original Compound was designed for a different era. Every fork should re-evaluate whether getCashPrior() returning raw balance is still appropriate.

  5. The attacker doesn't always win. Losing $4.7M while causing $2.18M in protocol bad debt suggests either miscalculation or griefing motivation.


References


DreamWork Security publishes weekly DeFi vulnerability analyses. Follow for deep dives into the bugs that cost millions.

Top comments (0)