🌳 Advanced ⏱️ 55 min

Lending Protocol Security

Lending protocols — Compound, Aave, MakerDAO — are the backbone of DeFi's credit system. They hold more value than almost any other smart contract category, and attackers know it. The mechanics are deceptively simple: deposit collateral, borrow against it, repay with interest. But the security implications of getting any step wrong are catastrophic. CREAM Finance lost $130M in a single transaction. Venus Protocol accumulated $200M in bad debt from one large position. This lesson explains exactly how these attacks work and how to prevent them.

📖 Lending Protocol Architecture

A lending protocol at its core is a smart contract that tracks: (1) how much collateral each user has deposited, (2) how much they have borrowed, (3) the current interest owed. The protocol stays solvent by ensuring collateral value always exceeds debt value by a required margin. When that margin is breached, anyone can liquidate the position for a profit.

Core Mechanics: Health Factor and Liquidation

🔍 Health Factor Calculation
// Example: Aave-style health factor // User deposits 1 ETH ($2000) as collateral // Collateral factor (LTV) = 80% → max borrow = $1600 // Liquidation threshold = 85% → liquidation if debt > $1700 // User borrows $1500 USDC function getHealthFactor(address user) public view returns (uint256) { uint256 collateralValueUSD = getCollateralValue(user); // $2000 uint256 debtValueUSD = getDebtValue(user); // $1500 uint256 liquidationThreshold = 8500; // 85% // Health factor = (collateral * liquidationThreshold) / debt // = (2000 * 8500) / (1500 * 10000) = 17000000 / 15000000 = 1.133 // Health factor > 1.0 → safe // Health factor < 1.0 → can be liquidated return (collateralValueUSD * liquidationThreshold) / (debtValueUSD * 10000); } // ETH drops to $1700: // Health factor = (1700 * 8500) / (1500 * 10000) = 0.9633 → LIQUIDATABLE

Interest Rate Models and Accrual Bugs

🔍 Jump Rate Interest Model
// Compound-style jump rate model // Utilization = totalBorrows / totalSupply // Below kink (80%): low interest rate // Above kink (80%): rate jumps dramatically function getBorrowRate(uint256 cash, uint256 borrows, uint256 reserves) public view returns (uint256) { uint256 util = utilizationRate(cash, borrows, reserves); if (util <= kink) { // Linear: base + util * multiplier return baseRate + (util * multiplierPerBlock / 1e18); } else { // Jump: rate at kink + excess * jumpMultiplier uint256 normalRate = baseRate + (kink * multiplierPerBlock / 1e18); uint256 excessUtil = util - kink; return normalRate + (excessUtil * jumpMultiplierPerBlock / 1e18); } } // ❌ INTEREST ACCRUAL BUG: not calling accrueInterest() before operations function buggyBorrow(uint256 amount) external { // Missing: accrueInterest() // Health factor calculated on stale debt value require(getHealthFactor(msg.sender) > 1e18); // User borrows more than they should be able to } // ✅ CORRECT: Always accrue interest before any state-dependent operation function safeBorrow(uint256 amount) external { accrueInterest(); // Update borrowIndex and totalBorrows first require(getHealthFactor(msg.sender) > 1e18); _doBorrow(msg.sender, amount); }
⚠️ Rounding Errors in Interest Accrual

Interest is accrued per block and stored as an index. When borrowers' debt is calculated as principal * currentIndex / initialIndex, rounding down consistently can lead to the protocol losing small amounts over time. More critically, rounding in the wrong direction can allow borrowers to repay slightly less than owed, creating bad debt at scale.

Flash Loan + Oracle Manipulation Attack

The CREAM Finance $130M exploit used a flash loan to manipulate the price of yUSD (a Yearn vault token) on the oracle CREAM was using. The attacker borrowed enough yUSD tokens to inflate the oracle price, then used that inflated collateral to borrow far more than was actually backed.

