# Solidity Oracle Manipulation

> Audit smart contracts for spot price flash loan manipulation, stale Chainlink price feeds, unhandled sequencer downtime on L2s, min/max circuit breaker bounds, and multi-oracle divergence. Derived from 390+ audit findings in Zaevlad/audit-findings-dataset.

- Skill: `netvar1337/solidity-oracle-manipulation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-oracle-manipulation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-oracle-manipulation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-oracle-manipulation

---


# Solidity Oracle Manipulation & Price Staleness Vulnerabilities

## Overview & Empirical Grounding
This security auditing skill is synthesized from **393 empirical audit contest findings** (54 Critical, 339 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Oracles & Pricing** patterns.

### Key Trigger Keywords
`oracle`, `chainlink`, `twap`, `spot price`, `roundid`, `sequencer`, `staleness`, `getrounddata`, `latestrounddata`, `flash loan oracle`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: LibUsdOracle will compromise Beanstalk peg due to wrong price and DoS
- **Severity**: High (Dataset Weight: 1.00, ID: #3536)
- **Vulnerability Mechanism**:
The function getUsdPrice from LibUsdOracle should return the token value in USD. For example, one of its consumers is the function getMintFertilizerOut:

fertilizerAmountOut = tokenAmountIn.div(LibUsdOracle.getUsdPrice(barnRaiseToken));

The goal of this function is to return the fertilizer amount with 0 decimals, therefore if the WETH value is at $1000 and the user would provide 1 WETH:

tokenAmountIn = 1e18.div(1e15) = 1e3 = 1000

Another critical use case for getUsdPrice is: fetching the ratios to calculate the deltaB.

```solidity
function getRatiosAndBeanIndex(
        IERC20[] memory tokens,
        uint256 lookback
    ) internal view returns (uint[] memory ratios, uint beanIndex, bool success) {
        success = true;
        ratios = new uint[](tokens.length);
        beanIndex = type(uint256).max;
        for (uint i; i < tokens.length; ++i) {
            if (C.BEAN == address(tokens[i])) {
                beanIndex = i;
                ratios[i] = 1e6;
            } else {
                ratios[i] = LibUsdOracle.getUsdPrice(address(tokens[i]), lookback); // @audit expect value return in USD
                if (ratios[i] == 0) {
                    success = false;
    

#### Proof of Concept / Vulnerable Pattern
```solidity
For the incorrect price:

Add the following test on oracle.t.sol

```solidity
function testgetUsdPricewhenExternalToken_priceIsInvalid() public {
        // pre condition: encode type 0x01 

        // WETH price is 1000
        uint256 priceWETH = OracleFacet(BEANSTALK).getUsdPrice(C.WETH);
        assertEq(priceWETH, 1e15);  //  1e18/1e3 = 1e15

        // WBTC price is 50000
        uint256 priceWBTC = OracleFacet(BEANSTALK).getUsdPrice(WBTC);
        assertEq(priceWBTC, 2e13); // 1e24.div(50000e6) = 2e13
    }
```

Run: forge test --match-test testgetUsdPricewhenExternalToken_priceIsInvalid

Output:

```javascript
Failing tests:
Encountered 1 failing test in test/foundry/silo/oracle.t.sol:OracleTest
[FAIL. Reason: assertion failed: 50000000000 != 20000000000000] testgetUsdPricewhenExternalToken_priceIsInvalid() (gas: 64594)
```

For the DoS when Chainlink oracle returns 0:
Add this test on oracle.t.sol

```solidity
// first - import {MockChainlinkAggregator} from "contracts/mocks/chainlink/MockChainlinkAggregator.sol";

function testgetUsdPricewillDoS_whenOracleFail() public { 
        // pre condition: encode type 0x01 and oracle fail
        MockChainlinkAggregator(WBTCUSDCHA
```

- **Remediation Recommendation**:
To fix both issues: On getTokenPriceFromExternal return the price from Chainlink directly, instead of scaling it.

```diff
return LibChainlinkOracle.getTokenPrice(
                        chainlinkOraclePriceAddress,
                        LibChainlinkOracle.FOURHOURTIMEOUT,
                        lookback
                    );
```

---
### Case Study: Incorrect Solidity version in FullMath.sol can cause permanent freezing of assets for arithmetic underflow-induced revert
- **Severity**: High (Dataset Weight: 1.00, ID: #19176)
- **Vulnerability Mechanism**:
`TokenisableRange` makes use of the `LiquidityAmounts.getAmountsForLiquidity` helper function in its [`returnExpectedBalanceWithoutFees`](https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L338), [`getTokenAmountsExcludingFees`](https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L371C31-L371C31) and [`deposit`](https://github.com/code-423n4/2023-08-goodentry/blob/71c0c0eca8af957202ccdbf5ce2f2a514ffe2e24/contracts/TokenisableRange.sol#L240C18-L240C18) functions to convert UniswapV3 pool liquidity into estimated underlying token amounts.

This function `getAmountsForLiquidity` will trigger an arithmetic underflow whenever `sqrtRatioX96` is smaller than `sqrtRatioAX96`, causing these functions to revert until this ratio comes back in range and the math no longer overflows.

Such oracle price conditions are not only possible but also likely to happen in real market conditions, and they can be permanent (i.e. one asset permanently appreciating over the other one).

Moving up the stack, assuming that `LiquidityAmounts.getAmountsForLiquidity` 

#### Proof of Concept / Vulnerable Pattern
```solidity
I’ll prove that permanent freezing can happen in two steps:

* first I’ll show one condition where the underflow happens
* then, I’ll set up a fuzz test to prove that given an A and B ticker, we cannot find a market price lower than A such that the underflow does not happen

The most simple way to prove the first point is by calling `LiquidityAmounts.getAmountsForLiquidity` in isolation with real-world values:
```solidity
function testGetAmountsForLiquidityRevert() public {
    // real-world value: it's in fact the value returned by
    // V3_FACTORY.getPool(USDC, WETH, 500).slot0();
    // at block 17811921; it is around 1870 USDC per WETH
    uint160 sqrtRatioX96 = 1834502451234584391374419429242405;

    // start price and end corresponding to 1700 to 1800 USDC per WETH
    uint160 sqrtRatioAX96 = 1866972058592130739290643700340936;
    uint160 sqrtRatioBX96 = 1921904167735311150677430952623492;

    vm.expectRevert();
    LiquidityAmounts.getAmountsForLiquidity(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, 1e18);
}
```
However, a more integrated test that involves PositionManager can also be considered:
```solidity
function testPocReturnExpectedBalanceUnderflow() public {
    vm.
```

- **Remediation Recommendation**:
Restore [the original FullMath.sol library](https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/FullMath.sol) so it compiles with solc versions earlier than 0.8.0.
```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.8.0;

/// @title Contains 512-bit math functions
/// @notice Facilitates multiplication and division that can have overflow of an intermediate value without any loss of precision
/// @dev Handles "phantom overflow" i.e., allows multiplication and division where an intermediate value overflows 256 bits
```
Another possible option, which is however not recommended, is to enclose the non-assembly statements of FullMath.sol in an `unchecked` block.

There’s an error with those lib versions, and we will replace with libs from the [0.8 branch](http

---
### Case Study: `AuraVault` inherits `AccessControl` BUT does not call the `_setupRole
- **Severity**: High (Dataset Weight: 1.00, ID: #21698)
- **Vulnerability Mechanism**:
The `AuraVault` contract inherits OpenZeppelin’s `AccessControl` contract to implement role-based access control. The issue is that the `AuraVault` contract does not call the `_setupRole()` function in it’s constructor to set the `DEFAULT_ADMIN_ROLE`, `VAULT_ADMIN_ROLE` or `VAULT_CONFIG_ROLE` roles.

Since this is not done in the constructor it is impossible to call `grantRole()` since it has the `onlyRole(getRoleAdmin(role))` modifier. It is important to note that no roles have been set; therefore, there is no address that can call this function to set roles. Therefore, it is impossible to set the `VAULT_ADMIN_ROLE` and `VAULT_CONFIG_ROLE` roles.

The minor impact is that the `setParameter()` function can never be called to change the `feed` or `auraPriceOracle` because it has the `onlyRole(VAULT_CONFIG_ROLE)` modifier. The critical impact is because `setVaultConfig()` function can never be called to initialize the `vaultConfig`.

The uninitialized `vaultConfig` struct will default all the variables to 0:
```solidity
struct VaultConfig {
    /// @notice The incentive sent to claimer (in bps)
    uint32 claimerIncentive;
    /// @notice The incentive sent to lockers (in bps)
    ui

#### Proof of Concept / Vulnerable Pattern
```solidity
Here is a fully coded POC with a mainnet fork:

1. Add the following `MockAuraPool.sol` to `/src/test/MockAuraPool.sol`:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IPool {
    function asset() external view returns (address);
    function balanceOf(address) external view returns (uint256);
    function deposit(uint256, address) external returns (uint256);
    function withdraw(uint256, bool) external;
    function withdraw(uint256, address, address) external;
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256);
    function getReward() external returns (bool);
    function extraRewardsLength() external view returns (uint256);
    function rewardToken() external view returns (address);
    function earned(address account) external view returns (uint256);
}

contract MockAuraPool is IPool, ERC20 {
    IERC20 public immutable _asset;
    IERC20 public immutable _rewardToken;
    mapping(address => uint256) public userRewards;

    constructor(address asset_, address 
```

- **Remediation Recommendation**:
Modify the `AuraVault` constructor as follows:
```solidity
constructor(
    address rewardPool_,
    address asset_,
    address feed_,
    address auraPriceOracle_,
    uint32 maxClaimerIncentive_,
    uint32 maxLockerIncentive_,
    string memory tokenName_,
    string memory tokenSymbol_,
    address vaultAdminRole,
    address vaultConfigRole

) ERC4626(IERC20(asset_)) ERC20(tokenName_, tokenSymbol_) {
    rewardPool = rewardPool_;
    feed = feed_;
    auraPriceOracle = auraPriceOracle_;
    maxClaimerIncentive = maxClaimerIncentive_;
    maxLockerIncentive = maxLockerIncentive_;

    _setupRole(VAULT_ADMIN_ROLE, vaultAdminRole);
    _setupRole(VAULT_CONFIG_ROLE, vaultConfigRole);
}
```

---
### Case Study: Slashing loss redistribution vulnerability allows existing depositors to avoid losses at new depositors' expense
- **Severity**: High (Dataset Weight: 1.00, ID: #22225)
- **Vulnerability Mechanism**:
The protocol's staking system spans L1 (Ethereum) and L2 (Metis) with state updates propagated via Chainlink CCIP. Current design is exposed to timing vulnerability where slashing events on Metis are not immediately reflected in the share price calculation on L2, allowing earlier depositors to exit at inflated share prices at the expense of new depositors.

The core issue stems from L2Strategy's total deposits calculation which depends on stale L1 state until a CCIP update is received:

```solidity
// L2Strategy.sol
function getTotalDeposits() public view override returns (uint256) {
    return l1TotalDeposits + tokensInTransitToL1 + tokensInTransitFromL1 + token.balanceOf(address(this));
}
```
When slashing occurs on Metis, it's visible in L1Strategy.getDepositChange() but not reflected in totalDeposits until updateDeposits is called via CCIP:

```solidity
// L1Strategy.sol
function getDepositChange() public view returns (int) {
    uint256 totalBalance = token.balanceOf(address(this));
    for (uint256 i = 0; i < vaults.length; ++i) {
        totalBalance += vaults[i].getTotalDeposits();
    }
    return int(totalBalance) - int(totalDeposits);
}
```
This can lead to a potential s

#### Proof of Concept / Vulnerable Pattern
```solidity
Run the following test. For this test, `allowInstantWithdrawals` is set to `true` on PriorityPool.

```solidity
  it('front running on metis', async () => {
    const {
      signers,
      accounts,
      l2Strategy,
      l2Metistoken,
      l2Transmitter,
      stakingPool,
      priorityPool,
      metisLockingInfo,
      metisLockingPool,
      l1Metistoken,
      l1Transmitter,
      l1Strategy,
      vaults,
      offRamp,
    } = await loadFixture(deployFixture)

    // // setup Bob and Alice
    const bob = signers[7]
    const bobAddress = accounts[7]
    const alice = signers[8]
    const aliceAddress = accounts[8]
    const initialDeposit = toEther(1000)

    await l2Metistoken.transfer(bobAddress, initialDeposit)
    await l2Metistoken.connect(bob).approve(priorityPool.target, ethers.MaxUint256)
    await stakingPool.connect(bob).approve(priorityPool.target, ethers.MaxUint256)

    // Bob makes initial deposit
    await priorityPool.connect(bob).deposit(initialDeposit, false, ['0x'])
    assert.equal(await fromEther(await l2Strategy.getTotalDeposits()), fromEther(initialDeposit))
    assert.equal(
      await fromEther(await l2Strategy.getTotalQueuedTokens()),
      fr
```

- **Remediation Recommendation**:
Consider the following recommendations:
- Disallow instant withdrawals on PriorityPool for the L2Strategy.
- Add a new owner controlled boolean state variable called `slashed`. Owner updates this variable to true as soon as slashing event occurs and resets it back to `false` once CCIP update is received on L2. When `slashed= true`, prevent all deposits and withdrawals on the strategy until the CCIP update by overriding the `canDeposit` and `canWithdraw` functions in `L2Strategy` as follows:

```solidity
  // @audit override in L2Strategy.sol
  function canWithdraw() public view override returns (uint256) {
        if(slashed) return 0; //@audit prevent withdrawals until slashing is updated on L2
        super.canWithdraw()
    }

    function canDeposit() public view override returns (uint

---
### Case Study: Arithmetic underflow in withdrawERC20 when there is a negative rebasing of asset tokens
- **Severity**: High (Dataset Weight: 1.00, ID: #23427)
- **Vulnerability Mechanism**:
The vault's accounting is based on the premise that the price of the underlying collateral will grow in perpetuity. While the underlying collateral has the lowest risks, capital is still susceptible to risk, especially when bonds are sold before maturity. For eg., if ONDO is ever forced to sell underlying bonds at market price and the bond's price is lower than the time they were purchased, that will cause a loss that will most likely mean a negative rebase on the price of USDY or oUSG.  

The vault tracks deposits using deposit-time pricing but calculates withdrawals using current market pricing. When prices decrease, the withdrawal calculation requires more tokens than the vault's accounting system has tracked as available.  

The consequences of a negative rebase (requiring more asset units for the same amount of USD) will impact the withdrawal flow when subtracting the withdrawAssetValue (and fee) from the VaultData.assetDepositNet, withdrawals will hit an underflow on that operation because the amount of asset units that will be discounted will be greater than the amount of asset units on the VaultData.assetDepositNet.  

```solidity
function withdrawERC20(
    address _to,
  

#### Proof of Concept / Vulnerable Pattern
```solidity
Add the following function to STBL_PT1_TestOracle:

```solidity
function decreasePriceByPercentage(uint256 basisPoints) external {
    require(basisPoints <= 9999, "Cannot decrease more than 99.99%");
    price = (price * (10000 - basisPoints)) / 10000;
}
```

Then add the following test to STBL_Test.sol:

```solidity
/// @notice Test deposit -> large price decrease -> attempted yield distribution -> withdrawal
/// @dev This tests accounting integrity under negative price movements and potential underflow
risks,!
function test_DepositWithdraw1PctPriceDecrease() public {
    console.log("=== 1% PRICE DECREASE TEST ===");
    // Get contracts
    STBL_PT1_TestToken assetToken = STBL_PT1_TestToken(getAssetToken(1));
    STBL_PT1_Issuer stblPT1Issuer = STBL_PT1_Issuer(getAssetIssuer(1));
    STBL_PT1_Vault stblPT1Vault = STBL_PT1_Vault(getAssetVault(1));
    STBL_PT1_TestOracle stblPT1TestOracle = STBL_PT1_TestOracle(getAssetOracle(1));
    STBL_PT1_YieldDistributor yieldDist = STBL_PT1_YieldDistributor(getAssetYieldDistributor(1));
    // === PHASE 0: ENSURE VAULT LIQUIDITY ===
    // Add significant extra tokens directly to vault to ensure liquidity
    // This prevents withdrawal fa
```

- **Remediation Recommendation**:
Consider modifying withdrawERC20 to prevent underflows by capping withdrawals to available accounting balance:

```solidity
function withdrawERC20(address _to, YLD_Metadata memory MetaData) external isValidIssuer {
    AssetDefinition memory AssetData = registry.fetchAssetData(assetID);
    uint256 withdrawAssetValue = iSTBL_PT1_AssetOracle(AssetData.oracle)
        .fetchInversePrice(
            ((MetaData.stableValueNet + MetaData.haircutAmount) - MetaData.withdrawfeeAmount)
        );
    uint256 withdrawFeeAssetValue = iSTBL_PT1_AssetOracle(AssetData.oracle)
        .fetchInversePrice(MetaData.withdrawfeeAmount);
    uint256 totalWithdrawal = withdrawAssetValue + withdrawFeeAssetValue;
    // @audit Cap withdrawal to available balance
    if (VaultData.assetDepositNet < totalWithdrawa

---
### Case Study: Lack of segregation between users' assets and
- **Severity**: High (Dataset Weight: 0.99, ID: #20198)
- **Vulnerability Mechanism**:
The users' assets are wrongly sent to the owner due to a lack of segregation between users' assets and collected fees, which might result in an irreversible loss of assets for the victims.
GLX uses the Chainlink Automation to execute the LimitOrderRegistry.performUpkeep function when there are orders that need to be fulfilled. The LimitOrderRegistry contract must be funded with LINK tokens to keep the operation running.
To ensure the LINK tokens are continuously replenished and funded, users must pay a fee denominated in Native ETH or ERC20 WETH tokens on orders claiming as shown below. The collected ETH fee will be stored within the LimitOrderRegistry contract.
/src/LimitOrderRegistry.sol#L696
File: LimitOrderRegistry.sol
```solidity
function claimOrder(uint128 batchId, address user) external payable returns (ERC20, uint256) {
    // ..SNIP..
    // Transfer tokens owed to user.
    tokenOut.safeTransfer(user, owed);
    // Transfer fee in.
    address sender = _msgSender();
    if (msg.value >= userClaim.feePerUser) {
        // refund if necessary.
        uint256 refund = msg.value - userClaim.feePerUser;
        if (refund > 0) sender.safeTransferETH(refund);
    } else {
    

- **Remediation Recommendation**:
Consider implementing one of the following solutions to mitigate the issue:
Solution 1 - Only accept Native ETH as fee
Uniswap V3 pool stored ETH as Wrapped ETH (WETH) ERC20 token internally. When the collect function is called against the pool, WETH ERC20 tokens are returned to the caller. Thus, the most straightforward way to mitigate this issue is to update the contract to collect the fee in Native ETH only.
In this case, there will be a clear segregation between users' assets (WETH) and owner's fee (Native ETH)
```solidity
function withdrawNative() external onlyOwner {
    uint256 nativeBalance = address(this).balance;
    // Make sure there is something to withdraw.
    if (nativeBalance == 0) revert LimitOrderRegistry__ZeroNativeBalance();
    // transfer nativeBalance if it exists
 


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

