Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: high

Oracle Staleness Not Validated — Stale Prices Used for Leverage and Unwind Calculations

Author Revealed upon completion

Oracle Staleness Not Validated — Stale Prices Used for Leverage and Unwind Calculations

Description

  • Chainlink's latestRoundData() returns five values: roundId, answer, startedAt, updatedAt, and answeredInRound. These fields exist specifically to allow consumers to detect stale or incomplete data.

  • StrataxOracle.getPrice() discards all fields except answer, performing no staleness validation. The price could be hours or days old without the protocol knowing.

function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token");
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
// @audit Only `answer` is used — updatedAt, roundId, answeredInRound all discarded
(, int256 answer,,,) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}
  • This stale price is consumed in two critical paths:

    • calculateOpenParams() (Stratax.sol:393-403) — determines flash loan and borrow amounts

    • _executeUnwindOperation() (Stratax.sol:570-571) — determines how much collateral to withdraw from Aave

Risk

Likelihood:

  • Chainlink feeds have heartbeat intervals (e.g., 1 hour for ETH/USD). Between heartbeats, the price is stale by definition

  • L2 sequencer downtime freezes all Chainlink feeds on that chain — the protocol has no sequencer uptime check

  • Oracle outages have occurred multiple times historically

Impact:

  • During createLeveragedPosition: Stale prices produce incorrect flashLoanAmount and borrowAmount, potentially creating overleveraged positions that are immediately liquidatable on Aave

  • During unwindPosition: _executeUnwindOperation calculates collateralToWithdraw using stale prices. If the real price is lower than the stale price, too little collateral is withdrawn, the swap produces fewer debt tokens, and the flash loan repayment fails — the position cannot be unwound

  • On L2 (Arbitrum, Optimism, Base): sequencer downtime means all prices are frozen. The protocol operates blindly on old data

Real-World Precedent:

  • Mango Markets (2022-10-11) — $114,000,000 lost: Oracle price manipulation enabled massive borrowing against inflated collateral

  • Euler Finance (2023-03-13) — $197,000,000 lost: Price manipulation and oracle issues contributed to the exploit

  • Multiple Chainlink heartbeat misses documented, where prices were stale for hours

Proof of Concept

How the issue manifests:

  1. ETH/USD Chainlink feed has a 1-hour heartbeat. The last update was 55 minutes ago at $2000

  2. In the last 55 minutes, ETH dropped 15% to $1700 on the market

  3. Owner calls unwindPosition() to close a leveraged ETH position

  4. _executeUnwindOperation fetches the stale $2000 price from the oracle

  5. Collateral-to-withdraw is calculated based on $2000: (debtAmount * debtPrice * collateralDec * LTV_PRECISION) / ($2000 \* debtDec \* liqThreshold)\

  6. At $2000, less collateral is withdrawn than needed (the real exchange rate is$1700)

  7. The swap converts this collateral to debt tokens at the real $1700 rate — producing fewer tokens than expected

  8. require(returnAmount >= totalDebt) fails because the swap output doesn't cover the flash loan + premium

  9. The entire unwind reverts — the position cannot be closed while the oracle is stale

  10. Meanwhile, Aave uses its own (fresh) oracle and may liquidate the position

Expected outcome: The position becomes stuck — unable to unwind via Stratax (stale oracle) while simultaneously at risk of liquidation on Aave (fresh oracle). The owner loses control of their position.

Recommended Mitigation

The root cause is that getPrice() uses the raw Chainlink answer without verifying it is fresh and from a completed round. The fix must validate all staleness fields before accepting the price.

Primary fix — Validate all Chainlink staleness fields:

uint256 public constant MAX_STALENESS = 3600; // 1 hour — adjust per feed heartbeat
function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token");
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
(uint80 roundId, int256 answer,, uint256 updatedAt, uint80 answeredInRound) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
require(updatedAt > 0, "Round not complete");
require(answeredInRound >= roundId, "Stale price data");
require(block.timestamp - updatedAt < MAX_STALENESS, "Price too stale");
price = uint256(answer);
}

Why this works:

  • updatedAt > 0 ensures the round has been completed (not pending)

  • answeredInRound >= roundId ensures the answer belongs to the current or a more recent round (guards against carried-over stale answers)

  • block.timestamp - updatedAt < MAX_STALENESS ensures the price is within an acceptable freshness window

  • If any check fails, the function reverts, preventing the protocol from operating on stale data

Additional hardening for L2 deployment: Add a Chainlink L2 Sequencer Uptime Feed check to detect sequencer downtime and implement a grace period after recovery. See Chainlink L2 Sequencer Feeds.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!