Root cause:
The contract consumes Chainlink latestRoundData() but only validates answer > 0, without validating updatedAt freshness and without enforcing a maximum staleness window. As a result, outdated (stale) oracle responses can be treated as current.
Impact:
Any protocol logic relying on getPrice() (e.g., collateral valuation, minting/redemption, liquidations, leverage checks, fee calculations) can be executed using stale prices, leading to incorrect accounting and potential economic loss (unfair liquidations, under/over-collateralization, value extraction via timing/arbitrage when the market price diverges from the stale oracle price)
Description
-
getPrice() should return a recent and valid price from the configured Chainlink feed, failing safely when the feed is not updating or returns data outside an acceptable freshness window.
-
The contract only validates that answer > 0 when consuming latestRoundData() and does not verify whether the returned price is sufficiently recent. As a result, stale oracle data may be accepted and used as if it were fresh.
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);
}
Risk
Likelihood — Medium:
Oracle feeds periodically experience delayed updates due to heartbeat behavior, deviation thresholds, or transient network conditions. These normal operational windows create realistic opportunities for stale data to be consumed, although successful exploitation still requires favorable timing and market movement.
Impact — Medium:
Stale oracle prices directly influence leverage sizing, borrow calculations, and unwind math. Using outdated prices can lead to economically incorrect position parameters and value leakage, potentially causing measurable financial loss, though not enabling a guaranteed or immediate protocol drain.
Proof of Concept
Need a MockChainlinkAggregator
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import {StrataxOracle} from "../src/StrataxOracle.sol";
import {MockChainlinkAggregator} from "./mocks/MockChainlinkAggregator.sol";
contract StrataxOracleF05 is Test {
StrataxOracle oracle;
address token = address(0xBEEF);
MockChainlinkAggregator feed8;
MockChainlinkAggregator feed18;
function setUp() public {
oracle = new StrataxOracle();
feed8 = new MockChainlinkAggregator(8);
feed18 = new MockChainlinkAggregator(18);
oracle.setPriceFeed(token, address(feed8));
}
function test_F05_NegativeAnswer_shouldRevert() public {
feed8.setRoundData(
100,
0,
block.timestamp,
block.timestamp,
100
);
vm.expectRevert();
oracle.getPrice(token);
}
function test_F05_UpdatedAtZero_shouldRevert() public {
feed8.setRoundData(
100,
100_000000,
block.timestamp,
0,
100
);
vm.expectRevert();
oracle.getPrice(token);
}
}
Results:
forge test -vvv --match-contract StrataxOracleF05
[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/StrataxOracle_F05.t.sol:StrataxOracleF05
[PASS] test_F05_NegativeAnswer_shouldRevert() (gas: 32452)
[FAIL: next call did not revert as expected] test_F05_UpdatedAtZero_shouldRevert() (gas: 36969)
Traces:
[36969] StrataxOracleF05::test_F05_UpdatedAtZero_shouldRevert()
├─ [15068] MockChainlinkAggregator::setRoundData(100, 100000000 [1e8], 1, 0, 100)
│ └─ ← [Stop]
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [5623] StrataxOracle::getPrice(0x000000000000000000000000000000000000bEEF) [staticcall]
│ ├─ [1472] MockChainlinkAggregator::latestRoundData() [staticcall]
│ │ └─ ← [Return] 100, 100000000 [1e8], 1, 0, 100
│ └─ ← [Return] 100000000 [1e8]
└─ ← [Revert] next call did not revert as expected
Backtrace:
at StrataxOracleF05.test_F05_UpdatedAtZero_shouldRevert
Suite result: FAILED. 1 passed; 1 failed; 0 skipped; finished in 668.96µs (263.59µs CPU time)
This result is expected because the contract already enforces require(answer > 0), causing the negative-answer test to pass as intended. However, since updatedAt is not properly validated in the version, getPrice() does not revert, so the second test correctly fails and demonstrates the issue.
Recommended Mitigation
+uint256 public maxStaleness = 1 hours;
+// @optional: configurable freshness threshold to limit stale oracle data
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, , uint256 updatedAt, ) = priceFeed.latestRoundData();
- require(answer > 0, "Invalid price from oracle");
- price = uint256(answer);
+ (, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
+ // @added: read updatedAt to enforce oracle freshness
+
+ require(answer > 0, "Invalid price from oracle");
+ require(updatedAt != 0, "Invalid updatedAt");
+ // @added: basic timestamp sanity check
+
+ require(block.timestamp - updatedAt <= maxStaleness, "Stale price");
+ // @optional: enforces price freshness window to mitigate stale oracle reads
+
+ price = uint256(answer);
}
The contract should verify that the returned updatedAt value is non-zero when consuming latestRoundData(). The contract should enforce a freshness constraint by ensuring the elapsed time since updatedAt does not exceed a predefined maxStaleness threshold. This prevents the protocol from operating on outdated price data during oracle delays or heartbeat gaps.
Reference:
https://docs.chain.link/data-feeds/api-reference#latestrounddata
https://github.com/sherlock-audit/2023-05-USSD-judging/issues/500
https://codehawks.cyfrin.io/c/2023-12-the-standard/s/608