When the Lock Wasn’t Loaded — Curve’s $70 Million Compiler Betrayal

Audit Tales – Episode #2

In late July 2023 Curve Finance redeployed a handful of ageing liquidity pools, expecting little more than a marginal gas reduction. The source code was unchanged, the re-entrancy decorator still wrapped every sensitive function, and the contracts sailed through unit-tests. Twelve minutes after they reached main-net, four of those pools lay empty and $70 million in assets had moved to freshly minted EOAs.

The fault was not in the Vyper source code. It was in the compiler itself.

A Routine “Gas Refresh” Goes Sideways

Smart-contract teams periodically rebuild legacy code with newer compiler versions to harvest incremental gas savings and harmonise dev-tooling. Curve followed that pattern on 30 July 2023, compiling CRV-ETH, alETH-ETH, msETH-ETH and pETH-ETH with Vyper 0.2.15-beta+commit.7d0.

In theory Vyper’s @nonreentrant(“lock”) decorator emits a constructor instruction that initialises storage slot 0 with the value 1. Every guarded function then executes:

assert self.lock == 1, “re-entrancy blocked”

self.lock = 2

self.lock = 1

But the optimisation pass in that specific build mis-hoisted the assignment. The bytecode deployed with lock = 0x00, a value that satisfies the run-time assertion by failing to detect the failure. Any external call could now re-enter itself without resistance.

Exploitation in Two Comfortable Movements

  1. Enumeration and confirmation – A search bot trawling Ethereum for “new contracts using decorator pattern but slot 0 == 0” flagged the four addresses minutes after deployment. A dry-run against CRV-ETH proved the lock inert.
  2. Value extraction – The attacker scripted a deposit / withdraw loop: add_liquidity() increased their LP balance; a re-entrant remove_liquidity_one_coin() withdrew tokens before internal balances updated. Iterating the loop drained ≈ $24 million from the first pool, then the same method vacuumed alETH-ETH, msETH-ETH and pETH-ETH.

No oracle manipulation, no flash-loan; just a decorator that forgot to close the door.

Why Auditors Missed It

  • Source fidelity bias – Code review confirmed the decorator; few teams decompile deploy-time bytecode after a “cosmetic” rebuild.
  • Unchecked build matrix – Continuous-integration pinned the compiler tag but not the SHA-256 of the Docker image. When the Vyper project quietly replaced artifacts, Curve’s pipeline reproduced the bug rather than the expected output.
  • Absent deploy-time invariants – Had the migration script asserted storage[0] == 1 post-deploy, the issue would have surfaced before liquidity arrived.

Immediate Containment and Recovery

Curve’s DAO multi-sig paused the affected gauges within thirty minutes; white-hat bots rescued several follow-on pools by front-running potential attackers, though $70 million had already exited. A retrospective build diff by the Vyper core team identified the exact optimisation flag responsible, and a patched version shipped 48 hours later.

Strategic Take-Aways

1. Toolchain determinism is part of protocol security
Pin compiler digests, not tags. Reproducible builds are the only defence when the optimiser is the adversary.

2. Byte-level invariants beat human intuition
A five-line Hardhat script that checks slot0 != 0 after deployment would have halted the migration. Automate those tests.

3. Legacy contracts require full re-audit on rebuild
Identical source with new bytecode is new risk. Treat it as such.

4. Expand bounty scopes to include binaries
Static source audits cannot reveal post-compile regressions. Pay researchers for either surface.

Closing Reflection

In Web3 we often say exploits are “one-line mistakes.” This time the line wasn’t even in the repo; it was a missing SSTORE deep in an optimiser pass. When your compiler can betray you, linting the source is necessary but never sufficient. Guard the toolchain, assert post-deploy invariants, and remember that yesterday’s battle-tested contract becomes today’s zero-day the moment you press rebuild.

Audit Tales will return in two weeks with an L2 airdrop contract that rewarded users twice because block producers rewrote history faster than a Merkle root could keep up.

Leave a Reply

Your email address will not be published. Required fields are marked *