Hijacking dYdX v4 oracle feeds
Authors
Yonghwi Jin
Category
Deep Dive
Published
May 6, 2026
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— receivesMsgCreateMarketPermissionless, looks up the ticker in MarketMap, and asksx/pricesto create the underlying market.x/prices— ownsMarketParam(the ticker registry) andcurrencyPairIDStore(the canonical-pair → marketId map consumed by oracle update generation).- MarketMap (Skip) — the canonical source of truth for tickers, keyed in
BASE/QUOTEform, 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 inMarketParam.Pair. - Canonical form — the slash-separated upper-case form
EIGEN/USD, produced byslinky.MarketPairToCurrencyPairand used as the key incurrencyPairIDStoreand 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:
- The duplicate-ticker check in
x/prices.CreateMarketuses raw string equality. - 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].
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/USDFrom 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:
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
MarketMapentries carriedmetadata_JSON. -
What was excluded: Major markets such as BTC-USD, ETH-USD, and SOL-USD were not included because their
MarketMapentries lackedmetadata_JSONand 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: 111EF2612C40E2BF9787021D1131859F84A5CBA73EDEFA0F1A9EF30703385FEFWaiting for tx 111EF2612C40E2BF9787021D1131859F84A5CBA73EDEFA0F1A9EF30703385FEF...Transaction succeededChecking 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=33eigen-usd marketId=36Monitoring prices for 30s...t=1770263154 EIGEN-USD=240250000 eigen-usd=0t=1770263158 EIGEN-USD=240250000 eigen-usd=0t=1770263161 EIGEN-USD=240250000 eigen-usd=0t=1770263164 EIGEN-USD=240250000 eigen-usd=240250000 ← DIVERGENCE STARTSt=1770263167 EIGEN-USD=240250000 eigen-usd=240250000t=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:
// 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.CreateMarketshould canonicalize both sides viaMarketPairToCurrencyPair(orstrings.EqualFold) before comparing.AddCurrencyPairIDToStoreshould return an error when the canonical key is already mapped, rather than silently overwriting.- Normalize the ticker to upper-case at the
x/listingboundary as a fail-safe.
Part 7: Timeline
- July 17, 2024 —
x/prices.CreateMarketbegins storing raw, non-canonicalized tickers. - July 31, 2024 —
MsgCreateMarketPermissionlessintroduced; permissionless market creation enabled. - September 16, 2024 — Bug becomes exploitable:
currencyPairIDStoremoved to persistent storage (b7879b194). - February 5, 2026 — Vulnerability discovered and reported to dYdX.
- March 16, 2026 — Vulnerability patched.