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:
- Funded operations via Tornado Cash — 7,447 ETH laundered through the mixer
- Deposited ETH into Aave to borrow stablecoins (clean leverage, no traces back)
- Accumulated THE tokens over 9 months — reaching ~84% of Venus's 14.5M supply cap
- 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));
}
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
Attack flow:
User → transfer(vTHE, amount) → NO supply cap check → balance increases → exchange rate inflates
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
}
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
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")
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")
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()")
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;
}
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;
}
Fix 3: Supply Cap Invariant Check
modifier enforceSupplyCap() {
_;
uint actualSupply = IERC20(underlying).balanceOf(address(this))
+ totalBorrows - totalReserves;
require(actualSupply <= supplyCap, "supply cap violated");
}
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;
}
Audit Checklist: Are You Vulnerable?
If you're running or auditing a Compound-forked protocol, check these:
- [ ] Does
getCashPrior()usebalanceOf()? → 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
balanceOf()is not your accounting system. Raw token balances can be manipulated by anyone with tokens. Internal state should be the source of truth.Supply caps are access control, not invariants. If your supply cap only triggers on one code path, it's a suggestion, not a limit.
Time-in-position is a risk signal. The 9-month accumulation period was a gift to defenders who weren't watching.
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.The attacker doesn't always win. Losing $4.7M while causing $2.18M in protocol bad debt suggests either miscalculation or griefing motivation.
References
- Halborn: Explained — The Venus Protocol Hack
- BlockSec: Venus THENA Donation Attack Analysis
- Quill Audits: Venus $5M Exploit Analysis
- Compound Finance: CToken.sol
DreamWork Security publishes weekly DeFi vulnerability analyses. Follow for deep dives into the bugs that cost millions.
Top comments (0)