MetaPool mpETH Free Mint
Exploit Transactions
0x57ee419a001d85085478d04dd2a73daa91175b1d7c11d8a8fb5622c56fd1fa69Victim Addresses
0x48afbbd342f64ef8a9ab1c143719b63c2ad81710Ethereum0xdf261f967e87b2aa44e18a22f4ace5d7f74f03ccEthereumLoss Breakdown
Similar Incidents
UERII Public Mint Drain
36%DFX flash LP mint exploit
34%GoodDollar Fake-Interest Mint
34%Unlimited-Mint Collateral Used to Over-Mint Debt Token
33%Luckytiger Lucky Mint Drain
33%EFVault Withdraw Under-Burn
32%Root Cause Analysis
MetaPool mpETH Free Mint
1. Incident Overview TL;DR
On Ethereum mainnet block 22722911, an attacker-controlled helper contract executed a single transaction, 0x57ee419a001d85085478d04dd2a73daa91175b1d7c11d8a8fb5622c56fd1fa69, that borrowed 200 WETH from Balancer, drained MetaPool's MetaETHLP pool inventory of mpETH, then called MetaPoolETH.mint to create 9701.950394814195092500 mpETH without paying the 10699.999999999999999986 ETH-equivalent assets quoted by the vault. The attacker monetized part of the unbacked mpETH, repaid the flash loan, and transferred the remaining proceeds to the attacker EOA.
The root cause is a broken ERC4626 mint invariant in MetaPool's Staking implementation. MetaPool inherited OpenZeppelin ERC4626Upgradeable.mint unchanged, but overrode _deposit so that _deposit no longer transfers underlying assets from the caller. Once the attacker first emptied the LiquidUnstakePool inventory branch, mint issued shares and increased accounting without receiving ETH or WETH.
2. Key Background
MetaPoolETH is the upgradeable proxy at 0x48afbbd342f64ef8a9ab1c143719b63c2ad81710, pointing at Staking implementation 0x3747484567119592ff6841df399cf679955a111a at the incident block. MetaETHLP is the upgradeable proxy at 0xdf261f967e87b2aa44e18a22f4ace5d7f74f03cc, pointing at LiquidUnstakePool implementation 0xcadd976ae3a04352b4ab28865af07ad2c366d675.
OpenZeppelin ERC4626Upgradeable.mint computes assets = previewMint(shares) and then calls _deposit(caller, receiver, assets, shares). In the standard ERC4626 implementation, _deposit performs safeTransferFrom(_asset, caller, address(this), assets) before minting shares. That invariant is what couples minted shares to received collateral.
MetaPool changed that assumption. Staking.deposit transfers WETH and unwraps it before calling _deposit, and Staking.depositETH passes msg.value into _deposit. But MetaPool left mint inherited from OpenZeppelin, so mint still assumes _deposit will collect assets itself.
LiquidUnstakePool.swapETHFormpETH is also relevant. During normal deposits, Staking._getmpETHFromPool can source already-existing mpETH inventory from MetaETHLP before minting fresh shares. The attacker exploited this by first draining that pool inventory, guaranteeing that the later mint path would fall through to fresh issuance.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an accounting and composition bug in MetaPool's ERC4626 integration, not a pricing edge or privileged action. OpenZeppelin's mint implementation is safe only if _deposit collects previewMint(shares) assets from the caller before minting. MetaPool overrode _deposit to handle ETH-native flows and pool sourcing, but removed the transfer step that the inherited mint path still depends on.
At the incident block, Staking._deposit checks minimum deposit size, optionally pulls pre-existing mpETH from LiquidUnstakePool, mints any residual shares to itself, increments totalUnderlying by the residual asset amount, and transfers shares to the receiver. It does not call transferFrom, it does not require ETH value for inherited mint, and it does not otherwise verify asset receipt inside mint.
The explicit invariant is: every successful MetaPoolETH.mint(shares, receiver) must first cause the vault to receive previewMint(shares) underlying assets so that totalSupply and totalAssets remain backed by real collateral. The code-level breakpoint is the inherited call chain ERC4626Upgradeable.mint -> Staking._deposit. Once LiquidUnstakePool inventory is zero, _getmpETHFromPool returns (0, 0), sharesToMint becomes the full requested share amount, assetsToAdd becomes the full previewed asset amount, and the vault books collateral and issues shares without receiving ETH or WETH.
4. Detailed Root Cause Analysis
The critical OpenZeppelin code path is:
function mint(uint256 shares, address receiver) public virtual override returns (uint256) {
uint256 assets = previewMint(shares);
_deposit(_msgSender(), receiver, assets, shares);
return assets;
}
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual {
SafeERC20Upgradeable.safeTransferFrom(_asset, caller, address(this), assets);
_mint(receiver, shares);
}
MetaPool replaced _deposit with logic that assumes the asset transfer has already happened:
function _deposit(address _caller, address _receiver, uint256 _assets, uint256 _shares) internal override {
(uint256 sharesFromPool, uint256 assetsToPool) = _getmpETHFromPool(_shares, address(this));
uint256 sharesToMint = _shares - sharesFromPool;
uint256 assetsToAdd = _assets - assetsToPool;
if (sharesToMint > 0) _mint(address(this), sharesToMint);
totalUnderlying += assetsToAdd;
_transfer(address(this), _receiver, _shares);
}
That override is compatible with deposit and depositETH, because those entrypoints transfer WETH or ETH before calling _deposit. It is not compatible with inherited mint, because inherited mint never performs those MetaPool-specific preparatory steps.
The on-chain trace for the seed transaction shows the exploit sequence directly:
Staking::depositETH{value: 107000000000000000000}(0xC3D10bd8e051a2bE6408d18Be8464654F699a25a)
0xdF261F...::swapETHFormpETH{value: 106910422650390972178}(...)
emit Transfer(from: 0xdF261F..., to: 0x48AFbB..., value: 96938281985300295162)
Staking::mint(9701950394814195092500, 0xC3D10bd8e051a2bE6408d18Be8464654F699a25a)
emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x48AFbB..., value: 9701950394814195092500)
emit Transfer(from: 0x48AFbB..., to: 0xC3D10bd8e051a2bE6408d18Be8464654F699a25a, value: 9701950394814195092500)
No ETH value accompanies the mint call, and no WETH transferFrom into MetaPoolETH occurs during that call. The focused state diff independently confirms the accounting break: storage slots corresponding to supply and underlying-accounting increase on 0x48afbbd342f64ef8a9ab1c143719b63c2ad81710, while the vault's real-asset balances do not receive the quoted 10699.999999999999999986 ETH-equivalent collateral.
LiquidUnstakePool is the enabler for the first stage. Its swapETHFormpETH function simply transfers existing mpETH inventory out of the pool in exchange for ETH:
function swapETHFormpETH(address _to) external payable onlyStaking returns (uint256) {
uint256 mpETHToSend = Staking(STAKING).previewDeposit(msg.value);
IERC20Upgradeable(staking).safeTransfer(_to, mpETHToSend);
ethBalance += msg.value;
return mpETHToSend;
}
By using depositETH first, the attacker drained the pool-held mpETH inventory to zero. That made the later mint call deterministic: _getmpETHFromPool could no longer satisfy any shares from existing inventory, so the entire 9701.950394814195092500 mpETH was freshly minted and transferred to the attacker-controlled helper contract without asset payment.
5. Adversary Flow Analysis
The adversary cluster is:
- EOA
0x48f1d0f5831eb6e544f8cbde777b527b87a1be98, the seed transaction sender and final proceeds recipient. - Contract
0xFF13d5899aa7d84c10E4CD6Fb030B80554424136, deployed at the start of the transaction. - Runtime exploit helper
0xC3D10bd8e051a2bE6408d18Be8464654F699a25a, which executes the flash-loan exploit.
The execution flow is:
- Balancer transfers
200 WETHto the helper contract. - The helper unwraps
107 WETHto ETH and callsMetaPoolETH.depositETH. Staking._getmpETHFromPoolforwards106.910422650390972178 ETHintoMetaETHLP.swapETHFormpETH, which transfers96.938281985300295162 mpETHout of the pool and leaves the pool inventory empty.- The helper calls
MetaPoolETH.mint(9701950394814195092500, helper)with zero ETH value. The vault records the previewed assets in accounting and mints9701.950394814195092500 mpETHto the helper without collecting ETH or WETH. - The helper sells part of the unbacked
mpETHvia twoMetaETHLP.swapmpETHforETHcalls and one Uniswap V3 pool swap, then wraps enough ETH back into WETH to repay Balancer. - After repaying the
200 WETHflash loan, the helper forwards remaining ETH andmpETHto the EOA.
The trace ends with the helper transferring 9682718631554663151620 mpETH to the attacker EOA and forwarding ETH proceeds separately, proving the exploit remained end-to-end feasible inside one public transaction.
6. Impact & Losses
The primary protocol loss is unbacked mpETH issuance:
mpETH:9701950394814195092500raw units (9701.950394814195092500 mpETH,18decimals)
This is a real protocol loss because the vault increased totalSupply and totalUnderlying as though assets had been received, but no corresponding collateral entered the vault during the vulnerable mint. The attacker then converted part of that unbacked supply into ETH through public liquidity and retained the rest as mpETH, externalizing the collateral shortfall to the protocol and remaining token holders.
7. References
- Seed transaction
0x57ee419a001d85085478d04dd2a73daa91175b1d7c11d8a8fb5622c56fd1fa69metadata and receipt. - MetaPool Staking implementation
0x3747484567119592ff6841df399cf679955a111a, especiallydeposit,depositETH,_deposit, and_getmpETHFromPool. - OpenZeppelin
ERC4626Upgradeableimplementation, especiallymintand_deposit. - MetaPool
LiquidUnstakePoolimplementation0xcadd976ae3a04352b4ab28865af07ad2c366d675, especiallyswapETHFormpETHandswapmpETHforETH. - Opcode-level trace for the seed transaction showing the pool drain, zero-payment mint, swaps, flash-loan repayment, and proceeds transfer.
- Focused state diff and balance diff artifacts confirming supply/accounting growth and attacker proceeds.