NewQED x Commonware - read the announcement

Hijacking dYdX v4 oracle feeds

Authors

Yonghwi Jin

Category

Deep Dive

Published

May 6, 2026

Hijacking dYdX v4 oracle feeds

A string-comparison anomaly in dYdX v4’s permissionless market listing could silently re-route the oracle feed of an existing perpetual market to an attacker-controlled clone.

This vulnerability was responsibly disclosed to dYdX via their bug bounty program. The dYdX team triaged and shipped a fix that canonicalizes ticker strings before the duplicate check.

We found a critical vulnerability in dYdX v4 that enables any user with $10,000 USDC to manipulate the oracle price feed of a permissionlessly-listed perpetual market by registering a case-variant ticker (e.g. eigen-usd vs. EIGEN-USD). The consequences are:

  • A frozen oracle price on the victim market — liquidations stop firing, margin checks read stale collateral values, and bad debt accumulates against the insurance fund, and/or
  • A redirected price stream to the attacker’s clone market — opening up self-dealing and PnL manipulation against any existing positions.

This issue affects all dYdX v4 versions since MsgCreateMarketPermissionless was introduced (Jul 31, 2024), and became fully exploitable on Sep 16, 2024 (commit b7879b194, when currencyPairIDStore moved to a persistent store).

We encountered this bug while running QED against the dYdX v4 protocol repositories. After a brief triage, we determined that the issue was a contract mismatch between the deduplication logic in x/prices, the canonicalization logic in x/listing, and Skip’s MarketMap. The exploit did not require any privileged role: a single permissionless MsgCreateMarketPermissionless transaction is sufficient to silently overwrite the oracle routing entry for an existing market.


Part 1: Technical background

Permissionless market listing in dYdX v4

dYdX v4 supports permissionless perpetual market creation: any account that deposits a fixed amount of USDC into the MegaVault can submit MsgCreateMarketPermissionless with a ticker like FOO-USD and have the protocol spin up a new perp market wired to Skip’s slinky / MarketMap oracle.

The pipeline involves three modules:

  • x/listing — receives MsgCreateMarketPermissionless, looks up the ticker in MarketMap, and asks x/prices to create the underlying market.
  • x/prices — owns MarketParam (the ticker registry) and currencyPairIDStore (the canonical-pair → marketId map consumed by oracle update generation).
  • MarketMap (Skip) — the canonical source of truth for tickers, keyed in BASE/QUOTE form, e.g. EIGEN/USD.

Ticker forms

Two distinct string forms appear in the codebase, and the bug lives in the gap between them:

  • Raw form — the dash-separated user-supplied ticker, e.g. EIGEN-USD, stored verbatim in MarketParam.Pair.
  • Canonical form — the slash-separated upper-case form EIGEN/USD, produced by slinky.MarketPairToCurrencyPair and used as the key in currencyPairIDStore and MarketMap.

The conversion MarketPairToCurrencyPair() is case-insensitive on the way in and upper-case on the way out. That is the seam.

Part 2: The bug

In short, the bug manifests in two places:

  1. The duplicate-ticker check in x/prices.CreateMarket uses raw string equality.
  2. The canonical-pair store is keyed by the canonicalized ticker and unconditionally overwrites any pre-existing entry.

The following snippet is an abridged version of the vulnerable function, with the vulnerable branch marked [1].

x/prices/keeper/market.go (vulnerable)
func (k Keeper) CreateMarket(
ctx sdk.Context,
marketParam types.MarketParam,
marketPrice types.MarketPrice,
) (types.MarketParam, error) {
// ...
// Stateful Validation
for _, market := range k.GetAllMarketParams(ctx) {
if market.Pair == marketParam.Pair { // [1] raw comparison: "EIGEN-USD" != "eigen-usd"
return types.MarketParam{}, errorsmod.Wrap(
types.ErrMarketParamPairAlreadyExists,
marketParam.Pair,
)
}
}
// Case-variant ticker bypasses this check!
}

Duplicate check: raw string equality

