Curve Vyper Lock Reentrancy
Exploit Transactions
Victim Addresses
0x9848482da3ee3076165ce6497eda906e66bb85c5Ethereum0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511EthereumLoss Breakdown
Similar Incidents
Conic ETH Oracle Reentrancy
36%OMPxContract bonding-curve loop exploit drains ETH reserves
34%BatchSwap Counterpart Reentrancy
31%Cream Finance cAmp / Amp Reentrancy Exploit
31%Orion Pool Double-Count Exploit
30%Curve crvUSD sDOLA Market In-Tx Oracle Refresh Liquidation Attack
30%Root Cause Analysis
Curve Vyper Lock Reentrancy
1. Incident Overview TL;DR
Curve's pETH pool at 0x9848482da3ee3076165ce6497eda906e66bb85c5 and CRV/ETH pool at 0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511 were exploitable by any unprivileged Ethereum user because both pools were compiled with vulnerable Vyper releases that broke named reentrancy locks. In block 17806056, tx 0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c used a public Balancer flash loan, reentered the pETH pool during an ETH payout, and finished with 6106654659206663837483 WETH before gas, or 6106598229149004628022 WETH net after subtracting 56430057659209461 wei of sender gas cost. In block 17807830, tx 0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c independently realized the same flaw against Curve's CRV/ETH pool.
The root cause is cross-function reentrancy introduced by the Vyper named-lock compiler bug described in advisory GHSA-5824-cm3x-3c38. Curve relied on @nonreentrant('lock') to serialize add_liquidity, exchange, remove_liquidity, and remove_liquidity_one_coin, but Vyper 0.2.15 and 0.3.0 allocated distinct lock slots per function instead of a shared slot per key. Because the vulnerable pool functions perform external ETH transfers before all LP-share and invariant accounting is finalized, an attacker fallback can reenter a second entrypoint against stale outer-frame snapshots and extract ETH/WETH profit.
2. Key Background
The ACT pre-state is the public Ethereum mainnet state immediately before block 17806056: Balancer Vault liquidity is available for flash loans, the pETH and CRV/ETH Curve pools already hold meaningful ETH liquidity and LP supply, and all contracts involved are public and permissionless. The attacker needs no privileged keys, private orderflow, or special permissions; the exploit depends only on public pool state, public Balancer flash-loan liquidity, and attacker-deployed helper contracts.
Curve's affected pools are 2-coin implementations that mix native ETH with an ERC-20 asset. Their accounting depends on coherent snapshots of self.balances, LP totalSupply, and invariant variables such as D. Source-level mutual exclusion was supposed to come from shared named locks, because add_liquidity, exchange, remove_liquidity, and remove_liquidity_one_coin all mutate the same accounting while also interacting externally.
The Vyper advisory matters because the pools were compiled, not merely written, under vulnerable compiler versions. On-chain verified source confirms:
pETH pool 0x9848482d...85c5 -> compiler vyper:0.2.15
CRV/ETH pool 0x8301ae4f...c511 -> compiler vyper:0.3.0
The advisory states that in versions 0.2.15, 0.2.16, and 0.3.0, each function using a named reentrancy lock receives a unique lock regardless of the key. That means source code that appears to serialize multiple functions with @nonreentrant('lock') does not actually provide cross-function mutual exclusion at runtime.
3. Vulnerability Analysis & Root Cause Summary
This incident is an ATTACK, not MEV arbitrage and not an access-control failure. The broken safety property is cross-function mutual exclusion: functions that share the same logical lock key are supposed to be mutually exclusive, but the compiled bytecode no longer enforces that property. In both affected Curve pools, the vulnerable remove and exchange paths send ETH with raw_call(..., value=...) before the surrounding frame finishes all dependent accounting updates. That external ETH send gives the attacker a callback point with enough gas to invoke a different pool function while the outer frame is still active. The reentrant inner call mints or redeems LP against a stale outer snapshot, so the outer frame later finalizes using LP supply and balance assumptions that are no longer current. The result is deterministic over-crediting of the attacker and measurable depletion of pool ETH that is later wrapped into WETH profit.
4. Detailed Root Cause Analysis
The key invariant is: every function annotated with the same @nonreentrant('lock') key must be mutually exclusive, so no second state-mutating pool entrypoint can observe partially finalized balances, invariant D, or LP supply from an in-flight first entrypoint. The code-level breakpoint is the Vyper compiler bug: named locks are allocated per function, not per lock name, so the intended shared lock never exists across functions.
The pETH pool source shows both the shared lock annotation and the dangerous interaction pattern. add_liquidity computes minting from the current LP supply and balance snapshot, while remove_liquidity transfers ETH before the outer frame is finished:
@external
@nonreentrant('lock')
def add_liquidity(_amounts: uint256[N_COINS], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256:
amp: uint256 = self._A()
old_balances: uint256[N_COINS] = self.balances
total_supply: uint256 = self.totalSupply
...
mint_amount = total_supply * (D2 - D0) / D0
@external
@nonreentrant('lock')
def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[N_COINS], _receiver: address = msg.sender) -> uint256[N_COINS]:
total_supply: uint256 = self.totalSupply
...
if i == 0:
raw_call(_receiver, b"", value=value)
The pETH pool also exposes the same pattern in exchange and remove_liquidity_one_coin, both guarded with the same named lock and both performing ETH sends:
@external
@nonreentrant('lock')
def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address = msg.sender) -> uint256:
...
else:
...
raw_call(_receiver, b"", value=dy)
@external
@nonreentrant('lock')
def remove_liquidity_one_coin(_burn_amount: uint256, i: int128, _min_received: uint256, _receiver: address = msg.sender) -> uint256:
...
if i == 0:
raw_call(_receiver, b"", value=dy[0])
The CRV/ETH pool at 0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511 is the same pattern under vyper:0.3.0. add_liquidity, exchange, remove_liquidity, and remove_liquidity_one_coin all carry @nonreentrant('lock'), but remove_liquidity and remove_liquidity_one_coin still transfer ETH before the function has fully finished its state transition:
@external
@nonreentrant('lock')
def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256, use_eth: bool = False) -> uint256:
xp: uint256[N_COINS] = self.balances
...
token_supply: uint256 = CurveToken(token).totalSupply()
...
CurveToken(token).mint(msg.sender, d_token)
@external
@nonreentrant('lock')
def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS], use_eth: bool = False):
total_supply: uint256 = CurveToken(token).totalSupply()
...
if use_eth and i == ETH_INDEX:
raw_call(msg.sender, b"", value=d_balance)
...
self.D = D - D * amount / total_supply
The seed traces show this bug becoming exploitable state, not just a theoretical compiler issue. In the canonical pETH exploit tx 0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c, the outer remove_liquidity pays ETH to attacker helper 0x466b85b49ec0c5c1eb402d5ea3c4b88864ea0f04, and the helper immediately reenters add_liquidity:
0x466B85B49EC0c5C1eB402d5EA3C4b88864Ea0f04::fallback{value: 34316009777207925757865}()
Vyper_contract::add_liquidity{value: 40000000000000000000000}([40000000000000000000000, 0], 0)
emit AddLiquidity(... token_supply: 125829583850937885021795)
...
emit RemoveLiquidity(... token_supply: 11215401298916882158302)
That trace shows the exact stale-snapshot effect: the inner frame mints against one supply state, but the outer frame later completes using a much smaller stale token_supply. The attacker is left with reentrantly minted LP that the outer withdrawal never priced in, then burns part of that inflated position and converts residual pETH back to ETH. The same mechanism appears in tx 0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c against the CRV/ETH pool:
Vyper_contract::totalSupply() -> 579778196102293886243359
0x83E056Ba00bEae4D8aA83dEb326a90A4E100d0c1::fallback{value: 204244670630797362608}()
Vyper_contract::add_liquidity{value: 400000000000000000000}([400000000000000000000, 0], 0, true)
emit AddLiquidity(... token_supply: 563509892755957785673574)
...
emit RemoveLiquidity(... token_supply: 550348187166762515331352)
After the CRV/ETH reentrant mint survives the outer withdrawal, the attacker burns the newly minted LP in remove_liquidity_one_coin and converts residual CRV back through exchange. The exploit conditions are therefore concrete and reproducible:
- The target pool must be compiled with a vulnerable Vyper version that breaks named-lock sharing.
- The pool must expose at least two state-mutating entrypoints that share the same logical lock and rely on cached LP or invariant snapshots.
- One of those entrypoints must perform an external ETH call before all dependent accounting is finalized.
- Sufficient public liquidity must exist to make the path economically viable; Balancer flash loans supplied that liquidity in both seeds.
The violated security principles are equally concrete: compiler-provided mutual exclusion did not preserve source-level lock semantics, check-effects-interactions was broken on ETH payout paths, and LP supply plus invariant math stopped being derived from one coherent state snapshot.
5. Adversary Flow Analysis
- In tx
0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c, EOA0x6ec21d1868743a44318c3c259a6d4953f9978538calls attacker orchestrator0x9420f8821ab4609ad9fa514f8d2f5344c3c0a6ab, which uses helper0x466b85b49ec0c5c1eb402d5ea3c4b88864ea0f04to take an80,000WETH Balancer flash loan from0xba12222222228d8ba445958a75a0704d566bf2c8. - The helper unwraps WETH to ETH, deposits
40,000ETH into the pETH pool viaadd_liquidity, and records the initial LP mint. - The helper arms its fallback, calls
remove_liquidity, receives ETH from the pool, and reentersadd_liquidityfrom the fallback while the outer frame is still running. - After the outer frame returns, the helper holds more LP than the initial mint alone would justify. It burns part of that inflated LP, receives more ETH and pETH, swaps residual pETH to ETH via
exchange(1, 0, ...), wraps the proceeds back into WETH, repays the Balancer principal, and forwards the leftover WETH to the orchestrator. - Tx
0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477cis an independent realization of the same root cause against0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511: it flash-borrows WETH, reentersadd_liquidityduringremove_liquidity, then usesremove_liquidity_one_coinandexchangeto monetize the stale-accounting LP position. - The act opportunity is permissionless throughout. Every transaction in the exploit path is an ordinary public Ethereum transaction using public protocol functions, public state, and attacker-deployed contracts.
6. Impact & Losses
The two seed transactions caused a combined loss of 13787148358551706422700 wei, or 13,787.148358551706422700 ETH-equivalent, across the affected Curve pools.
Tx 0xa84aa065...1620c (pETH pool): 6106654659206663837483 wei
Tx 0x2e7dc8b2...477c (CRV/ETH pool): 7680493699345042585217 wei
Combined loss: 13787148358551706422700 wei
Balance-diff artifacts match the economic result. For tx1, the pETH pool's native ETH balance decreases by 6107654659206663837483 wei while WETH increases by 6106654659206663837483 wei, reflecting the pool drain and wrapped profit path. Tx2 shows the CRV/ETH pool losing 7680493699345042585217 wei and WETH gaining the same amount. The specific tx1 profit predicate reported in root_cause.json is also satisfied: the adversary starts with zero WETH in the profit-holding contract, ends with 6106654659206663837483 WETH before gas, and nets 6106598229149004628022 WETH after deducting the sender's gas cost.
7. References
- Canonical pETH exploit tx:
0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c - Independent CRV/ETH exploit tx:
0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c - pETH pool verified source and compiler version:
https://etherscan.io/address/0x9848482da3ee3076165ce6497eda906e66bb85c5#code - CRV/ETH pool verified source and compiler version:
https://etherscan.io/address/0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511#code - Vyper advisory
GHSA-5824-cm3x-3c38:https://github.com/vyperlang/vyper/security/advisories/GHSA-5824-cm3x-3c38 - Collector trace for tx1:
/workspace/session/artifacts/collector/seed/1/0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c/trace.cast.log - Collector trace for tx2:
/workspace/session/artifacts/collector/seed/1/0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c/trace.cast.log - Collector balance diffs for tx1 and tx2 under
/workspace/session/artifacts/collector/seed/1/.../balance_diff.json