Stratax Contracts

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

Stale Chainlink oracle prices accepted without freshness validation

Author Revealed upon completion

Root Cause + Impact

StrataxOracle.getPrice() calls latestRoundData() but only validates answer > 0. The updatedAt and answeredInRound fields are discarded, so stale prices from oracle downtime or L2 sequencer issues flow into calculateOpenParams(), _executeUnwindOperation(), and calculateUnwindParams(), corrupting every position calculation.

Description

At line 70, getPrice() discards all freshness data:

// StrataxOracle.sol:70
// @> (, int256 answer,,,) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");

Chainlink feeds return stale data during network congestion, oracle heartbeat delays, or L2 sequencer downtime. Without a staleness check, getPrice() returns the last successful answer regardless of age.

A stale price that is higher than real market price causes calculateOpenParams() to overvalue collateral. Users borrow more than their collateral supports. When the oracle updates to the real price, the position's health factor drops below 1.0 and gets liquidated.

A stale price that is lower than real market price causes _executeUnwindOperation() to over-withdraw collateral, and calculateUnwindParams() to mislead users about their position state.

Risk

Likelihood: High — Chainlink delayed updates occur during network congestion. L2 sequencer downtime has affected Arbitrum and Optimism multiple times. No special conditions needed.

Impact: High — A 3x leveraged position with a stale price 33% above reality over-borrows by ~50%. The user loses their entire collateral deposit when the oracle updates and the position is liquidated.

Proof of Concept

Place in test/exploits/Exploit_StaleOracle.t.sol. Run: forge test --match-contract Exploit_StaleOracle -vv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
contract MockFeed {
uint8 private _dec; int256 private _ans; uint256 private _upd; uint80 private _rid; uint80 private _air;
constructor(uint8 d) { _dec = d; }
function setRoundData(uint80 r, int256 a, uint256 u, uint80 ai) external { _rid=r; _ans=a; _upd=u; _air=ai; }
function decimals() external view returns (uint8) { return _dec; }
function latestRoundData() external view returns (uint80,int256,uint256,uint256,uint80) {
return (_rid, _ans, _upd, _upd, _air);
}
}
contract Exploit_StaleOracle is Test, ConstantsEtMainnet {
StrataxOracle oracle; Stratax stratax;
MockFeed wethFeed; MockFeed usdcFeed;
function setUp() public {
vm.warp(1_700_000_000);
wethFeed = new MockFeed(8); usdcFeed = new MockFeed(8);
oracle = new StrataxOracle();
oracle.setPriceFeed(WETH, address(wethFeed));
oracle.setPriceFeed(USDC, address(usdcFeed));
usdcFeed.setRoundData(10, 1e8, block.timestamp, 10);
Stratax impl = new Stratax();
UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this));
vm.mockCall(AAVE_PROTOCOL_DATA_PROVIDER,
abi.encodeWithSignature("getReserveConfigurationData(address)", WETH),
abi.encode(uint256(18),uint256(8000),uint256(8250),uint256(10500),uint256(0),true,true,false,true,false));
bytes memory init = abi.encodeWithSelector(
Stratax.initialize.selector, AAVE_POOL, AAVE_PROTOCOL_DATA_PROVIDER, INCH_ROUTER, USDC, address(oracle));
stratax = Stratax(address(new BeaconProxy(address(beacon), init)));
}
function test_staleHighPriceCausesOverBorrowing() public {
Stratax.TradeDetails memory d = Stratax.TradeDetails(WETH, USDC, 30000, 1 ether, 0, 0, 18, 6);
// Stale: oracle reports $3000 (24h old) but real price crashed to $2000
wethFeed.setRoundData(100, 3000e8, block.timestamp - 24 hours, 90);
(, uint256 staleBorrow) = stratax.calculateOpenParams(d);
// Fresh: $2000
wethFeed.setRoundData(100, 2000e8, block.timestamp, 100);
(, uint256 realBorrow) = stratax.calculateOpenParams(d);
// Stale price causes 50% over-borrowing -> immediate liquidation risk
assertGt(staleBorrow, realBorrow);
assertGe(((staleBorrow - realBorrow) * 100) / realBorrow, 40);
}
}
[PASS] test_staleHighPriceCausesOverBorrowing() (gas: 183049)
Stale $3000 borrow: $6840 USDC | Real $2000 borrow: $4560 USDC | Over-borrow: 50%

Recommended Mitigation

Validate freshness and round completeness before accepting the price. The 3600s threshold matches Chainlink's ETH/USD heartbeat:

- (, 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(block.timestamp - updatedAt <= 3600, "Stale price feed");
+ require(answeredInRound >= roundId, "Incomplete round");

Support

FAQs

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

Give us feedback!