When x/listing forwards a permissionless listing into x/prices.CreateMarket, the duplicate detection at [1] performs a byte-exact comparison between market.Pair and marketParam.Pair. Because the previously-listed market was stored as EIGEN-USD and the attacker submits eigen-usd, this check returns false and listing proceeds.

currencyPairIDStore: canonical key, no overwrite guard

In contrast, the oracle-routing store keys entries by the canonical form cp.String(), which is upper-case-normalized. Both EIGEN-USD and eigen-usd canonicalize to EIGEN/USD. The store does not check whether the key is already mapped before writing, so the attacker’s new marketId overwrites the legitimate market’s entry.

Part 3: Exploitation

Using these two oversights, an attacker submits a single MsgCreateMarketPermissionless carrying a case-variant of an existing ticker:

MsgCreateMarketPermissionless(ticker="eigen-usd")
├─► x/listing: slinky.MarketPairToCurrencyPair("eigen-usd") → "EIGEN/USD"
├─► MarketMap.GetMarket("EIGEN/USD") ✓ FOUND (canonical lookup)
├─► x/prices.CreateMarket(Pair="eigen-usd")
│ └─► duplicate check: "eigen-usd" == "EIGEN-USD"? ✗ FALSE (raw compare)
├─► AddCurrencyPairIDToStore(newId, "EIGEN/USD")
│ └─► currencyPairIDStore["EIGEN/USD"] = newId ⚠ overwrites originalId
└─► Market `eigen-usd` (id=newId) is now the oracle-update sink for EIGEN/USD

From the next block onward, the slinky price-update generator iterates the canonical pairs reported by the oracle, looks each one up in currencyPairIDStore, and emits MsgUpdateMarketPrices against the resolved marketId:

x/prices/keeper/slinky_adapter.go (overwrites without check)
func (k Keeper) AddCurrencyPairIDToStore(
ctx sdk.Context,
marketId uint32,
cp slinkytypes.CurrencyPair,
) {
currencyPairString := cp.String() // canonical: "EIGEN/USD"
currencyPairIDStore := k.getCurrencyPairIDStore(ctx)
value := gogotypes.UInt64Value{Value: uint64(marketId)}
b := k.cdc.MustMarshal(&value)
currencyPairIDStore.Set([]byte(currencyPairString), b) // overwrites existing mapping!
}

The original market never sees another price update. Every downstream consumer of MarketPrice — margin checks, liquidations, funding-rate computation, conditional-order matching, position-limit enforcement — silently begins reading a frozen value.

Part 4: Impact

This bug had two concrete impacts: it could freeze a victim market’s oracle and it could let an attacker steal from users and the insurance fund.

Freezing oracles

The immediate effect is a permanent oracle freeze on the victim market. Liquidations stop, and every downstream system that reads MarketPrice keeps operating on a stale mark. Underwater positions can accumulate bad debt until the insurance fund absorbs the loss.

Asset theft

This bug does not just freeze markets; it can also be used to steal funds. By redirecting the oracle feed to an attacker-controlled clone market while leaving the original market stale, an attacker can trade against users or the insurance fund using a market whose live oracle they control, while positions on the victim market continue to be risk-checked against an obsolete mark.

Exact exposure at the time of discovery: $1,206,459 of open interest.

  • Exposure: We counted every dYdX v4 mainnet market that could actually be permissionlessly re-listed through this code path on 2026-02-04. This set consisted of 181 markets, including XMR-USD ($253,892 OI), HYPE-USD ($209,920), PAXG-USD ($122,659), ZEC-USD ($84,925), and ENA-USD ($73,079), whose MarketMap entries carried metadata_JSON.

  • What was excluded: Major markets such as BTC-USD, ETH-USD, and SOL-USD were not included because their MarketMap entries lacked metadata_JSON and therefore could not be hijacked through the permissionless listing flow.

  • Attack cost: $10,000 USDC, the MegaVault deposit required to submit MsgCreateMarketPermissionless. The deposit is recoverable.

  • Profitability: This attack was economically attractive because a recoverable $10,000 deposit could put $1.2M of open interest at risk.

Part 5: Proof of concept

