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:
-
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.
-
Cross-function re-entrancy testing. Even if
nonReentrantis 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. -
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.
-
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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.