Stratax Contracts

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

Oracle Staleness - Missing Freshness Checks in getPrice()

Author Revealed upon completion

Description

The StrataxOracle.getPrice() function retrieves prices from Chainlink without validating freshness. It calls latestRoundData() but discards the updatedAt, roundId, and answeredInRound return values, checking only that answer > 0. This allows arbitrarily stale prices to be consumed by all price-dependent calculations in Stratax.

In StrataxOracle.sol:64-74:

@> 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);
@> (, int256 answer,,,) = priceFeed.latestRoundData();
@> require(answer > 0, "Invalid price from oracle");
@> price = uint256(answer);
@> }

Missing validations:

  1. updatedAt timestamp: No check that the price was recently updated — prices could be hours, days, or weeks old

  2. answeredInRound >= roundId: No check that the answer was computed in the current round

This affects three price-dependent operations in Stratax:

  • calculateOpenParams() (Stratax.sol:393-402) — Fetches oracle prices when collateralTokenPrice or borrowTokenPrice is zero. A stale price distorts the flash loan and borrow amount calculations, producing incorrect leverage parameters. Note: Aave's own health factor check at L526 (require(healthFactor > 1e18)) uses Aave's independent oracle as a backstop, so over-leveraged positions would still be rejected by Aave. The impact here is suboptimal parameter estimation that either reverts inside Aave or produces unintended leverage ratios.

  • calculateUnwindParams() (Stratax.sol:461-462) — Directly calls getPrice() for both debt and collateral tokens. A stale price produces an incorrect collateralToWithdraw value that flows into the unwind operation.

  • _executeUnwindOperation() (Stratax.sol:570-571) — This is the most critical usage. Inside the flash loan callback, the stale oracle price directly determines how much collateral to withdraw from Aave at L575-577:

    uint256 collateralToWithdraw = (
    _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
    ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);

    If the stale price overvalues collateral (e.g., reports $4000 when actual is$2000), too little collateral is withdrawn. The subsequent swap produces insufficient debt tokens, and the flash loan repayment fails at L588 (require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan")). This results in a denial of service — the user cannot unwind their position until the oracle updates.

    If the stale price undervalues collateral, excess collateral is withdrawn, reducing the remaining position's health factor more than necessary.

Risk

Likelihood:

  • Chainlink feeds can become stale during network congestion, aggregator node failures, or extreme market volatility (e.g., feeds experienced delays during the LUNA/UST collapse)

  • These events are uncommon on Ethereum mainnet but do occur during the exact high-volatility conditions when accurate pricing matters most

  • No special attacker role required — the vulnerability exists in normal operation whenever a feed goes stale

  • The protocol has no circuit breaker or fallback mechanism for stale oracle data

Impact:

  • DoS on unwind operations: When the stale Stratax oracle price diverges from the actual price, the collateral withdrawal calculation in _executeUnwindOperation() produces an incorrect amount. If the stale price overvalues collateral, too little is withdrawn, the swap can't cover the flash loan, and the transaction reverts — the user is unable to unwind their position until the oracle resumes updates

  • Suboptimal position parameters: calculateOpenParams() returns incorrect flash loan and borrow amounts, causing positions to be created with unintended leverage ratios. Aave's health factor check (L526) acts as a safety net against positions that are outright unsafe, but cannot correct the parameter distortion itself

  • Excess collateral withdrawal during unwind: If the stale price undervalues collateral, more collateral is withdrawn from Aave than necessary, unnecessarily degrading the position's health factor

Proof of Concept

The PoC demonstrates three things:

  1. A week-old price is silently accepted by getPrice()

  2. A year-old price is silently accepted by getPrice()

  3. Stale prices distort the collateral-to-debt ratio calculation used by _executeUnwindOperation(), causing 50% less collateral to be withdrawn — which would cause the flash loan repayment to revert (DoS)

Verified: All 3 tests pass with forge test --match-path test/OracleStaleness.t.sol -vvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {StrataxOracle} from "../src/StrataxOracle.sol";
/// @notice Mock Chainlink feed that allows setting arbitrary updatedAt timestamps
contract MockChainlinkFeed {
uint8 public decimals = 8;
int256 private _price;
uint256 private _updatedAt;
uint80 private _roundId;
constructor(int256 initialPrice) {
_price = initialPrice;
_updatedAt = block.timestamp;
_roundId = 1;
}
/// @notice Set stale price data with a specific updatedAt timestamp
function setRoundData(int256 price_, uint256 updatedAt_, uint80 roundId_) external {
_price = price_;
_updatedAt = updatedAt_;
_roundId = roundId_;
}
function latestRoundData()
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
return (_roundId, _price, _updatedAt, _updatedAt, _roundId);
}
}
contract OracleStalenessTest is Test {
StrataxOracle public oracle;
MockChainlinkFeed public ethFeed;
address constant TOKEN = address(0x1);
function setUp() public {
// Set realistic block.timestamp to avoid underflows
vm.warp(1_700_000_000); // Nov 2023
oracle = new StrataxOracle();
ethFeed = new MockChainlinkFeed(2000e8); // $2000 initial
oracle.setPriceFeed(TOKEN, address(ethFeed));
}
/// @notice Demonstrates stale 1-week-old price is silently accepted
function test_weekOldPriceAccepted() public {
uint256 oneWeekAgo = block.timestamp - 7 days;
ethFeed.setRoundData(4000e8, oneWeekAgo, 1);
// Oracle returns the 7-day-old price without reverting
uint256 price = oracle.getPrice(TOKEN);
assertEq(price, 4000e8, "Stale week-old price was accepted");
}
/// @notice Demonstrates stale 1-year-old price is silently accepted
function test_yearOldPriceAccepted() public {
uint256 oneYearAgo = block.timestamp - 365 days;
ethFeed.setRoundData(100e8, oneYearAgo, 1);
// Even a 1-year-old price is returned without error
uint256 price = oracle.getPrice(TOKEN);
assertEq(price, 100e8, "Stale year-old price was accepted");
}
/// @notice Shows how stale prices distort collateral-to-debt ratio calculations
/// that _executeUnwindOperation() (Stratax.sol:L570-577) relies on.
function test_stalePriceDistortsCollateralRatio() public {
// -- Scenario: ETH crashes from $4000 to $2000 but Chainlink feed is stale --
// Step 1: Feed reports $4000, updatedAt = 3 hours ago (stale)
uint256 threeHoursAgo = block.timestamp - 3 hours;
ethFeed.setRoundData(4000e8, threeHoursAgo, 10);
uint256 stalePrice = oracle.getPrice(TOKEN);
assertEq(stalePrice, 4000e8);
// Step 2: Actual market price is $2000 — oracle doesn't know
// collateralToWithdraw = (debtAmount * debtPrice) / collateralPrice
// With stale $4000: collateralToWithdraw = debt / $4000 (too little)
// With real $2000: collateralToWithdraw = debt / $2000 (correct)
// Simulate the ratio calculation from _executeUnwindOperation (L574-577):
// Using simplified numbers: 1000 USDC debt at $1, collateral priced by oracle
uint256 debtAmount = 1000e6; // 1000 USDC (6 decimals)
uint256 usdcPrice = 1e8; // $1 (8 decimals)
uint256 collateralDec = 18; // ETH decimals
// With stale $4000 price — protocol withdraws 0.25 ETH
uint256 withdrawStale = (debtAmount * usdcPrice * (10 ** collateralDec))
/ (stalePrice * 10 ** 6);
assertEq(withdrawStale, 0.25 ether);
// With real $2000 price — protocol should withdraw 0.5 ETH
uint256 realPrice = 2000e8;
uint256 withdrawReal = (debtAmount * usdcPrice * (10 ** collateralDec))
/ (realPrice * 10 ** 6);
assertEq(withdrawReal, 0.5 ether);
// Stale price causes 50% less collateral to be withdrawn
// → swap produces insufficient debt tokens → flash loan repayment reverts
// → user cannot unwind their position (DoS)
assertEq(withdrawStale, withdrawReal / 2, "Stale price halves withdrawn collateral");
}
}

Recommended Mitigation

Add freshness checks to validate Chainlink data before use. The fix validates updatedAt is recent and answeredInRound >= roundId:

+ uint256 public constant PRICE_STALENESS_THRESHOLD = 3600; // 1 hour
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);
- (, int256 answer,,,) = priceFeed.latestRoundData();
+ (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");
+ require(block.timestamp - updatedAt <= PRICE_STALENESS_THRESHOLD, "Price is stale");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!