All incidents

Curve Vyper Lock Reentrancy

Share
Jul 30, 2023 13:10 UTCAttackLoss: 13,787.15 ETHPending manual check2 exploit txWindow: 5h 57m
Estimated Impact
13,787.15 ETH
Label
Attack
Exploit Tx
2
Addresses
2
Attack Window
5h 57m
Jul 30, 2023 13:10 UTC → Jul 30, 2023 19:08 UTC

Exploit Transactions

TX 1Ethereum
0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c
Jul 30, 2023 13:10 UTCExplorer
TX 2Ethereum
0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c
Jul 30, 2023 19:08 UTCExplorer

Victim Addresses

0x9848482da3ee3076165ce6497eda906e66bb85c5Ethereum
0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511Ethereum

Loss Breakdown

13,787.15ETH

Similar Incidents

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:

  1. The target pool must be compiled with a vulnerable Vyper version that breaks named-lock sharing.
  2. The pool must expose at least two state-mutating entrypoints that share the same logical lock and rely on cached LP or invariant snapshots.
  3. One of those entrypoints must perform an external ETH call before all dependent accounting is finalized.
  4. 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

  1. In tx 0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c, EOA 0x6ec21d1868743a44318c3c259a6d4953f9978538 calls attacker orchestrator 0x9420f8821ab4609ad9fa514f8d2f5344c3c0a6ab, which uses helper 0x466b85b49ec0c5c1eb402d5ea3c4b88864ea0f04 to take an 80,000 WETH Balancer flash loan from 0xba12222222228d8ba445958a75a0704d566bf2c8.
  2. The helper unwraps WETH to ETH, deposits 40,000 ETH into the pETH pool via add_liquidity, and records the initial LP mint.
  3. The helper arms its fallback, calls remove_liquidity, receives ETH from the pool, and reenters add_liquidity from the fallback while the outer frame is still running.
  4. 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.
  5. Tx 0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c is an independent realization of the same root cause against 0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511: it flash-borrows WETH, reenters add_liquidity during remove_liquidity, then uses remove_liquidity_one_coin and exchange to monetize the stale-accounting LP position.
  6. 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

  1. Canonical pETH exploit tx: 0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c
  2. Independent CRV/ETH exploit tx: 0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c
  3. pETH pool verified source and compiler version: https://etherscan.io/address/0x9848482da3ee3076165ce6497eda906e66bb85c5#code
  4. CRV/ETH pool verified source and compiler version: https://etherscan.io/address/0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511#code
  5. Vyper advisory GHSA-5824-cm3x-3c38: https://github.com/vyperlang/vyper/security/advisories/GHSA-5824-cm3x-3c38
  6. Collector trace for tx1: /workspace/session/artifacts/collector/seed/1/0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c/trace.cast.log
  7. Collector trace for tx2: /workspace/session/artifacts/collector/seed/1/0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477c/trace.cast.log
  8. Collector balance diffs for tx1 and tx2 under /workspace/session/artifacts/collector/seed/1/.../balance_diff.json