🔍 Flash Loan + Oracle Attack Pattern
// Simplified version of the CREAM Finance attack pattern function attack() external { // Step 1: Flash borrow 500M USDC flashLender.flashLoan(address(this), address(USDC), 500_000_000e6, ""); } function onFlashLoan(...) external { // Step 2: Deposit 500M USDC into yUSD vault → receive yUSD tokens uint256 yUSDAmount = yUSDVault.deposit(500_000_000e6); // Step 3: yUSD tokens are now the attacker's "collateral" in CREAM // CREAM reads yUSD price from the yUSD vault's pricePerShare() // Which is temporarily inflated because of the huge deposit CREAM.supply(yUSDAddress, yUSDAmount); // Step 4: Borrow maximum against inflated yUSD collateral // Borrow ETH, WBTC, and other assets worth $130M CREAM.borrow(ETH, maxBorrowable); CREAM.borrow(WBTC, maxBorrowable); // Step 5: Repay flash loan with small portion of stolen funds USDC.approve(flashLender, 500_000_000e6 + fee); // Keep the $130M profit } // Root cause: Using a manipulable internal rate as a price oracle

Reentrancy in Deposit/Withdraw Flow

🔍 Lending Protocol Reentrancy
// ❌ VULNERABLE: State updated after external call function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient balance"); // External call BEFORE state update (bool success,) = msg.sender.call{value: amount}(""); require(success); balances[msg.sender] -= amount; // Updated AFTER — too late! } // ✅ SAFE: Checks-Effects-Interactions pattern function safeWithdraw(uint256 amount) external nonReentrant { // 1. Checks require(balances[msg.sender] >= amount, "Insufficient balance"); require(getHealthFactor(msg.sender) > MIN_HEALTH, "Unhealthy"); // 2. Effects (update state BEFORE external call) balances[msg.sender] -= amount; // 3. Interactions (external call last) (bool success,) = msg.sender.call{value: amount}(""); require(success, "ETH transfer failed"); }

Liquidation Mechanics and Vulnerabilities

Liquidation IssueDescriptionImpactMitigation
Bot RacingMultiple bots compete to liquidate same position, wasting gasHigh gas costs, some liquidations failPartial liquidations, incentive design
Bad Debt AccumulationPosition goes underwater faster than liquidation can occurProtocol becomes insolventConservative LTV ratios, price circuit breakers
Collateral with Transfer FeesFee-on-transfer token reduces received collateral below recorded amountAccounting break, inflated collateral valueMeasure balance delta, not input amount
Paused MarketsBorrower cannot repay while interest accruesForced into liquidation unfairlyPause repayment and liquidation together
Liquidation Incentive Too LowBots unprofitable to run → no liquidations happenBad debt builds upDynamic incentive based on collateral ratio
Liquidation Discount Too HighBorrowers lose more than necessaryUser experience and fairness issueSoft liquidation (Curve/LLAMMA model)
🔍 Fee-on-Transfer Collateral Bug
// ❌ WRONG: Records amount before transfer fee deduction function deposit(address token, uint256 amount) external { IERC20(token).transferFrom(msg.sender, address(this), amount); collateral[msg.sender][token] += amount; // Records 1000 USDT // But only 995 USDT actually arrived (0.5% fee)! // Attacker's collateral is overstated by 5 USDT } // ✅ CORRECT: Measure the balance delta function deposit(address token, uint256 amount) external { uint256 balanceBefore = IERC20(token).balanceOf(address(this)); IERC20(token).transferFrom(msg.sender, address(this), amount); uint256 balanceAfter = IERC20(token).balanceOf(address(this)); uint256 actualReceived = balanceAfter - balanceBefore; // 995 USDT collateral[msg.sender][token] += actualReceived; // Correct! }

Bad Debt: Venus Protocol Case Study

