GHSA-6447-269v-g68m: Mezo: ERC-20 bridgeOut burn can be erased by a stale StateDB overwrite leading to full L1 bridge drain
**Note: the fixed version of the validator client has been deployed for some time.**ImpactPotential full drain of L1 bridge without changing bridged balance on Mezo.Brief/IntroA malicious user can steal all ERC-20 tokens locked in the L1 bridge by repeatedly calling the `bridgeOut` precompile from a crafted contract. The precompile's ERC-20 burn executes in an **inner StateDB** that commits to a cache context, but the **outer StateDB** still holds stale pre-burn storage slots in its `dirtyStorage`. When the outer transaction commits, it overwrites the inner burn with stale values -- **restoring the attacker's balance and allowance** while the `AssetsUnlocked` event has already been persisted to the bridge store. The Ethereum sidecar observes this event and attests the unlock on L1, releasing real tokens to the attacker. The attacker keeps their Mezo balance intact and can repeat the drain every block.Vulnerability DetailsMezo's EVM uses a dual-context state architecture for precompile calls. When a precompile is invoked, `Contract.Run()` calls `stateDB.CacheContext()` which creates a `cachedCtx` branched from the base `ctx`. Cosmos-side state changes (like ERC-20 burns) happen on `cachedCtx`, while standard EVM storage operations (SLOAD/SSTORE) operate on the outer StateDB's `dirtyStorage` backed by `baseCtx`.The `bridgeOut` precompile handles two token types with fundamentally different mechanisms:**BTC (`burnBitcoin`)**: Burns via `x/bank` (Cosmos native), then explicitly records a `journal.SubBalance` entry. `syncJournalEntries()` propagates this to the outer StateDB, keeping both contexts in sync.**ERC-20 (`burnERC20`)**: Burns via `ExecuteContractCall`, which creates an **entirely new inner StateDB** operating on `cachedCtx`. This inner StateDB executes `burnFrom` (decreasing balance, supply, and allowance slots), commits its changes to `cachedCtx`, and is discarded. **No journal entry is created.** The outer StateDB is never informed of these storage changes.This creates a critical asymmetry. After `burnERC20` returns, the outer StateDB's `dirtyStorage` still contains the **pre-burn allowance** (written by `approve` earlier in the same transaction), and has **never loaded** the balance or supply slots. When the attacker triggers a subsequent `transfer(sink, 1)` in the same transaction, the outer StateDB performs a `GetCommittedState` on the balance slot. This reads from `baseCtx` -- which holds the **stale pre-burn value** because `cachedCtx` changes haven't been flushed to `baseCtx` yet. The stale balance minus 1 is written to `dirtyStorage`.At end-of-transaction, `StateDB.Commit()` executes in this order:Step 1 flushes the inner burn into `baseCtx`. Step 2 then iterates every slot in `dirtyStorage` and calls `keeper.SetState` on `baseCtx` -- **unconditionally**, without comparing against current values:The stale allowance slot in `dirtyStorage` (from the `approve` before `bridgeOut`) overwrites the zeroed-out allowance that the inner burn wrote. The stale balance slot (from the `transfer` after `bridgeOut`) overwrites the zeroed-out balance. **The burn is erased.**Meanwhile, `SaveAssetsUnlocked` was called on `cachedCtx` during the precompile execution, persisting the `AssetsUnlockedEvent` to the bridge module's KV store. This event is flushed to `baseCtx` at Step 1 and survives Step 2 (different KV store prefix, not touched by `dirtyStorage`). The Ethereum sidecar observes this event and calls `AttestBridgeOut` on the L1 `MezoBridge` contract, releasing real tokens to the attacker's L1 address.The attack is **repeatable per-block**. Each execution costs only 1 wei (the `transfer` trigger) while draining the full `bridgeOut` amount from L1. There is no supply assertion for ERC-20 tokens (only `abtc` is checked in `verifyBTCSupply`), so no end-of-block invariant catches the mismatch.Impact DetailsCRITICAL - Using a fixed balance of e.g., 10,000 USDC on mezo an attacker can completly drain the USDC balance on ETH mainnet bridge via repeated exploit transactions on mezo.The Mezo bridge on L1 (0xF6680EA3b480cA2b72D96ea13cCAF2cFd8e6908c) holds approximatively ~1,753,958.4 USD worth of assets that can be stolen, taking into account offramp limit safeguard. Below are the details of asset that can be drained:cbBTC (mcbBTC: 0x6a7CD8E1384d49f502b4A4CE9aC9eb320835c5d7):limit: 900000000 (9 cbBTC)
decimals: 8
L1 bridge balance: 25.01605546 cbBTC
VALUE AT RISK: (9 cbBTC) 633,190.53 USDT (mT: 0xaaC423eDC4E3ee9ef81517e8093d52737165b71F):limit: 60000000000000000000000000 (60,000,000 T)
decimals: 18
L1 bridge balance: 426,294,138.843419368493748432 T
VALUE AT RISK: (60,000,000 T) 397,537.57 USD AT RISKUSDC (mUSDC: 0x04671C72Aab5AC02A03c1098314b1BB6B560c197):limit: 10000000000000 (10,000,000 USDC)
decimals: 6
L1 bridge balance: 508,549.393027 USDC
VALUE AT RISK: 508,549.39 USDC AT RISKUSDT (mUSDT: 0xeB5a5d39dE4Ea42C2Aa6A57EcA2894376683bB8E):limit: 10000000000000 (10,000,000 USDT)
decimals: 6
L1 bridge balance: 85,247.963024 USDT
VALUE AT RISK: 85,247.96 USDTxSolvBTC (mxSolvBTC: 0xdF708431162Ba247dDaE362D2c919e0fbAfcf9DE)limit: 90000000000000000000 (90 xSolvBTC)
decimals: 18
L1 bridge balance: 1.103740676354553591 xSolvBTC
VALUE AT RISK: 1.10374068 xSolvBTC (76,765.16 USD)SolvBTC (msolvBTC: 0xa10aD2570ea7b93d19fDae6Bd7189fF4929Bc747)limit 90000000000000000000 (90 solvBTC)
decimals: 18
L1 bridge balance: 0.496396263084933802 SolvBTC
VALUE AT RISK: 0.49639626 solvBTC (34,920.48 USD)FunctionBTC (mFBTC: 0x812fcC0Bb8C207Fd8D6165a7a1173037F43B2dB8)limit: 900000000 (9 FBTC)
decimals: 8
L1 bridge balance: 0.18064148 FBTC
VALUE AT RISK: 0.18064148 FBTC (12,741.73 USD)USDe (mUSDe: 0xdf6542260a9F768f07030E4895083F804241F4C4)limit: 1000000000000000000000000 (1,000,000.00 USDe)
decimals: 18
L1 bridge balance: 4,123.909662607937020208 USDe
VALUE AT RISK: 4,123.20 USDeswBTC (mswBTC: 0x29fA8F46CBB9562b87773c8f50a7F9F27178261c)limit: 900000000 (9 swBTC)
decimals: 8
L1 bridge balance: 0.02709065 swBTC
VALUE AT RISK: 0.02709065 (2,904.74 USD)DAI (mDAI: 0x1531b6e3d51BF80f634957dF81A990B92dA4b154)limit: 1000000000000000000000000 (1,000,000.00 DAI)
decimals: 18
L1 bridge balance: 882.421484670245389691 DAI
VALUE AT RISK: 882.38 DAI**Total assets currently at risk taking into consideration limits per ERC20 token and available drainable balance on the bridge: 1,753,958.4 USD of assets on L1.**References`x/evm/statedb/statedb.go:677-684` -- `Commit()` flushes cache context then writes `dirtyStorage`, allowing stale outer slots to overwrite inner burn results. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/evm/statedb/statedb.go#L677-L684`x/evm/statedb/statedb.go:655-674` -- `commit()` writes every `dirtyStorage` entry to the KV store unconditionally, without checking if the value has been superseded by the inner context flush. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/evm/statedb/statedb.go#L655-L674`x/evm/statedb/state_object.go:236-244` -- `GetCommittedState()` reads from `s.db.ctx` (the base context), returning stale pre-burn values for slots not yet flushed from `cachedCtx`. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/evm/statedb/state_object.go#L236-L244`precompile/contract.go:228` -- `CommitCacheContext()` writes draft storage to `cachedCtx` before precompile execution, but does not invalidate the outer StateDB's `dirtyStorage` or `originStorage`. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/precompile/contract.go#L228`precompile/contract.go:254-265` -- `syncJournalEntries()` only processes `SubBalance`/`AddBalance` entries, which `burnBitcoin` uses but `burnERC20` does not, creating the BTC/ERC-20 asymmetry. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/precompile/contract.go#L254-L265`precompile/assetsbridge/bridge_out.go:141-152` -- `burnERC20()` delegates to `ExecuteContractCall` without creating any journal entry, making the burn invisible to the outer StateDB. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/precompile/assetsbridge/bridge_out.go#L141-L152`precompile/assetsbridge/bridge_out.go:221-222` -- `burnBitcoin()` explicitly creates a `journal.SubBalance` entry, the mechanism absent from `burnERC20`. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/precompile/assetsbridge/bridge_out.go#L221-L222`x/evm/keeper/call.go:19-62` -- `ExecuteContractCall()` creates a new inner StateDB via `ApplyMessage(ctx, msg, tracer, true)`, where `true` means it commits the inner StateDB on completion. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/evm/keeper/call.go#L19-L62`x/evm/keeper/state_transition.go:390` -- `ApplyMessageWithConfig` creates a fresh `statedb.New(ctx, k, txConfig)` using the passed context (`cachedCtx`), isolating the inner state from the outer StateDB. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/evm/keeper/state_transition.go#L390`x/bridge/keeper/assets_unlocked.go:104-163` -- `SaveAssetsUnlocked()` persists the unlock event to the bridge KV store on `cachedCtx`, which survives the stale overwrite because it operates on a different store prefix. -> https://github.com/mezo-org/mezod/blob/17af5fdf29a6884d52e5ba6a1ee788c1f6e2a5ab/x/bridge/keeper/assets_unlocked.go#L104-L163FixThe root cause is that the outer StateDB's `dirtyStorage` is allowed to overwrite values that the inner StateDB (via `ExecuteContractCall`) has already committed to `cachedCtx`. Two complementary approaches:**Invalidate stale entries**: After `flushCache()` in `Commit()`, clear any `dirtyStorage` / `originStorage` entries that overlap with slots modified by the inner context. This prevents the blind overwrite in step 2.**Symmetric journal propagation**: Make `burnERC20` create journal entries (like `burnBitcoin` does) so that `syncJournalEntries()` reflects the balance and allowance changes in the outer StateDB. This removes the BTC/ERC-20 asymmetry entirely.Proof-of-Concept/ExploitFor a realistic, mainnet-like reproduction we will poc the attack on 4 validator cluster in local network.Video walkthrough -> https://mega.nz/file/rfYiEDoY#q53XXKdkBe8jIcOueAohbFHt9UkggTgKGIK41iEMSBAgit clone latest release of `mezod` repo (v7.0.0)clean any previous localnet and binary.build the binary and initialize the localnet.Start each validator in separate terminals.At this point wait atleast 5 blocks while monitoring the logs to see if the localnet is ready.install solidity/hardhat deps (if first time):In `mezod/solidity/hardhat.config.ts` add the localnet in network code branch:Create the `ExploitContract.sol` file in `solidity/contracts` path, and add the following contract:I've put extensive explanation about the attack contract see commentsCreate the `poc_exploit.ts` file in `solidity/scripts` path and paste the following scipt:And create the `run_poc.sh` file in `solidity/scripts` path and paste the followin script:Finally run the `run_poc.sh` script, which intiate the whole attack operation automatically.**Output:**The `run_poc.sh` script handles everything automatically:Waits for the JSON-RPC endpoint (localhost:8545) to be reachableExtracts private keys for node0 (deployer/admin) and node1 (attacker) from the localnet keyringWrites them to solidity/.envRuns poc_exploit.ts via Hardhat against the localnet network**What the script does:**deployer (node0) deploys the production mUSDC proxy contract, registers it with the AssetsBridge precompile using real production parameters, mints supply, and sends 20,000 USDC to the attackerattacker (node1) deploys ExploitContract and funds it with 20,000 USDCThe exploit runs 5 rounds -- each round calls exploit() which atomically does approve → bridgeOut → transfer(1 wei) in a single transactionAfter each round, the script verifies the AssetsUnlocked event was emitted and logs the balance**this issue is valid and currently exploitable leading to draining the ERC20 assets from the L1 bridge balance at no actual cost.**
CVSS v4.0: 8.8