QED constructed an end-to-end PoC against a dYdX v4 localnet. QED spun up an N-validator cluster, funded a single attacker account with 10,000 USDC, and submitted one MsgCreateMarketPermissionless with a case-variant ticker; from that block onward, all oracle updates for the canonical pair flow to the attacker’s market while the original is left stale.

PoC setup and observed results

$ ./oracle_hijack_e2e.sh eigen EIGEN # [new market] [existing market]
Creating malicious market listing (eigen-usd)...
Listing tx: 111EF2612C40E2BF9787021D1131859F84A5CBA73EDEFA0F1A9EF30703385FEF
Waiting for tx 111EF2612C40E2BF9787021D1131859F84A5CBA73EDEFA0F1A9EF30703385FEF...
Transaction succeeded
Checking for deposit_to_megavault events...
{
"type": "deposit_to_megavault",
...
}
Checking market params for both markets...
{
"id": 33,
"pair": "EIGEN-USD",
"exponent": -9,
"min_exchanges": 1,
"min_price_change_ppm": 800,
"exchange_config_json": ""
}
{
"id": 36,
"pair": "eigen-usd",
"exponent": 0,
"min_exchanges": 0,
"min_price_change_ppm": 800,
"exchange_config_json": ""
}
...
EIGEN-USD marketId=33
eigen-usd marketId=36
Monitoring prices for 30s...
t=1770263154 EIGEN-USD=240250000 eigen-usd=0
t=1770263158 EIGEN-USD=240250000 eigen-usd=0
t=1770263161 EIGEN-USD=240250000 eigen-usd=0
t=1770263164 EIGEN-USD=240250000 eigen-usd=240250000 ← DIVERGENCE STARTS
t=1770263167 EIGEN-USD=240250000 eigen-usd=240250000
t=1770263170 EIGEN-USD=240250000 eigen-usd=240250000
...

Two market params now coexist with case-variant Pair strings (EIGEN-USD at id=33 and eigen-usd at id=36), and the oracle update generator routes every subsequent EIGEN/USD tick to id=36. The original market’s MarketPrice is frozen at 240,250,000 for the entire observation window, while the attacker’s market tracks the live feed. Any liquidation attempted against a position on market 33 would fail to trigger, since the mark price never moves.

Part 6: Fixes

dYdX patched the bug by canonicalizing the ticker before the duplicate check in x/prices.CreateMarket:

x/prices/keeper/market.go (patch)
// Stateful Validation
for _, market := range k.GetAllMarketParams(ctx) {
if market.Pair == marketParam.Pair {
if strings.EqualFold(market.Pair, marketParam.Pair) {
return types.MarketParam{}, errorsmod.Wrap(
types.ErrMarketParamPairAlreadyExists,
marketParam.Pair,
...
// Validate update is permitted.
for _, market := range k.GetAllMarketParams(ctx) {
if market.Pair == updatedMarketParam.Pair && market.Id != updatedMarketParam.Id {
if strings.EqualFold(market.Pair, updatedMarketParam.Pair) && market.Id != updatedMarketParam.Id {
return types.MarketParam{}, errorsmod.Wrap(types.ErrMarketParamPairAlreadyExists, updatedMarketParam.Pair)

The relevant fixes are:

  • x/prices.CreateMarket should canonicalize both sides via MarketPairToCurrencyPair (or strings.EqualFold) before comparing.
  • AddCurrencyPairIDToStore should return an error when the canonical key is already mapped, rather than silently overwriting.
  • Normalize the ticker to upper-case at the x/listing boundary as a fail-safe.

Part 7: Timeline

  • July 17, 2024x/prices.CreateMarket begins storing raw, non-canonicalized tickers.
  • July 31, 2024MsgCreateMarketPermissionless introduced; permissionless market creation enabled.
  • September 16, 2024 — Bug becomes exploitable: currencyPairIDStore moved to persistent storage (b7879b194).
  • February 5, 2026 — Vulnerability discovered and reported to dYdX.
  • March 16, 2026 — Vulnerability patched.

© 2026 QED Audit Inc.