In May 2022, Venus Protocol accumulated $200M in bad debt when a whale used XVS (Venus's governance token) as collateral to borrow BNB and USDT, then dumped XVS, crashing its price faster than liquidators could respond. The protocol covered the loss with its reserves and insurance fund, but many users took losses.

🚨 The Venus Protocol Lesson

Using a protocol's own governance token as collateral is one of the most dangerous design choices in DeFi. The token has thin liquidity relative to the protocol's TVL, so large borrows against it create reflexive risk: if prices fall, liquidations cause more selling, causing more liquidations. Most sophisticated protocols today either exclude their own token from collateral or apply extremely conservative LTV ratios (20% or less).

Paused Market Attack Vector

🔍 Paused Market DoS
// Scenario: Admin pauses USDC market due to depeg concerns // Effect: All borrowers who used USDC as collateral cannot interact modifier whenNotPaused(address market) { require(!paused[market], "Market is paused"); _; } // PROBLEM: Repay is also blocked by the pause function repay(address market, uint256 amount) external whenNotPaused(market) // ← This blocks repayment! { // While this is blocked, interest still accrues // Borrower's health factor falls // They get liquidated even though they WANTED to repay } // ✅ CORRECT: Pause should block new borrows, not repayment function borrow(address market, uint256 amount) external whenNotPaused(market) // ← Correct: block new debt creation { ... } function repay(address market, uint256 amount) external // No pause check on repayment — always allow debt reduction { ... }

Cross-Asset Contagion in Multi-Asset Lending

🔍 Multi-Asset Lending Risk
// Multi-asset lending protocols allow one collateral to back many debts // This creates contagion risk: one asset's collapse affects all borrowers // Example: Aave has 30+ supported assets // User A: deposits ETH ($100K), borrows USDC ($75K) — health factor 1.33 // User A is also: deposits WBTC ($50K), borrows USDT ($35K) — health factor 1.43 // Compound's approach: separate markets (cETH, cWBTC — isolated) // Aave's approach: cross-collateral (all assets in one position) // Security implication: cross-collateral → single oracle failure affects everything // If ETH oracle returns inflated price: // User A's health factor improves → can borrow even more USDC // This borrowed USDC is used to repay in another protocol // Creates complex cross-protocol attack surface // ❌ VULNERABLE PATTERN: Global pause freezes all positions modifier globalNotPaused() { require(!globalPaused, "Protocol paused"); _; } function repay(...) external globalNotPaused { ... } // A global pause prevents ALL repayments → ALL positions can be liquidated // ✅ SAFER: Per-market pause with repayment always allowed mapping(address => bool) public marketPaused; function borrow(address market, ...) external { require(!marketPaused[market], "Market paused"); // Only new borrows blocked } function repay(address market, ...) external { // No pause check — always allow debt reduction }

Auditing Checklist for Lending Protocols

CategoryCheckSeverity if Missing
Interest AccrualIs accrueInterest() called before every health check?High
OracleCan the price feed be manipulated in one block?Critical
CollateralAre fee-on-transfer tokens handled via balance delta?High
LiquidationCan users always repay even when market is paused?High
ReentrancyCEI pattern in deposit, withdraw, liquidate?Critical
RoundingDoes rounding always favor the protocol?Medium
Bad DebtIs there an insurance fund? What's the governance token LTV?High
Collateral FactorCan LTV be set > 100% by admin?Critical
Donation AttackDoes any function reduce collateral without a health check?Critical
Utilization CapIs there a maximum utilization rate to prevent lock-out DoS?Medium
💡 Auditor's Heuristic

For every lending protocol function, ask: "What happens if the price drops 50% between when the user initiated this transaction and when it executes?" Any function whose correctness depends on price stability without fresh oracle reads is a potential vulnerability. Always trace how accrueInterest() and getPrice() interact with every user-facing function.

Euler Finance: The Most Complex DeFi Hack ($197M, March 2023)

The Euler Finance exploit is one of the most technically sophisticated lending protocol attacks in history. The attacker discovered a combination of a missing health check on the donateToReserves() function and the ability to create a "soft liquidation" that left a position in a state where the protocol owed the attacker more than the attacker owed the protocol.

🔍 Euler Finance Attack Pattern
// Euler had a donateToReserves() function that allowed anyone to // donate collateral to the protocol's reserves // CRITICAL BUG: it did NOT check the health factor of the donor after donation function donateToReserves_BUGGY(uint256 amount) external { // Reduce the caller's eToken (collateral token) balance eToken.burn(msg.sender, amount); // Add to protocol reserves totalReserves += amount; // ❌ MISSING: checkLiquidity(msg.sender) // The caller might now be undercollateralized — but nobody checks! } // Attack sequence (simplified): // 1. Flash borrow 30M DAI // 2. Deposit 20M DAI into Euler → receive 20M eDAI (collateral token) // 3. Mint 200M dDAI (debt token) — leveraged position // 4. Repay 10M DAI → eDAI increases by 10M (standard Euler mechanics) // 5. donateToReserves(100M eDAI) ← removes collateral, no health check // 6. Now position is massively undercollateralized // 7. Second attacker address liquidates the first at a discount // 8. Net result: attacker ends up with more assets than liabilities // Total stolen: ~$197M across multiple assets // Fix: check health factor after EVERY operation that reduces collateral function donateToReserves_FIXED(uint256 amount) external { eToken.burn(msg.sender, amount); totalReserves += amount; checkLiquidity(msg.sender); // ✅ Always check after reducing collateral }

Liquidation Incentive Design

🔍 Liquidation Mechanics and Edge Cases
// Standard liquidation: liquidator repays borrower's debt, receives collateral at discount function liquidate( address borrower, address debtAsset, uint256 debtAmount, address collateralAsset ) external { // Step 1: Verify position is liquidatable require(getHealthFactor(borrower) < 1e18, "Position healthy"); // Step 2: Cap liquidation at 50% of outstanding debt (prevents over-liquidation) uint256 maxLiquidatable = getDebt(borrower, debtAsset) / 2; uint256 actualDebt = debtAmount > maxLiquidatable ? maxLiquidatable : debtAmount; // Step 3: Calculate collateral bonus (e.g., 5% liquidation incentive) uint256 debtValueInCollateral = convertDebtToCollateral(actualDebt); uint256 collateralToSeize = debtValueInCollateral * 10500 / 10000; // 5% bonus // Step 4: Execute — liquidator pays debt, receives collateral IERC20(debtAsset).transferFrom(msg.sender, address(this), actualDebt); repayDebt(borrower, debtAsset, actualDebt); transferCollateral(borrower, msg.sender, collateralAsset, collateralToSeize); // Edge case: What if collateralToSeize > borrower's collateral balance? // This happens during rapid price drops — position went from healthy to deeply // underwater before liquidation could happen // Result: liquidator receives less than the 5% bonus → unprofitable // Solution: protocol absorbs bad debt via insurance fund }

Interest Rate Model Security

⚠️ Utilization Rate Manipulation

The utilization rate (borrows/deposits) determines the interest rate. An attacker who wants to grief depositors can borrow a huge amount to spike utilization to 100%, sending interest rates to 1000%+ APY. Depositors cannot withdraw because all liquidity is borrowed. Their funds are locked while their deposits earn the high rate — but if the attacker also holds the debt, they are paying themselves interest. This is a DoS on withdrawals combined with interest rate manipulation. Mitigation: set a maximum utilization cap (e.g., 95%) above which new borrows are blocked.

🔍 Utilization Griefing Attack
// Attacker scenario: deposit $1M USDC, borrow $990K USDC (99% utilization) // Jump rate model at 99% utilization → ~500% APY borrow rate // All other depositors' USDC is now locked — they cannot withdraw // Attacker controls both sides: they earn 500% on their deposit, // they pay 500% on their borrow — net cost is only gas + protocol fees // BUT: other depositors are locked for the duration of the attack // ✅ PROTECTION: Maximum borrow cap function borrow(uint256 amount) external { uint256 currentUtil = utilizationRate(); uint256 newUtil = (totalBorrows() + amount) * 1e18 / totalDeposits(); // Block borrows that would push utilization above 95% require(newUtil <= MAX_UTILIZATION, "Exceeds max utilization"); _borrow(msg.sender, amount); } uint256 constant MAX_UTILIZATION = 95e16; // 95%

Liquidation Design Flaws

The liquidation mechanism is the safety valve that keeps a lending protocol solvent. Poorly designed liquidations can fail to close underwater positions, create perverse incentives for liquidators, or allow liquidators to extract more than the safety bonus justifies.

🔍 Liquidation Close Factor and Bonus Attacks
// Liquidation parameters: // Close Factor: what fraction of debt can be repaid in one liquidation (e.g. 50%) // Liquidation Bonus: how much extra collateral the liquidator receives (e.g. 5%) // Bug 1: Close factor too high → full liquidation of borderline position // Health Factor = 0.99 (barely undercollateralized) // Close Factor = 100% → liquidator can close ENTIRE position at once // User loses 5% bonus on 100% of their collateral for a 1% health factor violation // Fix: Close factor 50% — multiple liquidations needed, price has time to recover // Bug 2: Liquidation bonus makes protocol insolvent when collateral is illiquid // If collateral price crashes 30%: // Debt value = $100 Collateral value = $70 // Liquidation bonus = 5% → liquidator gets $70 collateral but only repays $66.67 // Protocol still has $100 debt liability but $0 remaining collateral // Bad debt = $33.33 per position → platform insolvency at scale // Fix: Aave uses a "bad debt socialization" — remaining bad debt is covered by // the Safety Module (stakers accept the loss) // Bug 3: No liquidation bot incentive for small positions // Gas cost to liquidate: ~200K gas × 100 gwei = $40 // 5% bonus on $500 position = $25 — liquidation is unprofitable // Result: small underwater positions accumulate as unliquidated bad debt // Fix: minimum position size, or scaled liquidation bonus for small positions // ✅ Safe liquidation: check borrower's position is actually undercollateralized function liquidate(address borrower, uint256 repayAmount) external { accrueInterest(); // Always accrue before health check require(getHealthFactor(borrower) < 1e18, "Not liquidatable"); // Close factor: max repayAmount = 50% of borrower's total debt uint256 maxRepay = (borrowDebt[borrower] * CLOSE_FACTOR) / 1e18; require(repayAmount <= maxRepay, "Exceeds close factor"); // Collateral seized = repayAmount × (1 + liquidationBonus) / collateralPrice uint256 seized = (repayAmount * (1e18 + LIQUIDATION_BONUS)) / collateralPrice; require(seized <= collateral[borrower], "Insufficient collateral"); // CEI: effects before interactions borrowDebt[borrower] -= repayAmount; collateral[borrower] -= seized; collateral[msg.sender] += seized; // Interaction: pull repay token from liquidator debtToken.safeTransferFrom(msg.sender, address(this), repayAmount); }

Key Takeaways

  • Flash loan attacks on lending protocols almost always involve oracle manipulation — the protocol is tricked into believing collateral is worth more than it is.
  • Always call accrueInterest() before any health factor check to prevent stale debt calculations.
  • Measure received token amounts using balance deltas, never trust the amount parameter with unknown tokens.
  • Market pauses should never block repayment — only new borrows and new collateral deposits.
  • Using a protocol's own governance token as high-LTV collateral creates reflexive liquidation risk.
  • Every function that reduces collateral — including donation and donation-like mechanisms — must check the health factor afterward.
  • High utilization rates can be weaponized to lock depositors' funds — maximum utilization caps prevent this DoS vector.
  • Reentrancy in lending protocols can be subtle — the vulnerability may be in the health check, not the transfer.