Skip to content

[BUG BOUNTY] Median outlier defense defeated with <=2 active feeds: one corrupt price source gains unbounded upward price control #907

Description

@jackbone222

Vulnerability Description

The oracle-hub's outlier defense is ineffective when an asset has exactly two active price feeds. The median-of-all-quotes reference (used for outlier filtering) picks the corrupt quote itself when the corrupt quote is the higher one, the honest quote is dropped as an "outlier", and the corrupt price is returned as the sole surviving quote with no further validation. With the Median strategy and two feeds, the reported price is provably max(q1, q2) - upward manipulation succeeds at any magnitude.

Relevant code, all in contracts/oracle-hub/src/:

// aggregation.rs:44-46 — median_of returns the UPPER median for even counts
fn median_of(sorted: &Vec<i128>) -> i128 {
    sorted.get(sorted.len() / 2).unwrap()
}
// aggregation.rs:67-79 — outlier reference is the median of ALL quotes (unfiltered)
let reference = median_of(&sorted_prices(env, quotes));
// quote kept iff deviation_bps(q.price, reference) <= 2000 bps (20%)
// aggregation.rs:110 — final median again takes the upper element
sorted.get(sorted.len() / 2)
// aggregation.rs:192-207 — if one quote survives, it is returned unvalidated
if n == 1 {
    let q = quotes.get(0).unwrap();
    return Ok((q.price, q.confidence, q.timestamp));
}

Aggregation iterates only the three priority slots 0..=2 (lib.rs:418), so two active feeds is a fully supported configuration; staleness auto-disable (lib.rs:452-457) routinely drops assets from 3 to 2 active feeds.

Attack scenario

Asset X has exactly 2 active feeds. Honest feed reports 100. Corrupt feed reports 1000.

  1. filter_quotes: sorted = [100, 1000]; reference = upper median = 1000 (the corrupt quote itself).
  2. Honest quote deviation = 900 * 10000 / 1000 = 9000 bps > 2000 bps -> honest quote discarded as "outlier".
  3. Corrupt quote deviation = 0 -> kept. kept.len() == 1 -> corrupt price 1000 returned unvalidated.

Verified simulation results (faithful port of the algorithm):

Honest, corrupt Reference (upper median) Returned price Winner
(100, 1000) corrupt +900% 1000 1000 corrupt (honest dropped)
(1000, 1100) corrupt +10% 1100 1100 corrupt
(1000, 1001) corrupt +0.1% 1001 1001 corrupt
(1000, 100) corrupt -90% 1000 1000 honest (corrupt dropped)
(100, 100, 1000) 3 feeds 100 100 robust

With 2 feeds the corrupt price wins at any upward magnitude - unbounded. Downward lies fail; with 3+ feeds the defense works. The repo's own test (aggregation_test.rs:84-86) codifies the behavior: a 2-feed median returns the higher quote.

Impact

A single corrupt/compromised price source (the exact scenario the median design is documented to resist: "Median (default, robust to a corrupt feed)" per README.md:15) gains unbounded upward price control for any asset with exactly 2 active feeds, and the honest quote is silently discarded. Consumed by a lending protocol this is the canonical oracle-manipulation loss: inflate collateral value to over-borrow, or manipulate an asset price to extract value. No deviation-vs-previous-price cap, no minimum-surviving-quotes requirement, and no sanity bounds exist anywhere in the hub.

Severity Self-Assessment

High. No privileged prerequisite beyond one registered feed source turning adversarial, no compensating safeguard, and the 2-feed configuration is reachable through normal operation.

Steps to Reproduce

  1. Register two feeds for one asset (or wait for a third feed to be auto-disabled by staleness).
  2. Have the honest feed report a normal price (e.g., 100).
  3. From the second (corrupt/adversarial) feed, report any arbitrarily larger price (e.g., 1000, 10000, or any positive i128).
  4. Call get_price for the asset. The returned price equals the corrupt quote; the honest quote was dropped as an outlier.

Proof of Concept

// honest feed reports 100
oracle.report_price(&honest_oracle, &asset, 100, confidence, &ts);
// corrupt feed reports 1000
oracle.report_price(&corrupt_oracle, &asset, 1000, confidence, &ts);

let price = oracle.get_price(&asset);   // returns 1000
// honest quote 100 was filtered as >2000 bps away from reference 1000

Proposed Remediation

Use the lower median or a trimmed/trimmed-mean reference for outlier filtering, require at least 2 (or 3) quotes to survive filtering before returning a price, and/or cap a single surviving quote against the previous aggregate price (deviation cap). Mirror the min_sources + deviation breakers already present in the repo's other oracle module (contracts/hello-world/src/oracle.rs).

Researcher Information

  • Name/Handle: jackbone222
  • Email/Contact: reachable via this issue
  • ETH Wallet Address: 0xabee1b0ec0fc342d7162caa8a137dbe51289045d

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions