Curve Vyper Reentrancy (2023)
Smart contract security audit by TierZero · 2023-11-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit.
What the protocol did
Curve Finance runs stableswap-style AMM pools that let users trade between assets designed to hold a near-1:1 peg — stablecoins, or liquid-staked ETH derivatives against ETH. A subset of these pools, including several factory-deployed pools for pairs like pETH/ETH, alETH/ETH, and msETH/ETH, handled native ETH directly rather than wrapping it as WETH. That design choice — cheaper gas, no wrap/unwrap step — is what turned a compiler bug into a live exploit.
The pool contracts were written in Vyper, compiled with versions 0.2.15, 0.2.16, and 0.3.0. Those three compiler releases shipped a defective implementation of the @nonreentrant decorator. The bug was not in Curve's Solidity-equivalent logic; it was in the toolchain that turned correct-looking source into incorrect bytecode. Because other protocols — Alchemix, JPEG'd, Metronome — had deployed their own Curve-style ETH pools using the same vulnerable compiler versions, the blast radius extended well past Curve itself.
The vulnerability
Vyper's @nonreentrant('lock') decorator is supposed to compile into a storage-backed mutex: set a flag on entry, revert any nested call that observes the flag already set, clear it on exit. In the affected compiler versions, the storage slot allocation for that lock regressed — the generated bytecode did not reliably set and check the guard the way the source annotation implied, so a function marked @nonreentrant could still be re-entered mid-execution under the right call pattern.
@external
@nonreentrant('lock')
def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[N_COINS]) -> uint256[N_COINS]:
# ... compute withdrawal amounts from current pool balances ...
for i in range(N_COINS):
if coins[i] == ETH_ADDRESS:
raw_call(msg.sender, b"", value=amounts[i]) # triggers recipient's fallback
else:
ERC20(coins[i]).transfer(msg.sender, amounts[i])
# ... finalize balances / totalSupply after the external call ...
The pattern above is ordinary Curve withdrawal logic, and on a correctly compiled nonreentrant guard it is safe even though the ETH transfer happens before bookkeeping is fully settled — the lock is the safety net. Send ERC20 tokens and there's no code execution on the recipient side to worry about; send raw ETH to a contract address and you hand control to that contract's fallback function before your own function returns. With the guard silently defeated by the compiler bug, that fallback could call straight back into the same pool while its internal balances still reflected the pre-withdrawal state.
How the exploit worked
- Attacker deploys a contract whose
receive()/fallback re-enters the target pool. - Attacker calls
remove_liquidity(orremove_liquidity_imbalance) on a vulnerable ETH pool, burning LP tokens. - The pool computes payout amounts from current reserves and issues a raw ETH transfer to the attacker's contract before all state relevant to pricing/invariant is finalized.
- The ETH transfer triggers the attacker's fallback. Because the
nonreentrantlock is compiled incorrectly, the reentrant call is not rejected. - The reentrant call — another withdrawal, a swap, or a liquidity addition — reads pool balances that haven't yet been decremented for ETH already earmarked to leave, so it prices against a stale, favorable state.
- Attacker repeats the sequence to drain reserves, then does the same across every other pool compiled with the same broken Vyper version, hitting Curve's own pools plus Alchemix's alETH pool, JPEG'd's pETH pool, and Metronome's msETH pool in short order.
Aggregate losses across the affected pools ran into the tens of millions of dollars. A white-hat MEV searcher front-ran part of the remaining attacker activity and returned a meaningful share of funds to Curve; some attackers separately negotiated bounty returns. A second-order effect mattered more for systemic risk than the direct loss: the CRV price shock that followed pushed large CRV-collateralized positions tied to the protocol's founder close to liquidation thresholds across several lending markets, briefly threatening contagion unrelated to the original bug. That's a governance and collateral-concentration lesson as much as a smart-contract one — composability risk of exactly the kind we cover when we write about cross-program invocation risk in Solana DeFi, even though the mechanics there are chain-specific.
How an audit catches this
A source-level review that simply confirms @nonreentrant is present on every state-mutating external function would have passed this code and still missed the bug — the annotation was correct, the compiler's translation of it wasn't. That's the core lesson we bake into engagements at /services/smart-contract-audit:
- Pin and audit the compiler, not just the source. Treat the exact compiler version as part of the attack surface. Check it against the language's own advisory list before sign-off, the same discipline we apply when reviewing Solidity point releases in coverage like Solidity Permit2 gasless approvals and EVM sniping-bot exposure.
- Verify guards at the bytecode level, not by trusting the source annotation. Decompile or trace the deployed artifact to confirm the lock's storage slot is actually set and checked where expected.
- Fuzz every function that moves native ETH to an arbitrary address with a malicious receiver contract that attempts reentry, independent of whether a
nonreentrantdecorator is declared — defense in depth against exactly this class of silent guard failure. - Flag raw ETH transfers that precede final state settlement as a design smell even under a working guard; CEI ordering should hold on its own merits, not solely because a lock is assumed correct.
This is the kind of check that belongs in ongoing code review and audit coverage as much as a one-time engagement, since compiler upgrades land quietly and can reintroduce the same class of defect.
Remediation
Curve and the downstream protocols recompiled affected pools with a patched Vyper release once the compiler team fixed the storage-slot regression, and migrated liquidity to the corrected deployments. For pools that couldn't be patched in place, the practical response was to pause new deposits, encourage LPs to withdraw, and stand up off-chain monitoring — reserve-ratio and virtual-price anomaly detection — as a backstop that doesn't depend on the compiler getting a low-level guarantee right. Longer term, protocols building native-ETH pools should treat any function combining a raw ETH call/send with pre-guard trust in the compiler as requiring an explicit, independently verified reentrancy test, not just an annotation. Teams shipping new AMM or vault logic on this kind of design can get that verification built in from the start through smart contract development engagements that treat the compiler as adversarial by default.
If your protocol touches native ETH transfers, custom AMM math, or a pinned compiler version you haven't re-checked against its advisory history, that's worth a second set of eyes — talk to us about an audit.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit