|
| 1 | +# Market.xyz — a lending market prices Curve LP collateral through `get_virtual_price()` and is drained by re-entering the pool's view during `remove_liquidity` — Market.xyz / QuickSwap (Polygon) — 2022-10-24 |
| 2 | + |
| 3 | +**Loss:** **~\$220K** of MAI stablecoin extracted from a **Market.xyz** lending market hosted on **QuickSwap** (Polygon). Reported figures vary by snapshot (~\$188K–\$220K). The drained MAI had been **seeded to the market by QiDAO**, so no end-user deposits were lost; the loss fell on the protocol-supplied liquidity. Two attack transactions netted the attacker roughly **138 ETH and 700 MATIC** after swaps. QuickSwap shut down its lending service in response. |
| 4 | + |
| 5 | +**OAK Techniques observed:** **OAK-T9.010** (Read-Only Reentrancy — *primary, confirmed mechanism*. Market.xyz priced Curve LP-token collateral by reading the Curve pool's `get_virtual_price()` view. The attacker re-entered that view while the pool was mid-execution inside `remove_liquidity`: the pool transfers native tokens to the caller before it finishes updating its accounting, so during that callback the LP-token supply had already decreased while the invariant `D` had not been re-settled, and `get_virtual_price()` returned an inflated value. Market.xyz consumed the inflated price and extended a loan against over-valued collateral, which the attacker withdrew. The Curve pool itself lost nothing — the `staticcall` read did not move its assets. See [`techniques/T9.010-read-only-reentrancy.md`](../techniques/T9.010-read-only-reentrancy.md)). **OAK-T9.002** (Flash-Loan-Enabled Exploit — the working capital: the position that made the transient mispricing worth ~\$220K was flash-loan-funded, with add-liquidity, the re-entrant read, the over-collateralised borrow, and repayment all inside one transaction. See [`techniques/T9.002-flash-loan-enabled-exploit.md`](../techniques/T9.002-flash-loan-enabled-exploit.md)). |
| 6 | + |
| 7 | +**Attribution:** **pseudonymous.** No named individual or group. On-chain identifiers from the public write-ups: attacker EOA `0x4206d62305d2815494dcdb759c4e32fca1d181a0` with exploit contract `0xeb4c67e5be040068fa477a539341d6aef081e4eb` (first transaction `0xb8efe839da0c89daa763f39f30577dc21937ae351c6f99336a0017e63d387558`), and a second EOA `0xe3671a41c44f50048e60939df3d26704c9652d9d` with contract `0xC6CD77fcf37ffcd97B3429F0284ecfF50616EA74`. The 138 ETH proceeds were bridged from Polygon to Ethereum and sent to Tornado Cash. The read-only-reentrancy trace path (a `staticcall` to `get_virtual_price()` while the pool's outer `remove_liquidity` frame had not returned) is reproducible from the transaction trace. |
| 8 | + |
| 9 | +**Key teaching point:** **A view function is only a safe price source if the contract it reads cannot be mid-execution at the moment you read it — and a Curve pool can be, because `remove_liquidity` hands control back to the caller before it settles.** Market.xyz's own lending logic was not reentrant and was not the flaw; the flaw was the cross-protocol trust assumption that `get_virtual_price()` always returns settled state. That assumption is false during the pool's native-token transfer callback, and any consumer that reads the price then sees an inflated number. The load-bearing mitigation is entirely on the consumer side and independent of Curve: before using an external pool's view output for a value-transfer decision, assert the pool is not mid-execution — the pattern later standardised as Curve/Balancer read-only-reentrancy guards (calling a state-mutating no-op that reverts if the pool's reentrancy lock is held, or Balancer's `ensureNotInVaultContext`). This case is one of the earliest in-the-wild exploitations of that surface, predating the higher-profile Sentiment (April 2023) and Curve-Vyper (July 2023) events, and ChainSecurity had already warned about exactly this Curve-LP-oracle class before it was exploited here. |
| 10 | + |
| 11 | +## Summary |
| 12 | + |
| 13 | +**Market.xyz** was a permissionless lending-market platform (Fuse/Compound-style isolated money markets) whose markets were hosted on **QuickSwap**, a DEX on **Polygon**. One Market.xyz market accepted a **Curve LP token** as collateral and priced it through a Curve-LP oracle that read the pool's `get_virtual_price()` view function. |
| 14 | + |
| 15 | +Curve's `get_virtual_price()` returns the pool's invariant value per LP token. It is safe to read only when the pool is in a settled state. But `remove_liquidity` on a pool holding a native asset transfers that asset to the caller **before** it finishes updating the pool's internal accounting — a legitimate callback that momentarily leaves the pool mid-execution, with total LP supply already reduced but the invariant not yet recomputed. During that window, `get_virtual_price()` reports an inflated value. |
| 16 | + |
| 17 | +On **2022-10-24**, an attacker exploited this. Using flash-loaned capital, the attacker added liquidity to the Curve pool, then called `remove_liquidity`; inside the native-token transfer callback, while the pool was mid-execution, the attacker's contract invoked Market.xyz's borrow path, which read the inflated `get_virtual_price()` and valued the attacker's LP collateral far above its true worth. Market.xyz extended a loan against that inflated valuation, and the attacker withdrew the borrowed MAI, repaying the flash loan and keeping the difference — about **\$220K** across two transactions. |
| 18 | + |
| 19 | +The Curve pool was untouched: the read was a `staticcall`, no assets left the pool. The loss fell entirely on the Market.xyz market, and specifically on MAI that **QiDAO had seeded** to bootstrap it, so no user deposits were lost. **QuickSwap closed its lending service** after the exploit and initially attributed the failure to the Curve/QiDAO oracle configuration; **QiDAO stated the exploit was unrelated to its own smart contracts.** The 138 ETH of proceeds were bridged to Ethereum and routed through Tornado Cash. |
| 20 | + |
| 21 | +## Timeline (UTC) |
| 22 | + |
| 23 | +| When | Event | OAK ref | |
| 24 | +|---|---|---| |
| 25 | +| (standing) | Curve `remove_liquidity` transfers a native asset to the caller before settling pool accounting, leaving `get_virtual_price()` inflated mid-callback | (standing T9.010 surface) | |
| 26 | +| (pre-event) | ChainSecurity publicly warns that Curve-LP oracles reading `get_virtual_price()` are exposed to read-only reentrancy | (advisory, pre-event) | |
| 27 | +| (standing) | Market.xyz market on QuickSwap prices a Curve LP token via `get_virtual_price()` with no mid-execution check | (T9.010 surface present) | |
| 28 | +| 2022-10-24 | Attacker flash-borrows, adds liquidity, calls `remove_liquidity`; re-enters Market.xyz's borrow path during the callback and reads the inflated `get_virtual_price()` | **T9.002 → T9.010** | |
| 29 | +| 2022-10-24 | Loan extended against over-valued LP collateral; attacker withdraws MAI, repays flash loan; ~\$220K extracted across two transactions | **T9.010 extraction** | |
| 30 | +| 2022-10-24 onward | 138 ETH bridged Polygon → Ethereum → Tornado Cash | (laundering) | |
| 31 | +| post-event | QuickSwap closes its lending service; QiDAO states the exploit is unrelated to its contracts | (operator response) | |
| 32 | + |
| 33 | +## What defenders observed |
| 34 | + |
| 35 | +- **Pre-event (the class was already published).** ChainSecurity had documented the Curve-LP-oracle read-only-reentrancy exposure before this incident. The pre-event control is inventorying which of a protocol's price sources are external pool `view` reads that can be reached mid-execution, and treating any that lack a mid-execution check as a live exposure rather than a theoretical one. |
| 36 | +- **At-event (the vulnerable protocol shows no anomaly).** The Curve pool's own balances and invariants were intact throughout — forensic monitoring of the pool alone finds nothing. The anomaly lives on the consumer: a loan extended against a collateral valuation that was correct against the oracle read at that instant but wrong against settled state. This is what makes T9.010 harder to catch at the per-contract layer than a direct drain (T9.005), and why cross-protocol call-trace analysis is the detection surface. |
| 37 | +- **At-event (the diagnostic trace).** The direct signature is a `staticcall` to `get_virtual_price()` (or an equivalent pool view) executed while that pool's outer `remove_liquidity` frame has not returned, followed in the same transaction by a borrow against the value it produced. A per-transaction call-trace rule for view-reads of a mid-execution target is the highest-leverage at-event signal. |
| 38 | +- **Mitigation (consumer-side, Curve-independent).** The fix does not depend on Curve changing anything: the consumer asserts the pool is not mid-execution before trusting its view — invoking the pool's reentrancy lock via a state-mutating no-op that reverts if the lock is held, the pattern later standardised across Curve-LP and Balancer-LP integrations. Per-contract `ReentrancyGuard` on the consumer does not help, because the re-entry is into a `view`, not a state-mutating function. |
| 39 | +- **Disposition (seeded liquidity, not user funds).** The drained MAI was QiDAO-seeded bootstrap liquidity, so end users lost nothing — a disposition detail, neutral to how much of an attack this was. The mechanism and the missing control are identical whether the drained liquidity was protocol-seeded or user-deposited. |
| 40 | + |
| 41 | +## Public references |
| 42 | + |
| 43 | +- `[quillauditsmarketxyz2022]` — QuillAudits, "Decoding \$220K Read-Only Reentrancy Exploit" (primary technical breakdown: `get_virtual_price()` manipulation via `remove_liquidity`, the `D`-not-updated-while-supply-decreased window, flash loan, both attacker addresses and transaction hashes, ~\$220K MAI / 138 ETH + 700 MATIC, QiDAO-seeded funds): <https://quillaudits.medium.com/decoding-220k-read-only-reentrancy-exploit-quillaudits-30871d728ad5> |
| 44 | +- `[chainsecuritycurvelp2022]` — ChainSecurity, "Curve LP Oracle Manipulation: Post Mortem" (authoritative post-mortem of the Curve-LP-oracle read-only-reentrancy class and the consumer-side guard; the firm's prior "Heartbreaks & Curve LP Oracles" advisory pre-dated the exploit): <https://www.chainsecurity.com/blog/curve-lp-oracle-manipulation-post-mortem> |
| 45 | +- `[ambergroupmaifinance2022]` — Amber Group, "Mai Finance's Oracle Manipulation Vulnerability Explained" (independent mechanism analysis of the QuickSwap/Market.xyz oracle manipulation): <https://medium.com/amber-group/mai-finances-oracle-manipulation-vulnerability-explained-55e4b5cc2b82> |
| 46 | +- `[web3isgreatmarketxyz2022]` — Web3 Is Going Great, "Oracle manipulation attack on a QuickSwap market earns exploiter \$188,000" (contemporaneous record; QuickSwap closes lending service; QiDAO states its contracts unrelated): <https://www.web3isgoinggreat.com/?id=oracle-manipulation-attack-on-a-quickswap-market-earns-exploiter-188000> |
| 47 | + |
| 48 | +## Discussion |
| 49 | + |
| 50 | +Market.xyz is OAK's canonical anchor for the **read-only reentrancy via AMM LP-token oracle** shape of T9.010, and it earns the slot by being one of the earliest clean in-the-wild instances — October 2022, before the higher-profile 2023 cases. It pairs with Sentiment (April 2023, Balancer-Vault callback) and Cygnus (March 2024) as the cross-AMM anchor set: three different pool architectures, one identical consumer-side vulnerability — a lending protocol trusting a pool `view` output at a moment the pool was mid-execution. The AMM design and the specific callback differ; the trust assumption that breaks is the same. |
| 51 | + |
| 52 | +The case is worth separating cleanly from the **July 2023 Curve/Vyper incident**, with which it has been confused, because they are different events with different mechanisms. The Vyper incident ([`examples/2023-07-curve-vyper.md`](2023-07-curve-vyper.md)) was a *compiler-level* `@nonreentrant`-lock failure that made Curve pools reentrancy-capable when they should not have been, and its primary losses were **direct** reentrancy drains of the pools themselves (T9.005, ~\$73M aggregate). Market.xyz was not involved in that event, and the read-only-reentrancy surface it exploited here needed no compiler bug at all: `remove_liquidity`'s native-token callback is a legitimate, by-design mid-execution window. Conflating the two overstates both and misattributes the mechanism; OAK keeps Market.xyz as the Oct-2022 Polygon T9.010 anchor and the Vyper chain as the July-2023 T9.005 anchor. |
| 53 | + |
| 54 | +The durable lesson is the one ChainSecurity had already published before the exploit: **every external `view` read consumed for a pricing decision is a trust assumption that the read reflects settled state, and that assumption fails whenever the target can be mid-execution.** The mitigation is the consumer's to implement and does not require the target pool to change — which is exactly why a protocol cannot outsource this risk to the AMM it integrates. |
0 commit comments