Stratax Contracts

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

[M] Oracle does not validate price freshness, allowing stale prices in unwind path and increasing failure and liquidation risk

Author Revealed upon completion

Description

getPrice validates only answer > 0 (and round relation) but does not enforce an acceptable updatedAt freshness window. The unwind execution path directly uses this oracle price for collateral withdrawal math, so stale prices can distort withdraw parameters and increase unwind failure probability.

// Root cause in the codebase with @> marks to highlight the relevant section
function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
//...
// Get prices and decimals
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset); // @> uses this oracle price without freshness checks
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(unwindParams.collateralToken);// @> uses this oracle price without freshness checks
}

Risk

Likelihood:

  • More likely during oracle update stalls, network issues, or extreme market conditions.

Impact:

  • The protocol may use stale prices in a critical unwind path, causing de-leveraging reverts or degraded execution; during sharp downturns, users become less able to reduce risk in time, increasing liquidation likelihoodProof of Concept.

Proof of Concept

  1. Configure a feed that returns a valid positive answer with significantly stale `updateAt`

  2. Trigger unwind and observe that stale price is still accepted and used in calculations.

  3. In boundry-risk positions, observe unwind failure or insufficient de-leveraging

// this is the poc
function test_POC_StaleManipulatedPricesCauseDirectUnwindRevert() public {
vm.warp(30 days);
vm.mockCall(USDC, abi.encodeWithSignature("decimals()"), abi.encode(uint8(6)));
vm.mockCall(WETH, abi.encodeWithSignature("decimals()"), abi.encode(uint8(18)));
uint256 debtAmount = 10 ether;
uint256 liqThreshold = 8000;
vm.mockCall(
AAVE_PROTOCOL_DATA_PROVIDER,
abi.encodeWithSignature("getReserveConfigurationData(address)", USDC),
abi.encode(uint256(6), uint256(8000), liqThreshold, uint256(10500), uint256(1000), true, true, false, true, false)
);
uint256 staleUpdatedAt = block.timestamp - 20 days;
MockConfigurablePriceFeed staleWethFeed =
new MockConfigurablePriceFeed(100, int256(4000e8), staleUpdatedAt, 100);
MockConfigurablePriceFeed staleUsdcFeed =
new MockConfigurablePriceFeed(200, int256(5e7), staleUpdatedAt, 200); // 0.5 USD
strataxOracle.setPriceFeed(WETH, address(staleWethFeed));
strataxOracle.setPriceFeed(USDC, address(staleUsdcFeed));
uint256 expectedWithdraw = (debtAmount * 4000e8 * (10 ** 6) * 1e4) / (5e7 * (10 ** 18) * liqThreshold);
vm.mockCall(WETH, abi.encodeWithSignature("approve(address,uint256)", AAVE_POOL, debtAmount), abi.encode(true));
vm.mockCall(
AAVE_POOL,
abi.encodeWithSignature("repay(address,uint256,uint256,address)", WETH, debtAmount, 2, address(stratax)),
abi.encode(debtAmount)
);
vm.mockCallRevert(
AAVE_POOL,
abi.encodeWithSignature("withdraw(address,uint256,address)", USDC, expectedWithdraw, address(stratax)),
abi.encodeWithSignature("Error(string)", "withdraw exceeds available collateral")
);
Stratax.UnwindParams memory unwindParams = Stratax.UnwindParams({
collateralToken: USDC,
collateralToWithdraw: 0,
debtToken: WETH,
debtAmount: debtAmount,
oneInchSwapData: bytes(""),
minReturnAmount: 0
});
bytes memory encodedParams = abi.encode(Stratax.OperationType.UNWIND, ownerTrader, unwindParams);
vm.prank(AAVE_POOL);
vm.expectRevert(bytes("withdraw exceeds available collateral"));
stratax.executeOperation(WETH, debtAmount, 0, address(stratax), encodedParams);
}

Recommended Mitigation

1.Add a variable and make it configurable

2.Add freshness validation in `getPrice()` in StrataxOracle.sol:

  • 'updateAt != 0'

  • 'answeredInRound >= roundId'

  • 'block.timestamp - updateAt <= MAX_STALE_TIME'

  • keep answer > 0

// StrataxOracle.sol
+ uint256 public immutable MAX_STALE_TIME;
constructor(uint256 _maxStaleTime){
+ require(_maxStaleTime > 0, "Invalid stale time");
//...
+ MAX_STALE_TIME = _maxStaleTime;
}
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");
// @> should check price freshness
+ require(updatedAt != 0 && block.timestamp - updatedAt <= MAX_STALE_TIME, "Price is stale");
+ require(answeredInRound >= roundId, "Price is stale");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!