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
- 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.
- 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.
