Curve Re-entrancy (2023)

A $73M cascading exploit in July 2023 across multiple Curve Finance stablecoin pools, caused by a re-entrancy vulnerability in the Vyper compiler — not in the Curve code itself. The bug affected specific Vyper versions (0.2.15, 0.2.16, 0.3.0) and resulted in the nonReentrant decorator being silently ineffective in certain code paths.

Timeline

  • July 30, 2023: Several Curve pools (CRV/ETH, msETH/ETH, alETH/ETH, pETH/ETH) drained over a few hours. ~$73M total.
  • Same day: Curve identified the cause as a compiler bug, not pool logic. Patches issued by the Vyper team within hours.
  • August 2023: White hats (in particular c0ffeebabe.eth, a known whitehat MEV operator) recovered some funds. Negotiations with malicious attackers resulted in partial returns.
  • Total recovered: ~$52M; net loss ~$21M.

Root Cause

Vyper's @nonreentrant("lock") decorator was supposed to use a single shared lock slot across all functions sharing the same lock key, preventing cross-function re-entrancy. In affected compiler versions, the codegen for @nonreentrant was buggy: it used different storage slots for different functions sharing the same lock name. Result: function A's nonReentrant did not prevent re-entry into function B with the same lock key.

For Curve pools, this meant: while add_liquidity was executing (and had its lock held), an attacker could re-enter into remove_liquidity (with the "same" lock that wasn't actually shared). The pool's invariants assumed mutual exclusion that did not hold.

Exploit Path (simplified, alETH/ETH pool)

1. Attacker calls add_liquidity to deposit ETH + alETH.
2. add_liquidity, mid-execution, transfers ETH to the pool. This is a
   direct ETH send.
3. (In Vyper, the send to a contract triggers the receiver's fallback.)
4. The receiver — attacker's contract — calls remove_liquidity on the
   same pool.
5. Because @nonreentrant was broken, remove_liquidity proceeds.
6. remove_liquidity sees the pre-add-liquidity state (because the
   add_liquidity hasn't yet updated total supply / reserves consistently),
   and removes liquidity based on the stale ratios. Attacker exits with
   more than they put in.
7. add_liquidity continues and completes; bookkeeping is broken; attacker
   has profited.

The fundamental attack is cross-function re-entrancy: the pool's invariants held within add_liquidity and within remove_liquidity individually but not when one re-entered the other. This is exactly the bug class that nonReentrant is supposed to prevent.

What an Audit Should Have Caught

Curve's code was correct under the assumption that @nonreentrant works. The Curve audits had verified that the decorator was applied to the relevant functions. The bug was in the compiler.

But the broader audit category — "what if the toolchain has a bug?" — should be part of high-stakes audits:

  1. Toolchain trust assumptions. A protocol's security depends on Solidity / Vyper compilers, on the LLVM backend, on the EVM implementation. Audits typically assume these are correct. For very high-value contracts, this assumption deserves scrutiny.

  2. Cross-function re-entrancy testing. Even if nonReentrant is presumed to work, fuzzing / invariant tests that explicitly try cross-function re-entrancy (within the constraints the test framework allows) would have surfaced the bug.

  3. Pinned compiler versions. Curve was using Vyper versions known to be older (0.2.x). Newer versions had different (sometimes more) bugs. The trade-off between using mature/stable older versions and patched newer versions is non-trivial.

  4. Independent implementations. Formal verification or model-based testing of the pool's invariants — independent of the compiler-generated bytecode — would have caught that the actual bytecode behavior diverged from the model.

Lessons

  1. Toolchain bugs are real. Compiler / VM / library bugs have caused on-chain losses before (the OpenZeppelin ECRecover issue, the Vyper bug, Solidity ABI encoding bugs). High-value contracts should:

    • Audit the compiler-generated bytecode against the source.
    • Use formal verification to check invariants against the bytecode, not just the source.
    • Pin compiler versions and review their changelogs.
  2. Cross-function re-entrancy is the most-missed re-entrancy variant. Same-function re-entrancy is widely understood. Cross-function (different functions sharing state) is often missed. Audits should explicitly test that re-entry between every state-mutating function pair is safe, not just within a single function.

  3. The Vyper ecosystem is smaller and less reviewed than Solidity's. Vyper has merits (simpler language, fewer footguns) but the tooling, audit experience, and bug-discovery community are smaller. Protocols choosing Vyper accept this trade-off; audits should account for the limited Vyper expertise.

  4. The same bug in multiple pools is one bug. Curve's incident affected several pools because all used the same broken decorator pattern. A single compiler bug + many deployed pools = many simultaneous exploits. Audit reports should list the "blast radius" of each bug class.

  5. MEV whitehats are a partial mitigation. c0ffeebabe.eth's interception of the attacker's transactions recovered significant funds. The white-hat ecosystem is part of the security model now. But — like all opportunistic mitigations — it's not reliable.

  6. The compiler vs. language vs. usage distinction matters. Curve's code was idiomatic Vyper; the language design was fine; the compiler implementation was buggy. Three layers, one of which was at fault. Audits should explicitly evaluate which layer is being audited.

The Vyper-Curve incident pushed the industry toward more rigorous compiler-version review, more formal-verification adoption, and more explicit attention to toolchain assumptions. The category — "the code is right, the compiler is wrong" — is now a recognized risk category, even if it's hard to mitigate in practice.