Reproduced Exploit
An unlocked virtual price reads 10 during removal and the metapool pays out 1,000
The base pool's D is 1,000 and its supply is 1,000, so the virtual price is 1. remove_liquidity of 900 drops the supply to 100 and raw-calls the sender before it returns. D is unchanged. get_virtual_price does not look at the lock, so it returns 10. The metapool sells the caller's remaining 100 LP…
Loss
The metapool's 1,000 coin reserve is paid out at a virtual price of 10. A fair price of 1 would have paid 100
Chain
Ethereum
Category
reentrancy
Date
Dec 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb. Standalone Foundry PoC and full write-up: 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/reentrancy/read-only · vuln/oracle/price-manipulation
Reproduction: self-contained synthetic Foundry reduction; see output.txt.
Key info#
| Field | Value |
|---|---|
| Loss | The metapool's 1,000 coin reserve is paid out at a virtual price of 10. A fair price of 1 would have paid 100 |
| Vulnerable contract | OldBase27689.get_virtual_price (reduction of an old Curve ETH base pool, read by CurveStableSwapMetaNG) |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2023-12 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | get_virtual_price does not check the reentrancy lock, so D/supply is inflated while remove_liquidity is still in its callback |
TL;DR#
The base pool's D is 1,000 and its supply is 1,000, so the virtual price is 1. remove_liquidity of 900 drops the supply to 100 and raw-calls the sender before it returns. D is unchanged. get_virtual_price does not look at the lock, so it returns 10. The metapool sells the caller's remaining 100 LP at that rate and transfers its whole 1,000 coin reserve. The same removal against a base pool that reverts the view while locked pays nothing, and that reserve stays 1,000.
Background#
MixBytes, Curve StableSwap-NG. A metapool prices the base-pool LP with get_virtual_price (CurveStableSwapMetaNG.vy around the line the report cites). New base pools take the reentrancy lock inside that view. Old base pools that pay ETH do not. remove_liquidity updates supply, then sends ETH with a raw call, and the view is readable from the callback. Virtual price is D over supply. Supply has moved, D has not, and the rate is wrong for the duration of the call. The report rates this critical because a swap in the metapool during that window trades at the manipulated rate. The recommended fix is to refuse base pools paired with ETH when they are added to the factory.
The vulnerable code#
if (guardView) {
require(!locked, "locked");
}
// @> VULN: old ETH base pools do not check the reentrancy lock here. D/supply is read while supply is already reduced.
return D * 1e18 / supply;
The raw call is the empty call at the end of remove_liquidity, which is the reduction of the ETH transfer. No value is attached. The harm is the price read, not the ether.
Root cause#
The view is part of the price the metapool trusts, and it is not covered by the lock that the state-changing removal already holds. Callers can reenter the price before the removal has finished.
Preconditions#
- The base pool is an old pool:
get_virtual_pricedoes not revert whilelockedis true. - Removal reduces supply before the external call, and D stays put.
- The metapool holds a coin reserve it will pay against that price. Here the reserve is 1,000, and the caller sells 100 LP.
Attack walkthrough#
- The metapool reserve is 1,000 (output.txt:9).
- During the callback the price is 10 (output.txt:11).
- The caller receives 1,000 (output.txt:13). The metapool is empty (output.txt:15).
- Control: the guarded view reverts, the swap pays 0 (output.txt:17), and the control reserve is still 1,000 (output.txt:19).
[PASS](output.txt:6).
Diagrams#
Impact#
The metapool pays 1,000 for 100 LP. At the price from before the callback, 100 LP is worth 100. The extra 900 comes out of the other LPs' coin. The control path pays 0 during the callback and keeps the 1,000.
Remediation#
get_virtual_price has to take the same reentrancy lock as remove_liquidity. Do not register a base pool whose view is readable in the middle of an ETH transfer.
How to reproduce#
cd 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb_exp
forge test -vv
Sources#
- AuditVault finding: 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb
- MixBytes, Curve StableSwap-NG: https://github.com/mixbytes/audits_public/blob/master/Curve%20Finance/StableSwapNG/README.md
CurveStableSwapMetaNG.vyvirtual-price read, commit8c78731.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb_exp (evm-hack-registry mirror).
- AuditVault finding: 27689-read-only-reentrancy-in-meta-pool-with-an-old-base-pool-mixb.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.