Reproduced Exploit
Market.xyz / Hundred-clone Exploit — Curve LP Read-Only Reentrancy Inflates Collateral Price
Market.xyz (a Fuse/Compound fork) priced its mooCurvestMATIC-MATIC collateral by reading the underlying Curve pool's get_virtual_price(). Curve's NG/crypto-pool remove_liquidity (Vyper_contract.sol:1003-1037) burns the LP token first, then pays each coin out in a loop — and for the native side it d…
Loss
~$180k (the PoC ends with 172,389 WMATIC of net flash-loan-funded profit retained before repayments; on-chain…
Chain
Polygon
Category
Oracle Manipulation
Date
Oct 2022
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-10-Market_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Market_exp.sol.
Vulnerability classes: vuln/reentrancy/read-only · vuln/oracle/price-manipulation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: the Curve crypto pool Vyper_contract.sol and the Market lending market contracts_CErc20Delegator.sol.
Key info#
| Loss | ~$180k (the PoC ends with 172,389 WMATIC of net flash-loan-funded profit retained before repayments; on-chain net theft was ~$180k of pool liquidity) |
| Vulnerable contract (price source) | Curve crvUSDBTCETH-style two-coin crypto pool stMATIC/WMATIC — 0xFb6FE7802bA9290ef8b00CA16Af4Bc26eb663a28 (impl 0x5bcA7dDF…) |
| Vulnerable contract (consumer) | Market mMAI market CErc20Delegator — 0x3dC7E6FF0fB79770FA6FB05d1ea4deACCe823943 (impl 0xB6622Fe9…) |
| Collateral market | mooCurveStMATIC market CErc20Delegator — 0x570Bc2b7Ad1399237185A27e66AEA9CfFF5F3dB8 (BeefyVaultV6 mooCurvestMATIC-MATIC underlying) |
| Comptroller | Market Unitroller — 0x627742AaFe82EB5129DD33D237FF318eF5F76CBC |
| Price oracle | Master oracle 0x71585E… → Beefy LP oracle 0x684Ba8C… → Curve LP oracle 0x75a06C8… (reads get_virtual_price) |
| Attacker EOA | 0x4206d62305d2815494dcdb759c4e32fca1d181a0 |
| Attacker contract | 0xEb4c67E5BE040068FA477a539341d6aeF081E4Eb |
| Attack tx | 0xb8efe839da0c89daa763f39f30577dc21937ae351c6f99336a0017e63d387558 |
| Chain / block / date | Polygon / fork at 34,716,800 / ~Oct 24, 2022 (Chainlink round ts 1666562946) |
| Compiler | Pool: Vyper 0.3.1 (optimizer, 1 run). Market: Solidity 0.5.17. Beefy vault: 0.6.12 |
| Bug class | Read-only reentrancy → stale-D / fresh-totalSupply virtual-price inflation → lending oracle collateral overpricing |
TL;DR#
Market.xyz (a Fuse/Compound fork) priced its mooCurvestMATIC-MATIC collateral by reading the
underlying Curve pool's get_virtual_price(). Curve's NG/crypto-pool remove_liquidity
(Vyper_contract.sol:1003-1037)
burns the LP token first, then pays each coin out in a loop — and for the native side it does a
raw value= transfer that hands control to the receiver before self.D is updated. While the
attacker's receive() holds control, the pool is in a half-finished state:
totalSupplyhas already been reduced by the burn, butself.D(the invariant / reserve measure) still reflects the pre-withdrawal amount.
get_virtual_price() = 10**18 * get_xcp(self.D) / totalSupply
(:1335-1336) therefore returns a
massively inflated value. In the trace the first read inside the reentrant callback returns
6.121e18 (output.txt:884) versus the true ~1.002e18 read a few frames later
(output.txt:1249) — a ~6.1× inflation.
The attacker exploited this in one transaction:
- Flash-loan a huge amount of WMATIC (Aave) + WMATIC & stMATIC (Balancer), deposit into the Curve pool to mint 34.55M LP.
- Wrap the LP in Beefy (
mooCurvestMATIC-MATIC, 85,901 moo-shares), supply it to the Market collateral market, and mint 429,507 cTokens. - Call Curve
remove_liquidity(...)— during the native-MATIC payout, re-enter viareceive()and callmMAI.borrow(250,000). Because the borrow's solvency check reads the inflatedget_virtual_price, the collateral appears ~6× over-valued and the 250,000 MAI borrow is approved. - Let the now-undercollateralized self-position be liquidated by the attacker's own
Liquidator(repay 70,420 MAI, seize 429,420 collateral cTokens, redeem the Beefy shares back). - Unwind: pull all liquidity back out of Curve, swap MAI→USDC→WMATIC, repay both flash loans, keep the difference.
Net retained at the end of the PoC: 172,389 WMATIC (and ~1 stMATIC dust).
Background — the price stack#
Market.xyz on Polygon let users supply mooCurvestMATIC-MATIC (a Beefy auto-compounding vault whose
"want" is a Curve stMATIC/WMATIC LP token) as collateral, and borrow MAI (miMATIC) against it.
The collateral USD value is assembled through a chain of oracle reads (all visible in the trace):
| Layer | Address | What it returns |
|---|---|---|
| Market master oracle | 0x71585E… | getUnderlyingPrice(cToken) |
| Beefy LP oracle | 0x684Ba8C… | price(mooToken) = getPricePerFullShare() × LP_price |
BeefyVaultV6 | 0xE0570d… | getPricePerFullShare() — BeefyVaultV6.sol:1139-1141 |
| Curve LP oracle | 0x75a06C8… | price(stMATIC_f) — values 1 LP using get_virtual_price() × component prices |
| Curve pool | 0xFb6FE7… | get_virtual_price() — Vyper_contract.sol:1335-1336 |
Because getPricePerFullShare and the Beefy LP oracle are linear in the LP token's
get_virtual_price, inflating get_virtual_price inflates the final collateral price by the same
factor. In the trace the inner Beefy/Curve LP price price(stMATIC_f) came back as
0.003874e18 (output.txt:851) and the final
getUnderlyingPrice(collateral 570Bc2) returned 0.004059e18 (output.txt:808 path return at L?) — both carrying the ~6× inflation from the reentrant get_virtual_price read.
Relevant on-chain magnitudes at the fork block:
| Quantity | Value |
|---|---|
LP minted by attacker (add_liquidity) | 34,640,026 stMATIC_f |
Curve LP totalSupply before burn | 41,311,744 LP |
Curve LP totalSupply after burn (read by get_virtual_price) | 6,761,718 LP |
| Beefy moo-shares minted (supplied as collateral) | 85,901 mooCurvestMATIC-MATIC |
| Market cTokens minted | 429,507 |
| MAI borrowed during reentrancy | 250,000 MAI |
The vulnerable code#
1. Curve remove_liquidity — burn first, pay out (with reentrancy) before updating D#
@external
@nonreentrant('lock')
def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS],
use_eth: bool = False, receiver: address = msg.sender):
"""
This withdrawal method is very safe, does no complex math
"""
lp_token: address = self.token
total_supply: uint256 = CurveToken(lp_token).totalSupply()
CurveToken(lp_token).burnFrom(msg.sender, _amount) # ← (1) totalSupply reduced NOW
balances: uint256[N_COINS] = self.balances
amount: uint256 = _amount - 1
for i in range(N_COINS):
d_balance: uint256 = balances[i] * amount / total_supply
...
coin: address = self.coins[i]
if use_eth and coin == WETH20:
raw_call(receiver, b"", value=d_balance) # ← (2) NATIVE transfer ⇒ receive() reenters
else:
...
D: uint256 = self.D
self.D = D - D * amount / total_supply # ← (3) self.D updated only AFTER the loop
The @nonreentrant('lock') modifier protects the pool's own state-mutating functions from
re-entry, but it does not protect @view reads. Between step (1) and step (3), an external
observer that calls a view function sees totalSupply already shrunk while self.D is still large.
2. The view that becomes a lie mid-execution#
@external
@view
def get_virtual_price() -> uint256:
return 10**18 * self.get_xcp(self.D) / CurveToken(self.token).totalSupply()
@view ⇒ no lock guard ⇒ callable during the reentrancy window. With a numerator (get_xcp(D))
frozen at the old reserves and a denominator (totalSupply) already reduced by the burn, the quotient
spikes.
3. The consumer that trusts it — BeefyVaultV6.getPricePerFullShare#
function getPricePerFullShare() public view returns (uint256) {
return totalSupply() == 0 ? 1e18 : balance().mul(1e18).div(totalSupply());
}
getPricePerFullShare itself is fine; the bug is that the Market LP oracle multiplies it by the
manipulated Curve get_virtual_price, so the cToken collateral value carries the inflation.
4. The borrow path that reads the price during reentrancy#
The reentrant receive() calls mMAI.borrow(250_000e18)
(test/Market_exp.sol:171-175). The Market borrowAllowed solvency
check calls getUnderlyingPrice(collateral) (output.txt:808), which walks the oracle
chain above and reaches the inflated get_virtual_price (output.txt:878-884). The
position passes the liquidity check and 250,000 MAI is emitted
(output.txt:1010 — emit Borrow(borrowAmount: 250000e18, …)).
Root cause#
A Compound/Fuse-style money market computed the USD value of LP-derived collateral by reading an
instantaneous, manipulable view (get_virtual_price) on a Curve pool, and that view is not safe
to read while the pool is mid-remove_liquidity:
- Burn-before-settle ordering. Curve reduces LP
totalSupply(the price denominator) at the very start ofremove_liquidity, but only updatesself.D(the numerator basis) after transferring coins out. - A reentrancy handoff in between. For the native (WMATIC) leg, the pool uses
raw_call(receiver, b"", value=d_balance), which invokes the recipient'sreceive()while the pool is half-settled. @viewfunctions are not lock-guarded.@nonreentrant('lock')blocks re-entering writes but permits readingget_virtual_pricein the inconsistent state — the textbook read-only reentrancy condition.- The lending oracle trusted it unconditionally. Market's LP/Beefy oracle multiplied
getPricePerFullShareby the liveget_virtual_pricewith no reentrancy guard, TWAP, or sanity bound, so the inflated number flowed straight into the borrow solvency check.
Measured inflation in the trace:
| Read | get_virtual_price | Context |
|---|---|---|
output.txt:884 — first read in the reentrant borrow() | 6.121e18 | self.D stale (large), totalSupply = 6,761,718 (already burned) |
| output.txt:1249 / :1486 — later reads, still inside callback after settle math caught up | 1.002e18 | true value |
output.txt:2090 — after everything settles (2nd remove_liquidity) | 1.015e18 | true value |
A single reentrant read priced the collateral ~6.1× too high.
Preconditions#
- A money market that uses a Curve pool's
get_virtual_price()(directly, or transitively via a vault token like Beefy) as a live collateral price source, with no read-only-reentrancy guard / no TWAP / no bound on the result. - A Curve pool variant whose
remove_liquidityperforms an external transfer (nativevalue=send, or a token with a transfer hook) before it finalizesself.D— true for thisstMATIC/WMATICcrypto pool when withdrawing the WMATIC leg withuse_eth = true. - An attacker contract with a payable
receive()that re-enters the lending market'sborrow(). - Working capital to mint a large LP position and supply it as collateral — fully flash-loanable. The PoC sources it from Aave (15.42M WMATIC, test/Market_exp.sol:81-92) nested inside Balancer (34.58M WMATIC + 19.66M stMATIC, test/Market_exp.sol:108-118), all repaid in the same tx.
Step-by-step attack walkthrough (with on-chain numbers from the trace)#
The Curve pool's token0 = stMATIC, token1 = WMATIC. All figures are taken from the trace events
in output.txt.
| # | Step | Source | Key on-chain effect |
|---|---|---|---|
| 0 | Flash loan stack — Aave flashLoan(15,419,963 WMATIC) → inside callback Balancer flashLoan(34,580,036 WMATIC + 19,664,260 stMATIC) | test:81-118 | ~50M WMATIC + 19.66M stMATIC working capital, 0 fee on Balancer (output.txt:? getFlashLoanFeePercentage → 0) |
| 1 | add_liquidity([19.66M stMATIC, 49.99M WMATIC], 0) → mint LP | test:141, output.txt:71 | LP totalSupply 41,311,427 → grows; attacker holds 34,640,026 stMATIC_f |
| 2 | Enter market + Beefy deposit — beefyVault.deposit(90,000 stMATIC_f) → mint 85,901 moo-shares | test:145-149, output.txt:568 | moo-shares used as Market collateral |
| 3 | mooMarket.mint(85,901) → 429,507 cTokens | test:153, output.txt:633 emit Mint(mintTokens: 429,507e18) | collateral supplied |
| 4 | remove_liquidity(34,550,026 LP, [0,0], true) — burnFrom LP first (totalSupply 41,311,744 → 6,761,718), then pay WMATIC via native value= → receive() fires | test:156, output.txt:673-697 | reentrancy window opens; self.D not yet updated |
| 5 | Reentrant mMAI.borrow(250,000) inside receive() — solvency check reads inflated get_virtual_price = 6.121e18 (output.txt:884) → collateral ~6× over-valued → borrow approved | test:174, output.txt:698, 1010 | 250,000 MAI borrowed; emit Borrow(borrowAmount: 250000e18) |
| 6 | remove_liquidity finishes, self.D updated, virtual price reverts to ~1.0e18; attacker position is now massively undercollateralized | output.txt:1034-1043 emit RemoveLiquidity | self-liquidation is now profitable |
| 7 | Self-liquidate — own Liquidator repays 70,420 MAI, seizes 429,420 collateral cTokens, then redeems the Beefy shares | test:159-168, output.txt:1066, 1633, 1675 | attacker reclaims the over-priced collateral cheaply with its own MAI |
| 8 | 2nd remove_liquidity(87,462 LP, …) to pull remaining liquidity back | test:168, output.txt:1890 | unwind |
| 9 | _sellAll — wrap native MATIC, swap MAI→USDC→WMATIC, V3 swap WMATIC→stMATIC | test:177-202 | assemble repayment assets |
| 10 | Repay Balancer (34.58M WMATIC + 19.66M stMATIC) then Aave (15.43M WMATIC + 13,878 premium) | test:104, 132-133, output.txt:tail | both loans cleared in-tx |
Final retained balances logged by the PoC (output.txt:6-9):
Attacker's profit:
stMATIC: 1
WMATIC: 172389
Profit / loss accounting#
The profit is the difference the attacker keeps after both flash loans are repaid. The 250,000 MAI borrowed against over-priced collateral is the value injected; the self-liquidation lets the attacker recover the over-valued collateral for far less MAI than it was credited, and the unwinding swaps convert the surplus to WMATIC.
| Item | Amount |
|---|---|
| Aave flash loan (WMATIC) | 15,419,963 |
| Aave premium owed | 13,877.97 |
| Balancer flash loan (WMATIC) | 34,580,036 |
| Balancer flash loan (stMATIC) | 19,664,260 |
| MAI borrowed via inflated collateral | 250,000 |
| MAI repaid in self-liquidation | 70,420 |
| Net retained WMATIC | 172,389 |
| Net retained stMATIC | ~1 |
Reported real-world loss: ~$180k of Market.xyz pool liquidity.
Diagrams#
Sequence of the attack#
Why the virtual price spikes — pool state during remove_liquidity#
Oracle chain — how the inflation reaches the borrow check#
Remediation#
- Use a read-only-reentrancy guard when reading Curve prices. Before trusting
get_virtual_price()(or any Curve view), call the pool's reentrancy-lock check pattern (e.g.withdraw_admin_fees-style or the canonical0/ dummyremove_liquiditylock probe) so the read reverts if the pool is mid-operation. Curve later published exactly this guidance after this class of incident. - Don't price collateral from instantaneous AMM state. Replace the live
get_virtual_priceread with a TWAP/manipulation-resistant oracle, or bound the per-block change so a 6× spike is rejected. - Fix the ordering in the AMM (defense in depth). A pool should finalize all invariant state
(
self.D) before making external transfers, so no view is ever inconsistent during a callback. Curve's later pool implementations move the external transfer to the end / guard views accordingly. - Avoid native
value=payouts withuse_eth=truepaths feeding untrusted receivers in any integration whose collateral is priced off the same pool; if unavoidable, ensure consumers guard their reads. - Add sanity bounds to the LP/vault oracle. The Beefy-LP oracle multiplying
getPricePerFullShare × get_virtual_priceshould clampget_virtual_priceto a plausible band (e.g.[0.95e18, 1.5e18]for a stable-ish pool) and revert otherwise.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a whole-project forge build):
_shared/run_poc.sh 2022-10-Market_exp --mt testHack -vvvvv
- RPC: a Polygon archive endpoint is required (the fork pins block 34,716,800).
foundry.tomluseshttps://polygon.drpc.org; most pruned public Polygon RPCs will fail withmissing trie nodeat this historical block. - The test took ~144s of fork I/O in this run.
Expected tail:
Ran 1 test for test/Market_exp.sol:MarketExploitTest
[PASS] testHack() (gas: 4238760)
Logs:
Attacker's profit:
stMATIC: 1
WMATIC: 172389
Suite result: ok. 1 passed; 0 failed; 0 skipped
References (from the PoC header, test/Market_exp.sol:13-17): QuillAudits "$220k read-only reentrancy" write-up, Amber Group "Mai Finance oracle manipulation explained", and Statemind / Beosin threads. SlowMist Hacked classifies this under Market.xyz / Curve read-only reentrancy on Polygon, ~$180k.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-10-Market_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Market_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Market.xyz / Hundred-clone Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.