When Your Safety System Becomes the Weapon
On March 10, 2026, Aave's Correlated Asset Price Oracle (CAPO) — a system specifically designed to prevent oracle manipulation — became the source of an oracle attack. Not from an external attacker, but from a misconfiguration in its own update pipeline.
The result: 34 leveraged wstETH positions liquidated. $27 million wiped out. 499 ETH (~$1.2M) handed to liquidation bots. All because a timestamp and a ratio disagreed by a few percentage points.
This is the anatomy of a security system eating its own users — and the lessons every DeFi protocol should learn from it.
Understanding CAPO: The Guard That Went Rogue
CAPO exists to solve a real problem. Yield-bearing tokens like wstETH naturally appreciate against their underlying asset (ETH) as staking rewards accrue. An attacker could theoretically manipulate this exchange rate — through donation attacks or flash-loan-driven inflation — to artificially boost their collateral value and drain lending pools.
CAPO prevents this by imposing a deterministic ceiling on how fast a correlated asset's exchange rate can grow:
max_ratio = snapshot_ratio × (1 + max_growth_rate) ^ time_elapsed
If the live wstETH/ETH ratio exceeds this ceiling, CAPO caps it. The protocol uses the lower value, protecting against manipulation.
The architecture is split:
- Off-chain (Chaos Labs): Computes and submits updated snapshot ratios and growth parameters
- On-chain (BGD contracts): Enforces caps and validates updates against safety constraints
This hybrid design is the strength — and, as March 10 proved, the vulnerability.
The Kill Chain: How Three Correct Components Created a Wrong Answer
Step 1: The Stale Snapshot
CAPO's on-chain contract held a snapshotRatio that had drifted from the actual wstETH/ETH exchange rate. This happens naturally — the snapshot is a historical reference point, and the live rate grows as staking rewards accumulate.
The correct live rate: ~1.228 wstETH per ETH
Step 2: The Constrained Update
Chaos Labs' off-chain system attempted to update the snapshot ratio to reflect the rate from seven days prior (~1.2282). However, an on-chain safety constraint blocked it:
The snapshot ratio cannot increase by more than 3% within any three-day window.
This constraint exists to prevent an attacker from submitting a fraudulently high snapshot. It worked exactly as designed — it rejected the update.
Step 3: The Timestamp Divergence
Here's where the bug lived. The snapshotTimestamp was updated as if the full seven-day growth had been incorporated, even though the ratio update was only partially applied due to the 3% cap.
The CAPO formula then computed:
max_ratio = partially_updated_ratio × (1 + growth_rate) ^ (time_since_new_timestamp)
Since the timestamp said "I'm up to date as of 7 days ago" but the ratio said "I only moved 3%," the formula calculated a maximum rate of ~1.1939 — roughly 2.85% below the actual market rate.
Step 4: Mass Liquidation
With CAPO reporting wstETH at 1.1939 instead of 1.228, every highly leveraged E-Mode position using wstETH as collateral suddenly appeared undercollateralized. Liquidation bots — doing exactly what they're programmed to do — swept in and liquidated 10,938 wstETH across 34 accounts.
Actual wstETH value: $X per token
CAPO-reported value: $X × 0.9715 per token (2.85% haircut)
E-Mode LTV threshold: ~93%
For a position at 92% LTV (safe by 1%):
After CAPO error: 92% / 0.9715 = 94.7% LTV → LIQUIDATABLE
The Root Cause: Coupling Without Atomicity
The fundamental issue is that CAPO's update mechanism treats the snapshotRatio and snapshotTimestamp as independently updatable values, but they're semantically coupled. The formula only produces correct results when both values are consistent.
// Simplified CAPO logic
function getMaxRatio() public view returns (uint256) {
uint256 timeElapsed = block.timestamp - snapshotTimestamp;
uint256 maxGrowth = _compound(maxYearlyGrowthRate, timeElapsed);
return snapshotRatio * maxGrowth / 1e18;
// BUG: If snapshotTimestamp advances but snapshotRatio is capped,
// the computed maxRatio will be too low
}
The fix is conceptually simple: either make the update atomic (both values change together or neither does) or derive the timestamp from the ratio rather than storing them independently.
// FIXED: Atomic update with rollback
function updateSnapshot(uint256 newRatio, uint256 newTimestamp) external onlyOracle {
uint256 ratioChange = (newRatio - snapshotRatio) * 10000 / snapshotRatio;
uint256 timeChange = newTimestamp - snapshotTimestamp;
// If ratio is capped, also cap the timestamp proportionally
if (ratioChange > maxChangePerPeriod) {
uint256 cappedRatio = snapshotRatio * (10000 + maxChangePerPeriod) / 10000;
uint256 cappedTimestamp = snapshotTimestamp +
(timeChange * maxChangePerPeriod / ratioChange);
snapshotRatio = cappedRatio;
snapshotTimestamp = cappedTimestamp; // Timestamp advances proportionally
} else {
snapshotRatio = newRatio;
snapshotTimestamp = newTimestamp;
}
}
Why This Pattern Is More Dangerous Than External Exploits
External oracle attacks — flash loan price manipulation, TWAP gaming — are adversarial. Protocols build defenses against them, and the security community has well-established patterns for detection and prevention.
The CAPO incident represents something harder to defend against: a safety mechanism that fails into an unsafe state. Specifically:
1. The Failure Mode Was Invisible
There was no on-chain alert, no anomaly detection, no circuit breaker. The oracle reported a price. It was wrong. But it was wrong in a direction that looked plausible — wstETH being slightly undervalued isn't alarming in the way that wstETH suddenly being 10x overvalued would be.
2. Liquidation Bots Are Amoral Executors
The bots did nothing wrong. They saw undercollateralized positions and liquidated them. The protocol incentivizes this behavior. The problem is that the positions weren't actually undercollateralized — the oracle was lying.
3. The Safety Constraint Caused the Failure
The 3% update cap — a security measure — is what prevented the ratio from being corrected. Remove that constraint and the bug doesn't manifest. But removing it would open the door to the exact attack CAPO is designed to prevent.
This is the classic security paradox: the tighter your constraints, the more catastrophic the failure when they interact unexpectedly.
Defense Patterns for Oracle-Dependent Protocols
Pattern 1: Atomic Parameter Updates
Never allow semantically coupled parameters to update independently:
struct OracleSnapshot {
uint256 ratio;
uint256 timestamp;
uint256 nonce;
}
function updateSnapshot(OracleSnapshot calldata newSnapshot) external {
require(newSnapshot.nonce == currentSnapshot.nonce + 1, "Non-sequential update");
uint256 impliedGrowthRate = _calculateGrowthRate(
currentSnapshot.ratio, newSnapshot.ratio,
currentSnapshot.timestamp, newSnapshot.timestamp
);
require(impliedGrowthRate <= maxAllowedGrowthRate, "Growth rate exceeded");
currentSnapshot = newSnapshot;
}
Pattern 2: Bidirectional Deviation Alerts
CAPO only caps upward manipulation. The March 10 incident was a downward error. Oracle systems should monitor deviation in both directions:
function validatePrice(uint256 reportedPrice) internal view {
uint256 chainlinkPrice = getChainlinkPrice();
uint256 deviation = _absDiff(reportedPrice, chainlinkPrice) * 10000 / chainlinkPrice;
if (deviation > MAX_DEVIATION_BPS) {
emit OracleDeviationAlert(reportedPrice, chainlinkPrice, deviation);
revert("Oracle deviation exceeds threshold");
}
}
Pattern 3: Liquidation Delay for Oracle-Driven Events
When liquidations are triggered by oracle price changes (rather than user actions), introduce a short delay:
mapping(address => uint256) public liquidationEligibleAt;
function markForLiquidation(address account) external {
require(isUndercollateralized(account), "Position is healthy");
if (liquidationEligibleAt[account] == 0) {
liquidationEligibleAt[account] = block.timestamp + LIQUIDATION_DELAY;
emit LiquidationWarning(account);
return;
}
require(block.timestamp >= liquidationEligibleAt[account], "Grace period active");
_executeLiquidation(account);
}
Pattern 4: Cross-Reference Multiple Oracle Sources
function getVerifiedPrice(address asset) public view returns (uint256) {
uint256 capoPrice = capoOracle.getPrice(asset);
uint256 chainlinkPrice = chainlinkOracle.getPrice(asset);
uint256 uniswapTWAP = uniswapOracle.getTWAP(asset, 30 minutes);
uint256 agreementCount = 0;
uint256 median = _median(capoPrice, chainlinkPrice, uniswapTWAP);
if (_withinTolerance(capoPrice, median, 200)) agreementCount++;
if (_withinTolerance(chainlinkPrice, median, 200)) agreementCount++;
if (_withinTolerance(uniswapTWAP, median, 200)) agreementCount++;
require(agreementCount >= 2, "Oracle consensus failure");
return median;
}
The $27M Question: Configuration as Code
The deepest lesson isn't about oracles. It's about configuration management in high-stakes systems.
The misconfiguration that caused $27M in liquidations was a parameter mismatch — the kind of bug that configuration-as-code practices, automated testing, and CI/CD pipelines routinely catch in traditional software. But DeFi oracle systems operate in a hybrid on-chain/off-chain architecture where:
- On-chain constraints can't be tested in isolation — they interact with off-chain update cadences
- Off-chain systems don't know about on-chain caps — or at least, this one didn't
- There's no staging environment for oracle updates to mainnet lending markets with $27M in positions
What Every Protocol Should Build
- Oracle update simulation: Before submitting an on-chain update, simulate the effect on all active positions.
-
Consistency invariant tests:
assert(impliedGrowthRate(snapshotRatio, snapshotTimestamp) <= maxGrowthRate)after every update. - Shadow oracles: Run a parallel pipeline, compare results, and alert on divergence before committing.
Timeline and Response
| Time | Event |
|---|---|
| T+0 | CAPO off-chain update submitted |
| T+0 | On-chain 3% cap partially blocks ratio update |
| T+0 | Timestamp advances fully despite partial ratio update |
| T+~minutes | Liquidation bots detect "undercollateralized" positions |
| T+~minutes | 34 positions liquidated, 10,938 wstETH sold |
| T+hours | Chaos Labs identifies root cause |
| T+hours | Emergency parameter correction deployed |
| T+days | Aave commits to full user reimbursement |
Credit where due: Aave's response was swift. No bad debt was incurred, and all affected users are being compensated. The CAPO system is fundamentally sound. But "fundamentally sound systems with configuration bugs" is exactly how $27M in user funds gets liquidated.
Conclusion
The Aave CAPO incident is a case study in how defense mechanisms fail. The oracle cap that was supposed to prevent price manipulation became the mechanism that caused incorrect pricing. The safety constraint that limited update speed became the constraint that prevented timely correction.
For DeFi developers, the takeaway is uncomfortable: your safety systems need safety systems. Oracle caps need deviation monitors. Update constraints need consistency checks. Automated liquidations need verification delays.
The $27M that was incorrectly liquidated will be reimbursed. The 499 ETH that liquidation bots earned is a sunk cost. But the architectural lesson — that tightly coupled parameters with independent update paths will eventually diverge catastrophically — is worth far more than $27M to the protocols that learn it before it happens to them.
DreamWork Security researches oracle systems, lending protocol architecture, and DeFi security patterns. Follow for weekly analysis of vulnerabilities, audit tools, and defense strategies. For audit inquiries: @ohmygod_eth
Top comments (0)