Stratax Contracts

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

Oracle Missing Stale Price Validation

Author Revealed upon completion
  1. Summary: The StrataxOracle contract accepts stale price data from Chainlink feeds without validation. This allows the protocol to execute critical calculations using outdated market rates, which can lock the single-owner out of unwinding positions during volatility.

  2. Severity: High

  3. Affected Component:

    • File: src/StrataxOracle.sol

    • Function: getPrice

    • Source Confirmation: The function calls latestRoundData() but ignores the updatedAt return value:

      // src/StrataxOracle.sol:70
      (, int256 answer,,,) = priceFeed.latestRoundData(); // <--- updatedAt is discarded
      require(answer > 0, "Invalid price from oracle");
      price = uint256(answer);
  4. Root Cause: Methodical oversight in the Oracle integration. The developer assumed latestRoundData always returns fresh data, neglecting to check the updatedAt timestamp against a heartbeat threshold.

  5. Impact and Transaction Trace:
    Context: Stratax is a single-owner protocol where only the deployer can manage positions.
    Scenario:

    • The Owner holds a leveraged position: Collateral = ETH, Debt = USDC.

    • State: Market crashes. ETH price drops from $2000 to $1000.

    • Oracle: The Chainlink ETH feed stops updating (latency or DoS), freezing at $2000 (Stale Price).

    • Action: The Owner attempts to call unwindPosition to close the trade and stop losses.

    • Execution Trace:

      1. Stratax.unwindPosition calls _executeUnwindOperation.

      2. Contract calls calculateCollateralToWithdraw.

      3. StrataxOracle.getPrice returns the stale $2000 for ETH.

      4. Calculation: collateralToWithdraw = Debt / $2000.

      5. Result: The contract withdraws only 0.5x the amount of ETH actually needed to cover the debt (because real price is $1000).

      6. The contract swaps this insufficient ETH for USDC.

      7. The swap returns roughly 50% of the required USDC.

      8. The check require(returnAmount >= totalDebt) fails and reverts.

        Source Confirmation:

        // src/Stratax.sol:588
        require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");
    • Outcome: The Owner is locked out of closing their position because the contract math relies on the wrong price. As the real price continues to drop, the position gets liquidated by Aave, causing total loss of collateral, purely because the Stratax contract refused to calculate the correct withdrawal amount.

  6. PoC (End-to-End Verified):
    This PoC simulates the exact ETH/USD scenario described in the impact trace. It uses the WETH_PRICE_FEED constant and simulates a 2-hour old price update (exceeding the standard 1-hour heartbeat).

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {StrataxOracle} from "../src/StrataxOracle.sol";
import {ConstantsEtMainnet} from "./Constants.t.sol";
contract OraclePoC_E2E_Real is Test, ConstantsEtMainnet {
StrataxOracle public oracle;
function setUp() public {
vm.warp(2000000000);
oracle = new StrataxOracle();
// Use ETH Feed to match the report narrative
vm.mockCall(WETH_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
oracle.setPriceFeed(WETH, WETH_PRICE_FEED);
}
function test_E2E_Confirm_Price_Staleness_ETH() public {
// 1. Setup Mock for ETH Feed (Simulating Real Environment)
// Heartbeat for ETH/USD is typically 3600s (1 hour)
uint80 roundId = 1;
int256 stalePrice = 2000e8; // $2000
// 2. Set updatedAt to 2 hours ago (7200s), definitely stale
uint256 updatedAt = block.timestamp - 7200;
uint256 startedAt = updatedAt;
// Mock the Chainlink Aggregator call for ETH
vm.mockCall(
WETH_PRICE_FEED,
abi.encodeWithSelector(0xfeaf968c), // latestRoundData selector
abi.encode(roundId, stalePrice, startedAt, updatedAt, roundId)
);
// 3. Execution: Call Oracle
uint256 price = oracle.getPrice(WETH);
// 4. Verification: The Oracle accepts the stale price
assertEq(price, 2000e8, "Oracle accepted price older than heartbeat");
}
}
  1. Verification Evidence:
    The following is the actual terminal output from the test execution. Note that this test runs in a mocked environment (as indicated by the gas usage and lack of fork initialization logs), which is sufficient to prove the logic flaw in the StrataxOracle contract itself.

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PoC_Oracle_E2E_Real.t.sol:OraclePoC_E2E_Real
[PASS] test_E2E_Confirm_Price_Staleness_ETH() (gas: 15512)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 572.87µs (102.40µs CPU time)
Ran 1 test suite in 8.40ms (572.87µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
  1. Recommended Fix:
    Add a configurable heartbeat mapping. To ensure backward compatibility, setPriceFeed sets a default heartbeat (24h) if none exists.

    ⚠️ Warning: The default 24h heartbeat is unsafe for volatile assets like ETH (which requires 1h). Owners MUST call setHeartbeat manually for volatile tokens after setting the feed, or the vulnerability will persist for those assets (allowing up to 23h of staleness).

--- src/StrataxOracle.sol
+++ src/StrataxOracle.sol
@@ -10,0 +11 @@
+ mapping(address => uint256) public heartbeats;
@@ -30,6 +32,15 @@
function setPriceFeed(address _token, address _priceFeed) external onlyOwner {
_setPriceFeed(_token, _priceFeed);
+ // Set default heartbeat (24h) if not set to ensure safety without breaking existing calls
+ if (heartbeats[_token] == 0) {
+ heartbeats[_token] = 24 hours;
+ }
emit PriceFeedUpdated(_token, _priceFeed);
}
+ function setHeartbeat(address _token, uint256 _heartbeat) external onlyOwner {
+ require(_token != address(0), "Invalid token");
+ require(_heartbeat > 0, "Invalid heartbeat");
+ heartbeats[_token] = _heartbeat;
+ }
+
function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
@@ -70,6 +83,11 @@
- (, int256 answer,,,) = priceFeed.latestRoundData();
+ (, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
+
+ uint256 heartbeat = heartbeats[_token];
+ require(heartbeat > 0, "Heartbeat not set");
+ require(block.timestamp - updatedAt <= heartbeat, "Price is stale");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!