Curve Stablepools / CRV-ETH Vyper Nonreentrant Lock Bypass (2023-07-30)
- Name: Curve stablepools / CRV-ETH Vyper nonreentrant lock bypass
- Date: 2023-07-30
- Protocol: Curve Finance
- Chain: Ethereum
- Category: compiler-induced cross-function reentrancy / AMM LP-share inflation / pool drain
- Loss: Roughly $70 million was extracted across affected pools before recoveries. Curve and LlamaRisk’s July 30 postmortems broke the realized drains out roughly as
pETH/ETH: 6,106.65 WETH,msETH/ETH: 866.55 WETH + 959.71 msETH,alETH/ETH: 7,258.70 WETH + 4,821.55 alETH, andCRV/ETH: 7,193,401.77 CRV + 7,680.49 WETH + 2,879.65 ETH, with the final2,879.65 ETHfromCRV/ETHlater recovered by whitehatc0ffeebabe.ethand returned. - Summary: This was not a normal “forgot the reentrancy guard” bug. Several Curve pools had been compiled with vulnerable Vyper versions whose
@nonreentrantimplementation allocated named locks incorrectly after the compiler changes later tracked back to PR#2391. Functions that were supposed to share one lock actually got separate locks, so cross-function reentrancy remained possible. In the affected ETH-paired pools, attackers used flash-loaned capital, entered liquidity-removal paths that sent native ETH viaraw_call, re-entered pool functions before balances and supply were fully reconciled, minted LP claims against stale pre-burn accounting, and then withdrew a disproportionate share of pool assets. - Trigger / entry point:
- Vyper versions
0.2.15,0.2.16, and0.3.0misallocated named@nonreentrantlocks, per the official GitHub advisory; the Vyper postmortem later traced the bad behavior to compiler changes that landed in PR#2391, while fixes landed in PRs#2439and#2514. - The affected Curve pools relied on those locks to prevent cross-function reentrancy between liquidity-removal and liquidity-addition / accounting-sensitive paths.
- Native-ETH pool flows used external ETH transfers (
raw_call), which handed control to attacker contracts during pool state transitions. - Curve responders also flagged ERC-777-bearing pools as theoretically exposed through the same callback class, although the main realized July 30 drains were the ETH-paired pools.
- Vyper versions
- Root cause:
- The Vyper compiler bug removed the guarantee that multiple functions using the same nonreentrancy key would actually share one lock.
- More specifically, the compiler refactor later traced to PR
#2391stopped reusing one slot per named key and instead allocated a fresh slot per decorated function, so source code that saidthese entrypoints share one guardcompiled into bytecode where they did not. - Curve pool logic still had callback-sensitive windows where external ETH transfer could occur before all balance/supply effects were fully sealed.
- Once the language-level guard failed, the attacker could bounce between functions that developers believed were mutually excluded.
- Exploit path:
- The attacker took a large Balancer flash loan in WETH and unwrapped it to native ETH.
- The attacker added liquidity to a vulnerable ETH-paired Curve pool and received LP tokens.
- The attacker invoked a liquidity-removal path such as
remove_liquidityorremove_liquidity_one_coin, entering code that would transfer ETH outward before pool accounting was fully settled. - During that outbound ETH transfer, the attacker contract’s fallback path re-entered a second pool function that had been annotated with the same Vyper nonreentrancy key.
- Because the compiler had allocated separate locks instead of one shared lock, the second function was not blocked.
- The re-entrant call minted or priced LP claims using stale pre-burn balances / supply, effectively manufacturing an oversized claim on the pool.
- The attacker then withdrew pool assets against those inflated LP rights, repaid the flash loan, and kept the remainder.
- Variants of the same primitive drained
pETH/ETH,msETH/ETH,alETH/ETH, andCRV/ETH; the laterCRV/ETHwhitehat transaction exploited the same broken-state aftermath to recover remaining ETH and returned it to Curve.
- What the attacker needed to be true:
- The pool had to be compiled with one of the vulnerable Vyper versions.
- Multiple sensitive functions had to rely on the same named nonreentrancy key for safety.
- A control-flow handoff had to exist before accounting finalization; in practice the ETH pools’ native-ETH transfer path supplied that callback point.
- The pool needed enough TVL that flash-loan-funded LP-share inflation produced real extractable value after slippage and repayment.
- Technical anatomy worth remembering:
- This incident is a clean example of compiler-induced reentrancy exposure: application code appeared to use the right guard, but the compiler broke the guard’s shared-lock semantics.
- The vulnerability was specifically cross-function reentrancy, not just re-entering the exact same function.
- In the stable pools, the recurring money step was: burn LP, re-enter add-liquidity / related path against stale balances, overmint LP, then cash those LP claims out.
- LlamaRisk’s replay numbers make the mechanic vivid: in
pETH/ETH, burning about32,431.41LP tokens first withdrew about3,740 pETH + 34,316 ETH, but re-entering before post-burn accounting finalized let the attacker re-add40,000 ETH, mint about82,182more LP tokens against stale state, and later cash that inflated claim out. - The
CRV/ETHpool added a second layer because once CRV reserves had been drained, the pool’s internal view of balances was broken enough that a follow-on whitehat could donate CRV, callclaim_admin_fees, sync balances, and pull out the stranded ETH. - The affected pools were notable precisely because they paired against native ETH. Curve and community responders stressed that WETH-paired pools were not exposed through the same path, while older ERC-777-bearing pools sat in the same general risk family because token callbacks can reopen unfinished accounting boundaries too.
- Representative transactions:
pETH/ETHexploit:0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620cmsETH/ETHexploit:0xc93eb238ff42632525e990119d3edc7775299a70b56e54d83ec4f53736400964alETH/ETHexploit:0xb676d789bb8b66a08105c844a49c2bcffb400e5c1cfabd4bc30cca4bff3c9801CRV/ETHexploit:0x2e7dc8b2fb7e25fd00ed9565dcc0ad4546363171d5e00f196d48103983ae477cCRV/ETHwhitehat recovery transaction:0xcd99fadd7e28a42a063e07d9d86f67c88e10a7afe5921bd28cd1124924ae2052- Return of whitehat-recovered ETH to Curve deployer:
0xb76754124fdde090f25129105ed2907e3c62e0db87ecb8ffcefcb1dede0954fd
- Response / mitigation:
- Curve publicly warned LPs in affected pools, assessed additional exposure, and moved to kill gauge emissions for affected pools.
- Vyper published an advisory identifying versions
0.2.15,0.2.16, and0.3.0as vulnerable and pointed projects to0.3.1+; its later technical postmortem noted that0.3.1had already patched the bug accidentally before the ecosystem understood the impact. - Community and protocol responders distinguished vulnerable native-ETH pools from safer WETH-paired replacements, and Curve discussed migrating liquidity into new pool implementations.
- Whitehat coordination recovered part of the remaining value from
CRV/ETH, most visibly the2,879.65 ETHreturned byc0ffeebabe.eth.
Why this incident matters
- It shows that smart-contract security assumptions can fail below the application layer; a correct-looking
@nonreentrantannotation is not a proof if the compiler is wrong. - It is one of the clearest examples of cross-function reentrancy producing LP-share inflation rather than the more familiar simple-withdrawal drain.
- It is also a reminder that native-asset transfer paths often create more dangerous callback surfaces than wrapped-token designs.
- It preserves a harsher review rule too: compiler, language, and framework guard semantics belong inside the threat model whenever a protocol’s solvency boundary depends on them.
Guard present in sourceis weaker thanguard semantics proved at runtime.
Pattern tags
- Compiler-broken shared lock semantics: the decisive failure was not that Curve forgot to add
@nonreentrant, but that Vyper versions0.2.15,0.2.16, and0.3.0could allocate separate locks to functions that source code said should share one reentrancy key. - Native-ETH callback-bearing burn path: the monetizable control handoff came from ETH-paired pool flows that delivered native ETH with
raw_callbefore burn / reserve reconciliation had finished. - Cross-function LP-share inflation: the attacker did not simply recurse one withdrawal path. They re-entered a sibling mint / liquidity-management surface while post-burn balances and supply were still stale, then converted that stale state into oversized LP claims.
- Compiler-trust-boundary failure with live solvency consequences: the pool math itself only stayed safe if the language runtime preserved the lock-sharing invariant, so compiler selection and deployed bytecode provenance were part of the custody boundary.
Why the exploit worked
- Vyper’s named-lock bug meant Curve’s source-level claim
these functions share one lockwas false at runtime, reopening cross-function call interleavings the pool logic assumed were impossible. - The affected pools still exposed an unfinished external-delivery edge: native ETH left the contract before the burn / accounting sequence had fully settled, so an attacker-controlled fallback could observe and act on transient state.
- Curve’s economic state machine made that window directly profitable. During the callback, re-entered add-liquidity / accounting-sensitive paths still saw pre-burn balances or supply, so newly minted LP tokens encoded claims against a balance sheet that had already partly paid out.
- Flash liquidity amplified rather than created the bug. The important invariant failure was that stale pool state could still mint or price redeemable LP rights; flash loans only scaled the amount of state distortion worth monetizing.
- The later
CRV/ETHwhitehat sequence is part of the same lesson: once the broken callback path and reserve depletion had desynchronized the pool’s internal accounting from real balances, even recovery required explicit balance resynchronization before stranded value could be extracted safely.
Three compiler-and-callback promotions this incident collapsed
-
A source-level guard annotation existed at all
- Curve’s vulnerable pools really did use named
@nonreentrantdecorators, and reviewers could honestly see the same key repeated across the relevant entrypoints in source. - But that weak fact only proved
the code appears guarded, notdeployed bytecode actually enforces one shared exclusion domain.
- Curve’s vulnerable pools really did use named
-
That annotation was still treated as canonical runtime mutual-exclusion truth
- PR
#2391’s slot-allocation change broke the key-sharing guarantee: functions annotated with the same lock name received different storage slots, soremove_liquidityand its sibling paths no longer contended on one runtime lock. - The dangerous promotion was from
the source carries one guard labeltothe live contract has already published one money-good reentrancy boundary around these paths.
- PR
-
That mistaken runtime truth still inherited live LP-issuance and reserve-spending authority across callbacks
- Once the compiler-level exclusion boundary silently disappeared, Curve’s native-ETH payout edge let attacker contracts re-enter sibling mint / accounting paths while burn and reserve reconciliation were unfinished.
- The real loss came from letting that false guard story keep downstream authority: stale pre-burn state still minted redeemable LP claims and those claims still spent against live pool reserves.
Detection and audit pivots
- Treat compiler, language, and framework versions as first-class security dependencies whenever solvency depends on guard primitives like
@nonreentrant, pausable modifiers, arithmetic helpers, or storage-layout assumptions. - For every function expected to share a reentrancy domain, verify runtime behavior from compiled artifacts, not only source annotations. The relevant question is
do these bytecode paths really contend on one lock? - Trace every native-asset or callback-capable token delivery path and ask whether reserves, balances, LP supply, fees, and admin-accounting state are already final before control can leave the contract.
- Fuzz cross-function sequences, not only same-function recursion:
remove_liquidity -> callback -> add_liquidity,remove_liquidity_one_coin -> callback -> exchange, and nearby variants that can re-price LP rights against stale state. - Separate
WETH designfromETH designin review. If one path unwraps and performs raw ETH delivery while another keeps wrapped-token semantics, they are different callback surfaces and deserve different trust assumptions. - Keep a live inventory of deployed contracts by compiler version and redeployability. Once a language advisory lands, responders need to know which pools, gauges, wrappers, or forks still carry economically relevant bytecode built with the affected toolchain.
Failure-prevention checklist
- Do not let solvency rest on source-level guard annotations alone. For any callback-sensitive contract, verify from compiled artifacts and deployment metadata that shared-lock domains, storage slots, and emitted bytecode still match the intended runtime guard model.
- Prefer check-effects-interactions even when a language guard exists. If a pool must ever transfer ETH or callback-capable tokens before final accounting, treat that design as exceptional and prove why the post-transfer frame cannot still mint, burn, or re-price claims.
- Collapse native-asset delivery behind safer post-settlement adapters where possible.
raw_call-style ETH sends should not remain on the same hot path that decides LP-supply truth, reserve balances, admin-fee state, or claimable withdrawal amounts. - Fuzz and invariant-test sibling entrypoints that are supposed to share one exclusion domain.
remove_liquidity,remove_liquidity_one_coin,add_liquidity,exchange, and fee-claim or sync helpers must fail closed under callback-time interleavings, not merely under direct same-function recursion. - Maintain a current compiler / framework bill of materials for all economically live deployments, including factory pools, wrappers, gauges, and forks. A language advisory should immediately answer
which live contracts are built with the bad toolchain, and can we migrate or kill incentives before attackers arrive? - Separate
wrapped asset path is safefromnative asset path is safe. If WETH-paired and ETH-paired variants have different control-transfer behavior, review and cap them as different risk surfaces instead of one pool family. - Keep bounded-loss controls around pools whose safety still depends on one compiler, one guard primitive, or one callback-sensitive external-delivery edge. Gauge shutdown, supply caps, migration playbooks, and kill switches matter because toolchain bugs can bypass application-level review.
Audit prompts
- Which invariants in this pool are actually enforced by the compiler or framework rather than by explicit application storage transitions, and do we have bytecode-level confirmation that those invariants still hold on deployed contracts?
- For every pair of functions that claims to share one
@nonreentrantkey, what concrete artifact review proves they contend on the same runtime slot or lock object? - At the moment ETH or a callback-capable token leaves the pool, which balances, LP-supply values, admin-fee counters, and claim calculations are still provisional rather than final?
- If a callback re-enters through a sibling function instead of the same function, can it mint, burn, swap, sync, or fee-claim against stale pre-burn or pre-sync state that still leads to redeemable LP rights?
- Which pool variants depend on native-asset delivery, and have those variants been reviewed separately from otherwise similar WETH pools rather than inheriting the same risk rating?
- When a compiler advisory lands, who owns the deployment inventory, redeployability map, and incentive-kill / migration decision for every affected live pool?
- Do our tests only assert
reentrant call reverts, or do they also assertno intermediate callback frame can publish stale reserve truth into later mint / burn mathacross sibling entrypoints? - If the language-level guard vanished tomorrow, would the pool still fail safely, or would unfinished external delivery immediately become LP-share issuance authority?
Lessons that should already be standard
- Compiler choice is part of custody. If LP-share solvency depends on a language primitive behaving exactly as advertised, then compiler provenance belongs inside the same threat model as pool math and admin keys.
Guard present in sourceis not evidence. For callback-sensitive code, the real review object is deployed bytecode plus the exact compiler and build pipeline that produced it.- Native-asset convenience is not a neutral UX detail. The ETH path in Curve was materially more dangerous than the WETH path because it handed control away before the pool had finished deciding reserve and LP-supply truth.
- Cross-function reentrancy needs first-class review. Teams often test
function re-enters itself; attackers look forfunction A pays out, function B still trusts the old state. - Toolchain incidents need live inventory and fast blast-radius reduction, not just a patch release. By the time the ecosystem understands a compiler bug, the operational question is already
which live contracts still embody it and how quickly can we kill incentives or migrate users? - A healthy design assumes a guard may someday fail. If one broken lock implementation instantly turns an ordinary callback into infinite-share or reserve-drain authority, the external-delivery boundary was already too trusted.
Internal linkages
- Core recurring pattern: reentrancy.
- Positive-design companion: temporary-capability-closure-before-external-delivery — useful when the sharper design question is why Curve still had an externally controlled native-ETH delivery edge before all burn / mint accounting had settled, so one failed shared-lock assumption immediately reopened monetizable state.
- Positive-design companion: callback-frame-truth-security-object — useful when the sharper design question is what exact pool / account / stage object should have stayed coherent across any outbound ETH callback instead of relying on the compiler to make multiple entrypoints mutually exclusive.
- Adjacent protocol context: curve.
Control surface
-
The leverage sat in a stack, not one line of code: Vyper compiler correctness, Curve’s reliance on named shared nonreentrancy locks, native-ETH transfer callbacks, and pool math that only remained safe if those locks really were shared.
-
The painful lesson is that “we used the guard” is not the same as “the guard exists at runtime.”
-
The design-side corollary is that
language primitive plus unfinished external-delivery edgeis still one control surface: if a failed guard would expose stale accounting, the delivery edge itself needs a safer post-settlement shape or narrower callback capability. -
Sources:
-
Last reviewed: 2026-08-02 UTC