Stratax Contracts

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

Missing Chainlink Oracle Staleness Checks in StrataxOracle

Author Revealed upon completion

Root + Impact

The function retrieves prices from Chainlink without checking if the data is stale. This means the protocol could be making critical leverage and liquidation decisions based on outdated price information, potentially causing users to get liquidated or lose funds.

Description

Looking at the oracle implementation in StrataxOracle.sol, I noticed the getPrice() function only grabs the answer value from Chainlink and ignores everything else:

// Root cause in the codebase with @> marks to highlight the relevant section
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);
//@> Problem: We're only grabbing 'answer' and throwing away the other 4 values
//@> latestRoundData returns: (roundId, answer, startedAt, updatedAt, answeredInRound)
(, int256 answer,,,) = priceFeed.latestRoundData(); //Only using answer!
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}

The problem is that latestRoundData() actually returns 5 values:

  • roundId - The round ID

  • answer - The actual price

  • startedAt - When the round started

  • updatedAt - When this price was last updated

  • answeredInRound - The round where this answer was computed

The code completely ignores updatedAt and answeredInRound, which are critical for detecting stale prices. According to Chainlink's own documentation, you should always check these values to ensure the price is recent enough for your application.

Risk

Likelihood:

  • Chainlink Price Feeds Regularly Experience Staleness Windows

    • Chainlink oracles update based on two triggers: deviation threshold (typically 0.5-1%) or heartbeat interval (time-based). For major pairs like ETH/USD and BTC/USD on Ethereum mainnet, the heartbeat is 3600 seconds (1 hour). During periods of low volatility - which happens regularly overnight, on weekends, or during ranging markets - the price deviation threshold isn't met. The oracle sits dormant until the full heartbeat interval passes, creating guaranteed staleness windows of 30-60 minutes multiple times per week. Altcoin feeds with 24-hour heartbeats create even longer staleness windows. Every time the protocol uses getPrice() during these periods, it retrieves outdated price data.

  • User Transactions Naturally Occur During Staleness Windows

    • Users don't check oracle update timestamps before interacting with the protocol - they simply call createLeveragedPosition() or unwindPosition() based on current market conditions they see on exchanges like Coinbase or Binance. When market prices move 3-5% (common daily volatility in crypto) while the Chainlink feed hasn't updated yet, users unknowingly create positions using prices that are materially different from reality. This happens organically without any malicious intent - a user sees ETH at $3,000 on Binance, calls the protocol expecting calculations based on $3,000, but gets calculations based on $2,700 because that's what Chainlink last reported 45 minutes ago. The timing collision between user transactions and oracle staleness happens routinely in normal operations.

Impact:

  • Impact 1: Users Get Liquidated Shortly After Opening Positions They Thought Were Safe

    • Here's what happens to an actual user: Alice checks Coinbase and sees ETH is trading at $3,000. She decides to open a 2x leveraged position with her 10 ETH, thinking she's being conservative - 2x leverage should give her plenty of room before liquidation. She calls createLeveragedPosition() and the transaction goes through successfully. Everything looks fine.

      But here's the problem - the Chainlink oracle hasn't updated in 45 minutes and still shows ETH at $2,700. So when the protocol calculates how much Alice can borrow, it thinks her 10 ETH is only worth $27,000 instead of the actual $30,000. The math goes wrong from there: she gets approved for $20,520 in borrowing instead of the $22,800 she should get for proper 2x leverage.

      Alice has no idea this happened. Her transaction succeeded, she sees her position is open, and everything appears normal. But her position isn't actually the safe 2x leverage she intended - the parameters are off.

      Ten minutes later, the oracle finally updates to $3,000. Now her position shows different numbers than when she opened it. Then ETH drops 6% (totally normal daily movement) to $2,820. Alice isn't worried because she knows 2x leverage should handle a 6% drop easily. But because her position was created with wrong calculations, her health factor drops below 1.0. Liquidation bot immediately swoops in and liquidates her position. Alice just lost $2,500 to liquidators plus gas fees - roughly 5% of her entire position - on what she thought was a conservative trade. She didn't do anything wrong, she just got unlucky with oracle timing.

  • Impact 2: Users Can't Exit Their Positions When They Want To

    • Now imagine Bob has been running a profitable leveraged ETH position for a few weeks. ETH has gone up nicely and he decides it's time to take profits and close the position. He calls unwindPosition() to exit.

      The transaction reverts. Bob tries again with different parameters. Reverts again. He's confused - his position is healthy, his health factor is 2.0, everything should work. What he doesn't realize is that the oracle price is stale again.

      The protocol is trying to withdraw collateral based on outdated prices. It calculates that to repay his debt, it needs to withdraw more ETH than is safe to withdraw. Aave's safety checks prevent withdrawing that much because it would drop his health factor too low. So Bob is just... stuck. He can't exit his position even though he wants to.

      This is incredibly frustrating from a user perspective. Bob sees ETH at the perfect price to take profits, but the protocol won't let him exit. He has a few bad options: wait hours for the oracle to update and hope the price hasn't moved against him, try to manually adjust his position (risky and expensive in gas), or just stay in the leveraged position and accept the ongoing risk. If ETH suddenly dumps while he's stuck, he could face liquidation on a position he was actively trying to close. That's a terrible user experience and a direct financial risk that shouldn't exist.

      The same issue happens in reverse - sometimes the stale price makes it look like he can withdraw less collateral than he actually should be able to. So Bob closes his position but doesn't get back as much ETH as he deserves. He just unknowingly lost money to the oracle staleness issue. There's no error message, no warning - he just gets less value back and might not even notice until he checks his wallet balance closely.

Proof of Concept

This vulnerability occurs because StrataxOracle.getPrice() retrieves price data from Chainlink's latestRoundData() function but only validates that the price answer is greater than zero. It completely ignores the timestamp data that indicates when the price was last updated.

The exploit happens naturally without malicious intent - users simply interact with the protocol during periods when Chainlink's price hasn't updated recently. Since Chainlink feeds update based on either price deviation (0.5-1%) or time intervals (heartbeat), there are regular windows where prices can be minutes or even hours old during low volatility periods.

// The bug is in StrataxOracle.sol lines 64-70
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);
//@> Problem: We're only grabbing 'answer' and throwing away the other 4 values
//@> latestRoundData returns: (roundId, answer, startedAt, updatedAt, answeredInRound)
(, int256 answer,,,) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}
// Real-world exploit scenario:
// It's Sunday morning, markets are quiet
// Chainlink ETH/USD updated 50 minutes ago when ETH was $2,700
// ETH has since climbed to $3,000 on CEXs but oracle hasn't updated yet
// Alice sees ETH at $3,000 and wants 2x leverage with 10 ETH
// She calls createLeveragedPosition()
// What happens inside calculateOpenParams() at line 395:
details.collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(WETH);
// getPrice() returns 270000000000 ($2,700 with 8 decimals)
// This price is 50 minutes stale but nobody checks!
// Line 413 - calculating collateral value with STALE price:
uint256 totalCollateralValueUSD = (25 ether * 270000000000) / 1e18;
// Result: 6750000000000 ($67,500 with 8 decimals)
// But Alice's 25 ETH is actually worth $75,000 at the real $3,000 price
// Line 418 - calculating borrow amount based on WRONG collateral value:
uint256 borrowValueUSD = (6750000000000 * 8000 * 9500) / (10000 * 10000);
// Result: 5130000000000 ($51,300 worth of USDC)
// Should be: ($75,000 * 0.8 * 0.95) = $57,000
// Alice gets approved to borrow ~$5,700 LESS than she should
// Her position opens successfully with wrong parameters
// 10 minutes later, oracle updates to $3,000
// Alice's position now has different risk profile than she intended
// Later that day, ETH drops 8% to $2,760 (normal volatility)
// With correct 2x leverage, this should be totally safe
// But because her position was created with stale prices, the math is off
// Health factor calculation:
// Collateral value: 25 ETH * $2,760 = $69,000
// Debt: $51,300
// Health factor: ($69,000 * 0.8) / $51,300 = 1.07
// Another 3% drop to $2,677:
// Collateral: 25 ETH * $2,677 = $66,925
// Health factor: ($66,925 * 0.8) / $51,300 = 1.04
// One more 2% dip to $2,623:
// Health factor drops below 1.0 → LIQUIDATED
// Alice loses 5-10% to liquidation penalty = $3,000 - $6,000
// All because the oracle was stale when she opened the position
// She had no way to know this was happening

Recommended Mitigation

The root cause is that getPrice() doesn't validate price freshness. We need to add three critical checks that Chainlink's documentation explicitly recommends:

  1. Staleness Check: Verify the price was updated recently by comparing updatedAt timestamp against the feed's known heartbeat interval

  2. Round Completeness Check: Ensure answeredInRound >= roundId to confirm the price is from the current round, not carried over from a previous one

+ // First, let's track what heartbeat each token should have
+ mapping(address => uint256) public heartbeats;
// Update the setPriceFeed function to require heartbeat info
+ function setPriceFeed(address _token, address _priceFeed, uint256 _heartbeat) external onlyOwner {
- function setPriceFeed(address _token, address _priceFeed) external onlyOwner {
require(_token != address(0), "Invalid token address");
require(_priceFeed != address(0), "Invalid price feed address");
+ require(_heartbeat > 0 && _heartbeat <= 86400, "Invalid heartbeat");
AggregatorV3Interface priceFeed = AggregatorV3Interface(_priceFeed);
require(priceFeed.decimals() == 8, "Price feed must have 8 decimals");
priceFeeds[_token] = _priceFeed;
+ heartbeats[_token] = _heartbeat; // Store the expected update frequency
emit PriceFeedUpdated(_token, _priceFeed);
}
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);
+ // Actually grab all the data instead of throwing most of it away
+ (
+ uint80 roundId,
+ int256 answer,
+ uint256 startedAt,
+ uint256 updatedAt,
+ uint80 answeredInRound
+ ) = priceFeed.latestRoundData();
- (, int256 answer,,,) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
+ require(updatedAt > 0, "Round not complete");
+ require(answeredInRound >= roundId, "Stale answer");
+
+ // This is the key check - make sure the price isn't too old
+ uint256 heartbeat = heartbeats[_token];
+ 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!