The Curve Finance Vyper Exploit: When Compilers Betray You

On July 30, 2023, several Curve Finance liquidity pools were drained of approximately $70 million. The affected contracts had correct source code. The developers had used @nonreentrant decorators exactly as intended. The compiler generated broken bytecode anyway.

This is the kind of vulnerability that source code review cannot catch—because the source code is not what runs on-chain.

What Happened

Four pools were drained across multiple protocols:

ProtocolPoolLoss
Curve DAOCRV/ETH~$24.7M
AlchemixalETH/ETH~$22.6M
JPEG’dpETH/ETH~$11M
MetronomemsETH/ETH~$3.4M

All four were Vyper contracts compiled with versions 0.2.15, 0.2.16, or 0.3.0. All four used the @nonreentrant decorator. In all four, that decorator did not work.

Vyper’s Reentrancy Protection

Vyper offers @nonreentrant as a built-in language feature. When applied to a function, the compiler is supposed to generate code that sets a storage flag on entry and clears it on exit, reverting if any reentrant call finds the flag already set:

@external
@nonreentrant('lock')
def remove_liquidity(amount: uint256):
    self._burn(msg.sender, amount)
    send(msg.sender, amount)  # External call — protected by the decorator

The equivalent in Solidity would be:

function remove_liquidity(uint256 amount) external nonReentrant {
    _burn(msg.sender, amount);
    payable(msg.sender).transfer(amount);
}

The @nonreentrant decorator is a trust agreement between the developer and the compiler: write this, and the compiler handles the guard. In the affected versions, the compiler broke that agreement.

The Compiler Bug

The bug was in how Vyper handled multiple functions sharing the same reentrancy lock key. When two or more functions in the same contract used @nonreentrant('lock'), the compiler’s code generation for the storage slot assignment could go wrong in two ways: generating the lock acquisition correctly for one function while omitting the check in another, or failing to clear the lock in certain exit paths.

In simplified bytecode terms, the expected output for any function with @nonreentrant looks like:

function_entry:
    SLOAD lock_slot      ; Load the lock
    ISZERO               ; Is it zero (unlocked)?
    JUMPI continue       ; Jump if unlocked
    REVERT               ; Revert if already locked
continue:
    SSTORE lock_slot, 1  ; Set the lock
    ; ... function body ...
    SSTORE lock_slot, 0  ; Clear the lock

In the buggy versions, some functions would generate only:

function_entry:
    ; Lock check entirely absent
    ; Function body proceeds immediately

The missing check meant that calling one function could trigger an ETH transfer to an attacker-controlled address, which could then re-enter a second function in the same contract—the one missing its lock check—before the first call completed and balances were updated.

The Attack

The mechanics were standard reentrancy once the guard was absent:

  1. Attacker calls remove_liquidity() on a vulnerable pool
  2. Pool sends ETH to attacker
  3. Attacker’s fallback re-enters another pool function before balances update
  4. Attacker withdraws again against the pre-update state
  5. Repeat until pool is drained

The attacker needed to identify pools compiled with the affected Vyper versions. Pool contracts on Ethereum carry compiler metadata in their bytecode that makes version identification straightforward.

The Deeper Problem

The thing that makes this incident unsettling is how thoroughly it defeats conventional security processes.

A code reviewer reading the Vyper source would see @nonreentrant and correctly assess that reentrancy is handled. A Vyper-aware static analyzer that operates on source code would reach the same conclusion. Even a Vyper compiler audit conducted before these specific versions were released would not have flagged the bug—it was introduced in 0.2.15 and existed across three version increments before anyone noticed.

The bug was present for roughly two and a half years before exploitation. During that time, the contracts were live, holding hundreds of millions of dollars, with broken security properties that were invisible to every standard review process.

Bytecode analysis, by contrast, does not trust the source. It examines the actual sequence of opcodes that will execute. A tool checking whether the remove_liquidity function contains the expected SLOAD/SSTORE pattern for a reentrancy guard would find it absent—regardless of what the source code claimed.

Recovery

A white hat MEV operator known as c0ffeebabe.eth front-ran some of the attacker’s transactions and recovered approximately $5.3 million from the CRV/ETH pool, returning it to Curve. Other efforts brought total fund recovery to roughly 70% of losses.

Curve’s total value locked fell from approximately $3 billion to $1.5 billion following the attack. The CRV token dropped close to 30%. More significantly, the incident prompted a broad audit of Vyper-compiled contracts across DeFi—teams with any contracts using the affected versions had to assess their exposure.

Compiler Hygiene

The practical lessons from this incident are less dramatic than the exploit itself, but they matter:

Pin compiler versions and document them. Every deployed contract should have a record of the exact compiler version used. When a compiler security advisory is published, that record tells you whether you are affected.

Monitor compiler advisories. The Vyper team maintains a security disclosure process. Subscribing to their GitHub releases or security mailing list takes minutes.

Verify bytecode, not just source. For critical deployments, the bytecode that lands on-chain should be checked against expected patterns—particularly for security-critical features like reentrancy guards. This is not a common practice, but the Curve incident is a case where it would have mattered.

Treat alternative compilers as carrying additional risk. Vyper is a legitimate language with genuine security advantages over Solidity in some respects. But it has a smaller user base, fewer security tools, and a shorter track record. These factors do not make it wrong to use—but they make verification more important, not less.


References