← Back to Blog

DeFi Liquidation Security: Bad Debt, Oracle Manipulation, and Liquidation Griefing

2026-04-18 liquidation defi lending bad debt oracle solidity security 2026

In March 2023, Euler Finance lost $197 million in a single transaction. The attacker exploited a flaw in Euler's donation logic that allowed a borrower to create a position that could not be liquidated at a profit — then triggered a self-liquidation that drained reserves. The root cause was a mis-designed liquidation mechanism: the protocol's math made bad debt the rational outcome.

Liquidation mechanisms are the immune system of every lending protocol. When they fail — through oracle manipulation, bonus miscalculation, precision errors, or deliberate griefing — the protocol accumulates bad debt and eventually becomes insolvent. This post covers the six most critical liquidation vulnerabilities auditors find in DeFi lending systems, with Solidity examples and the patterns that fix them.


How Liquidations Work

Every lending protocol tracks a borrower's health using a ratio between collateral value and outstanding debt. When that ratio falls below a threshold — typically called a health factor of 1.0 — the position becomes eligible for liquidation. A liquidator repays some or all of the debt and receives collateral at a discount, called the liquidation bonus. The bonus compensates the liquidator for gas costs, market risk, and the capital required to execute the repayment.

The math works in stable markets: a borrower deposits 1 ETH ($2,000), borrows $1,400 USDC, and an 8% liquidation bonus means a liquidator who repays $1,400 receives $1,512 in collateral. As long as ETH stays above $1,521, the system clears itself. The problem is that markets are not always stable, liquidators are not always present, and the liquidation surface is far more adversarial than most designers assume.


Vulnerability 1: Oracle Price Manipulation Enabling Instant Liquidation

The most direct attack on a liquidation system is forcing healthy positions into an unhealthy state by manipulating the price feed used to compute collateral value. If a protocol prices collateral using a spot price from a low-liquidity AMM pool, an attacker with a flash loan can move that price in a single block.

// VULNERABLE: spot price from single DEX pool
function getHealthFactor(address borrower) public view returns (uint256) {
    (uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(collateralPair).getReserves();
    uint256 spotPrice = (reserve1 * 1e18) / reserve0;
    uint256 collateralValue = (collateral[borrower] * spotPrice) / 1e18;
    uint256 debtValue = debt[borrower];
    return (collateralValue * 1e18) / debtValue;
}

The attack: flash loan → dump tokens into the AMM to crash the spot price → call liquidate() on every position that now reads as unhealthy → pocket the bonus → repay the flash loan. Profit scales with TVL; cost is only gas and the flash loan fee.

The fix is using Chainlink as the primary feed with staleness validation, cross-checked against a TWAP:

function getPrice() external view returns (uint256) {
    (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
        = chainlinkFeed.latestRoundData();

    require(answer > 0, "negative price");
    require(updatedAt >= block.timestamp - 3600, "stale feed");
    require(answeredInRound >= roundId, "incomplete round");

    uint256 chainlinkPrice = uint256(answer);
    uint256 twapPrice = _getUniswapV3TWAP();
    uint256 deviation = chainlinkPrice > twapPrice
        ? ((chainlinkPrice - twapPrice) * 10000) / twapPrice
        : ((twapPrice - chainlinkPrice) * 10000) / chainlinkPrice;
    require(deviation <= 500, "price deviation too high"); // 5% max
    return chainlinkPrice;
}

No single block can move a TWAP significantly — the longer the window, the more capital and time an attacker needs.


Vulnerability 2: Missing Liquidation Bonus Cap

Liquidation bonuses are meant to attract liquidators. Some protocols set the bonus dynamically — higher bonus the further underwater a position is — without imposing an upper cap. An attacker can exploit this to self-liquidate at an enormous discount.

// VULNERABLE: bonus scales with health factor, no cap
function getLiquidationBonus(address borrower) public view returns (uint256) {
    uint256 hf = getHealthFactor(borrower);
    // More underwater = larger bonus, no upper bound
    return (1e18 - hf) * 10; // 10x the underwater percentage as bonus
}

function liquidate(address borrower, uint256 repayAmount) external {
    uint256 bonus = getLiquidationBonus(borrower);
    uint256 collateralToSeize = (repayAmount * (1e18 + bonus)) / 1e18;
    // Transfer collateralToSeize from borrower to liquidator
    // No check that collateralToSeize <= totalCollateral[borrower]
}

The exploit: flash loan → borrow against minimal collateral to create a maximally underwater position → self-liquidate via a separate address. At extreme underwater levels, the unbounded bonus exceeds 100%, seizing far more than was repaid — funded directly from protocol reserves.

The fix is a hard cap on the bonus and a guard that seized collateral never exceeds what the borrower holds:

uint256 public constant MAX_LIQUIDATION_BONUS = 0.15e18; // 15% hard cap

function getLiquidationBonus(address borrower) public view returns (uint256) {
    uint256 rawBonus = (1e18 - getHealthFactor(borrower)) * 2;
    return rawBonus > MAX_LIQUIDATION_BONUS ? MAX_LIQUIDATION_BONUS : rawBonus;
}

function liquidate(address borrower, uint256 repayAmount) external {
    uint256 collateralToSeize = (repayAmount * (1e18 + getLiquidationBonus(borrower))) / 1e18;
    // Cannot seize more than borrower holds — prevents reserve drain
    collateralToSeize = collateralToSeize > collateral[borrower]
        ? collateral[borrower] : collateralToSeize;
}

Vulnerability 3: Bad Debt Socialization Without a Cap

A 40% price drop in a single block — from a CEX failure or cascading liquidations — can outpace the liquidation mechanism entirely. When collateral value falls below outstanding debt before any liquidator acts, the protocol accumulates bad debt with no circuit breaker:

// VULNERABLE: no bad debt cap per block, no circuit breaker
function _handleBadDebt(address borrower) internal {
    uint256 debtValue = debt[borrower];
    uint256 collateralValue = getCollateralValue(borrower);
    if (collateralValue < debtValue) {
        uint256 badDebt = debtValue - collateralValue;
        totalBadDebt += badDebt;     // unbounded accumulation
        debt[borrower] = 0;
        collateral[borrower] = 0;
        // Bad debt socialized to all depositors with no limit
        totalDeposits -= badDebt;
    }
}

If totalDeposits reaches zero, the protocol is insolvent. A circuit breaker limits how much bad debt can be socialized per block and pauses new borrowing when a threshold is breached:

uint256 public constant MAX_BAD_DEBT_PER_BLOCK = 100_000e18;
uint256 public badDebtThisBlock;
uint256 public lastBadDebtBlock;

function _handleBadDebt(address borrower) internal {
    if (block.number != lastBadDebtBlock) {
        badDebtThisBlock = 0;
        lastBadDebtBlock = block.number;
    }
    uint256 badDebt = debt[borrower] - getCollateralValue(borrower);
    badDebtThisBlock += badDebt;
    require(badDebtThisBlock <= MAX_BAD_DEBT_PER_BLOCK, "circuit breaker triggered");

    totalBadDebt += badDebt;
    uint256 reserveCoverage = reserves >= badDebt ? badDebt : reserves;
    reserves -= reserveCoverage;
    if (badDebt > reserveCoverage) totalDeposits -= (badDebt - reserveCoverage);
}

The reserve fund absorbs bad debt first; only excess debt is socialized to depositors. The per-block cap gives governance time to pause the protocol before systemic loss occurs.


Vulnerability 4: Liquidation DoS and Griefing

A profitable liquidation can be blocked by front-running: the attacker liquidates a tiny fraction of the position — just enough to shift the debt balance without restoring health. The legitimate liquidator's transaction then either reverts or produces far less profit than expected.

// VULNERABLE: no minimum, no slippage guard
function liquidate(address borrower, uint256 repayAmount) external {
    require(getHealthFactor(borrower) < 1e18, "position healthy");
    // Attacker front-runs with repayAmount = 1 wei — position stays unhealthy
    // but legitimate liquidator's profit drops below gas cost
    _repayDebt(borrower, msg.sender, repayAmount);
    _seizeCollateral(borrower, msg.sender, repayAmount);
}

The gas griefing variant deploys a collateral token whose transfer() consumes all forwarded gas, blocking every liquidation attempt. Three defenses address this together:

uint256 public constant MIN_LIQUIDATION_AMOUNT = 100e18; // $100 minimum

function liquidate(
    address borrower,
    uint256 repayAmount,
    uint256 minCollateralOut  // slippage guard against front-running
) external {
    require(repayAmount >= MIN_LIQUIDATION_AMOUNT, "below minimum");
    require(getHealthFactor(borrower) < 1e18, "position healthy");
    uint256 collateralToSeize = _calculateCollateralToSeize(borrower, repayAmount);
    require(collateralToSeize >= minCollateralOut, "slippage exceeded");
    // Gas-limited transfer prevents collateral token from consuming all gas
    (bool ok,) = collateralToken.call{gas: 200_000}(
        abi.encodeWithSelector(IERC20.transfer.selector, msg.sender, collateralToSeize)
    );
    require(ok, "collateral transfer failed");
}

The minimum amount prevents dust griefing. The minCollateralOut parameter causes a clean revert if a front-runner already partially liquidated. The gas cap blocks collateral tokens engineered to exhaust forwarded gas.


Vulnerability 5: Integer Precision in Health Factor Calculation

An off-by-one in the health factor comparison has two failure modes: it allows liquidation of exactly-healthy positions, or prevents liquidation of unhealthy ones. Both appear regularly in production audits.

// VULNERABLE: wrong operator AND division-before-multiplication
function isLiquidatable(address borrower) public view returns (bool) {
    return getHealthFactor(borrower) >= 1e18; // BUG: flags hf=1e18 as liquidatable
}

function getHealthFactor(address borrower) public view returns (uint256) {
    uint256 collateralValue = collateral[borrower] * oracle.getPrice() / 1e18;
    return (collateralValue / debt[borrower]) * 1e18; // BUG: division first loses precision
}

The correct implementation multiplies before dividing, guards against zero debt, and uses strict comparison:

function getHealthFactor(address borrower) public view returns (uint256) {
    if (debt[borrower] == 0) return type(uint256).max;
    // Multiply first — no intermediate division, full precision preserved
    uint256 adjustedCollateral = collateral[borrower] * oracle.getPrice() * collateralFactor;
    return adjustedCollateral / (debt[borrower] * 1e8 * 1e18);
}

function isLiquidatable(address borrower) public view returns (bool) {
    return getHealthFactor(borrower) < 1e18; // strict: exactly 1.0 is NOT liquidatable
}

Fuzz both boundaries: 1e18 - 1 returns true, 1e18 returns false.


Vulnerability 6: Collateral Not Correctly Valued — Illiquid Collateral Exploit

Protocols that accept any ERC-20 token as collateral and price it at oracle value without a liquidity check are vulnerable to a simple drain: an attacker creates or controls an illiquid token, lists it as collateral, uses the oracle-reported price to borrow real assets, then the token becomes worthless when the protocol tries to liquidate because there is no market to sell it into.

// VULNERABLE: accepts token at oracle price with no liquidity check
function addCollateral(address token, uint256 amount) external {
    uint256 tokenPrice = oracle.getPrice(token);
    uint256 collateralValue = (amount * tokenPrice) / 1e18;
    collateral[msg.sender][token] += collateralValue;
    IERC20(token).transferFrom(msg.sender, address(this), amount);
}

The attacker deploys AttackToken with an inflated oracle price, deposits tokens as collateral, borrows real assets, and exits. When liquidators try to sell the seized tokens, no DEX liquidity exists — the protocol holds worthless collateral against real debt.

The mitigation requires a whitelist and on-chain liquidity validation before accepting any deposit:

mapping(address => bool) public approvedCollateral;
mapping(address => uint256) public maxCollateralFactor; // per-token LTV cap

function addCollateral(address token, uint256 amount) external {
    require(approvedCollateral[token], "token not approved as collateral");
    uint256 dexLiquidity = _getUniswapV3Liquidity(token);
    require(dexLiquidity >= MIN_LIQUIDITY_THRESHOLD, "insufficient liquidity");

    uint256 adjustedValue = (amount * oracle.getPrice(token) * maxCollateralFactor[token])
        / (1e18 * 1e18);
    collateral[msg.sender][token] += adjustedValue;
    IERC20(token).transferFrom(msg.sender, address(this), amount);
}

New tokens require a governance vote with a timelock, and LTV caps are enforced in the contract rather than relying on off-chain policy.


Liquidation Defense Patterns

Partial liquidation with close factor cap. A close factor of 50% means liquidators repay at most half the debt per call, preventing a single liquidator from extracting the entire bonus in one block. Enforce it as a hard limit, not a suggestion:

uint256 public constant CLOSE_FACTOR = 0.5e18;
function _maxRepayAmount(address borrower) internal view returns (uint256) {
    return (debt[borrower] * CLOSE_FACTOR) / 1e18;
}

Reserve fund absorbs bad debt first. Protocol fees accumulate in a reserve buffer. Bad debt draws from reserves before any loss reaches depositors — protecting them from all but catastrophic events.

Exchange-rate socialization. When bad debt must be socialized, reduce the deposit token's exchange rate proportionally rather than creating a separate liability. This spreads loss smoothly across all depositors and avoids a bank-run dynamic:

function _socializeBadDebt(uint256 badDebtAmount) internal {
    uint256 totalShares = depositToken.totalSupply();
    if (totalShares == 0) return;
    exchangeRate -= (badDebtAmount * 1e18) / totalShares;
}

What ContractScan Detects

Vulnerability Detection Method Severity
Spot price oracle for collateral valuation Static analysis — no TWAP fallback detected Critical
Missing liquidation bonus cap Arithmetic analysis — unbounded bonus multiplier High
No bad debt circuit breaker Control flow — uncapped totalBadDebt increment High
Health factor comparison bug (>= vs <) Comparison operator pattern matching High
Division before multiplication in health factor Arithmetic ordering analysis Medium
Unwhitelisted collateral acceptance Access control — missing approval check High
No minCollateralOut slippage parameter Function signature — missing slippage arg Medium

Audit Your Lending Protocol

Liquidation mechanisms sit at the intersection of oracle design, collateral accounting, and economic incentives — a surface static analysis cannot fully cover, but where it catches the deterministic bugs that serve as entry points for exploits.

Audit your lending protocol at https://contract-scanner.raccoonworld.xyz to surface oracle dependency issues, health factor precision bugs, and missing bounds checks before they become an incident.



This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.
\n\n## Important Notes\n\nThis post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →