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:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- 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):
// 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.
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:
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
// 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.
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:
TokenisableRangemakes use of theLiquidityAmounts.getAmountsForLiquidityhelper function in itsreturnExpectedBalanceWithoutFees,getTokenAmountsExcludingFeesanddepositfunctions 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
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:
function testPocReturnExpectedBalanceUnderflow() public {
vm.
- Remediation Recommendation: Restore the original FullMath.sol library so it compiles with solc versions earlier than 0.8.0.
// 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
AuraVaultcontract inherits OpenZeppelin’sAccessControlcontract to implement role-based access control. The issue is that theAuraVaultcontract does not call the_setupRole()function in it’s constructor to set theDEFAULT_ADMIN_ROLE,VAULT_ADMIN_ROLEorVAULT_CONFIG_ROLEroles.
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:
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
AuraVaultconstructor as follows:
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:
// 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:
// 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
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 tofalseonce CCIP update is received on L2. Whenslashed= true, prevent all deposits and withdrawals on the strategy until the CCIP update by overriding thecanDepositandcanWithdrawfunctions inL2Strategyas follows:
// @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:
/// @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:
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.